Sign in through the server's single sign-on, with a code
The sign-in screen asks the server how it can be signed in to (GET /api/auth/config) and offers what it finds: the password form, and "Sign in with <provider>" when the server can do a device login. The TUI shows a link and a short code, the person approves it in any browser, and the next poll hands over the ordinary session, so it works over SSH where no browser can be opened. The terminal never talks to the identity provider. The password form is hidden when the server has turned password login off. `auth: sso` in config.yaml starts the SSO login straight away, but not right after signing out, where that would sign the person straight back in; any other value is refused when the config is read. Polling honours the server's interval, backs off on slow_down, and gives up after repeated failures rather than retrying forever. A server without /api/auth/config answers 404 and is treated as passwords only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0 for SSO.
This commit is contained in:
+111
-1
@@ -126,6 +126,48 @@ type connectedMsg struct {
|
||||
type connectErrMsg struct{ err error }
|
||||
type loginDoneMsg struct{}
|
||||
type loginErrMsg struct{ err error }
|
||||
|
||||
// Single sign-on. A device login is a chain: the server hands out a code
|
||||
// (deviceStartedMsg), the client waits out the interval (devicePollMsg), asks
|
||||
// (devicePendingMsg, or loginDoneMsg on approval), and waits again. Every message
|
||||
// carries the attempt it belongs to, so the late answers of an attempt that was
|
||||
// cancelled or replaced are dropped rather than acted on.
|
||||
type authConfigMsg struct{ cfg api.AuthConfig }
|
||||
type deviceStartedMsg struct {
|
||||
attempt int
|
||||
login api.DeviceLogin
|
||||
}
|
||||
type devicePollMsg struct{ attempt int }
|
||||
type devicePendingMsg struct {
|
||||
attempt int
|
||||
slower bool // the server asked for fewer polls
|
||||
err error // a poll that failed in a way worth retrying (network, 5xx)
|
||||
}
|
||||
type deviceFailedMsg struct {
|
||||
attempt int
|
||||
err error
|
||||
}
|
||||
|
||||
// ssoLogin is a device login in progress; the zero value is none. attempt only
|
||||
// ever goes up: starting, cancelling and finishing all bump it, which is what
|
||||
// makes the messages of an earlier attempt stale.
|
||||
type ssoLogin struct {
|
||||
attempt int
|
||||
active bool // started, and not yet cancelled, failed or finished
|
||||
login *api.DeviceLogin // nil until the server has answered
|
||||
// interval is the wait between polls: the server's, lengthened when it says
|
||||
// it is being asked too often.
|
||||
interval time.Duration
|
||||
// failures counts polls in a row that failed for a reason other than "not
|
||||
// yet", so a dead connection ends the wait instead of spinning forever.
|
||||
failures int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultDevicePoll = 5 * time.Second
|
||||
maxPollFailures = 3
|
||||
)
|
||||
|
||||
type logoutDoneMsg struct{}
|
||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
@@ -228,6 +270,12 @@ type Model struct {
|
||||
loginNote string
|
||||
ticking bool
|
||||
|
||||
// How the server can be signed in to, nil until it has answered. authPref is
|
||||
// the config's `auth`, which only chooses among what the server offers.
|
||||
authInfo *api.AuthConfig
|
||||
authPref string
|
||||
sso ssoLogin
|
||||
|
||||
// Connection & dashboard
|
||||
connected bool
|
||||
loading bool
|
||||
@@ -471,6 +519,13 @@ func (m Model) WithDefaultTeam(team string) Model {
|
||||
return m
|
||||
}
|
||||
|
||||
// WithAuth sets the default way to sign in, from the config's `auth`: "sso"
|
||||
// starts a single sign-on login by itself when the server offers one.
|
||||
func (m Model) WithAuth(pref string) Model {
|
||||
m.authPref = pref
|
||||
return m
|
||||
}
|
||||
|
||||
// WithLogin prefills the sign-in form's username and sets a note shown above it.
|
||||
func (m Model) WithLogin(username, note string) Model {
|
||||
m.loginInputs[loginUsername].SetValue(username)
|
||||
@@ -488,7 +543,10 @@ func (m Model) WithLogin(username, note string) Model {
|
||||
// already showing and there is nothing to do until it is submitted.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.mode == modeLogin {
|
||||
return nil
|
||||
// Ask how the server can be signed in to, so the form offers the right
|
||||
// thing. Nothing depends on the answer arriving: the password form is
|
||||
// already usable.
|
||||
return authConfigCmd(m.client)
|
||||
}
|
||||
return connectCmd(m.client)
|
||||
}
|
||||
@@ -1053,6 +1111,58 @@ func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd
|
||||
}
|
||||
}
|
||||
|
||||
// authConfigCmd asks how the server can be signed in to. A server too old to be
|
||||
// asked, or one that cannot be reached, is treated as offering passwords only: the
|
||||
// form that always existed is the safe fallback, and it reports a real connection
|
||||
// problem itself when it is submitted.
|
||||
func authConfigCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cfg, err := client.AuthConfig()
|
||||
if err != nil {
|
||||
cfg = api.AuthConfig{PasswordLogin: true}
|
||||
}
|
||||
return authConfigMsg{cfg}
|
||||
}
|
||||
}
|
||||
|
||||
// startDeviceCmd asks the server to begin a device login.
|
||||
func startDeviceCmd(client *api.Client, attempt int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
login, err := client.StartDeviceLogin()
|
||||
if err != nil {
|
||||
return deviceFailedMsg{attempt, err}
|
||||
}
|
||||
return deviceStartedMsg{attempt, *login}
|
||||
}
|
||||
}
|
||||
|
||||
// devicePollAfter waits out the interval before the next poll.
|
||||
func devicePollAfter(attempt int, interval time.Duration) tea.Cmd {
|
||||
return tea.Tick(interval, func(time.Time) tea.Msg { return devicePollMsg{attempt} })
|
||||
}
|
||||
|
||||
// pollDeviceCmd asks whether the login has been approved. Approval saves the
|
||||
// session the way a password sign-in does, and ends in the same loginDoneMsg.
|
||||
func pollDeviceCmd(client *api.Client, serverURL string, attempt int, deviceCode string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
token, err := client.PollDeviceLogin(deviceCode)
|
||||
switch {
|
||||
case err == nil:
|
||||
_ = session.Save(serverURL, token)
|
||||
return loginDoneMsg{}
|
||||
case errors.Is(err, api.ErrDevicePending):
|
||||
return devicePendingMsg{attempt: attempt}
|
||||
case errors.Is(err, api.ErrDeviceSlowDown):
|
||||
return devicePendingMsg{attempt: attempt, slower: true}
|
||||
case errors.Is(err, api.ErrDeviceExpired), errors.Is(err, api.ErrDeviceDenied):
|
||||
return deviceFailedMsg{attempt, err}
|
||||
}
|
||||
// Anything else is the network or the server having a moment, which a
|
||||
// person waiting on a browser should not have to start over for.
|
||||
return devicePendingMsg{attempt: attempt, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// logoutCmd ends the session on the server and deletes the saved one. The saved
|
||||
// copy goes even when the server cannot be reached, because the person asked to
|
||||
// be signed out and a token left on disk would say otherwise.
|
||||
|
||||
Reference in New Issue
Block a user