Sign in through the server's single sign-on, with a code
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s

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:
Niklas Ye
2026-09-26 21:47:13 +02:00
parent 057302cb39
commit ee25552a53
11 changed files with 970 additions and 12 deletions
+111 -1
View File
@@ -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.