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
+155 -5
View File
@@ -7,6 +7,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard"
@@ -20,7 +21,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// was being done cannot succeed, so go back to the sign-in form and say why,
// rather than leaving every action to fail with "server returned 401".
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd()
m, entry := m.requireLogin("your session has ended — sign in again").enterLogin(true)
return m, tea.Batch(forgetSessionCmd(), entry)
}
switch msg := msg.(type) {
@@ -45,6 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case loginDoneMsg:
m.loggingIn = false
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
m.loginErr = ""
m.loginNote = ""
m.loginInputs[loginPassword].Reset()
@@ -61,7 +64,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case logoutDoneMsg:
return m.requireLogin("you have signed out"), nil
// Not auto-started even with auth: sso: somebody who has just signed out
// did not ask to be signed straight back in.
return m.requireLogin("you have signed out").enterLogin(false)
case authConfigMsg:
cfg := msg.cfg
m.authInfo = &cfg
if m.mode == modeLogin && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
case deviceStartedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
login := msg.login
m.sso.login = &login
m.sso.interval = time.Duration(login.Interval) * time.Second
if m.sso.interval <= 0 {
m.sso.interval = defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case devicePollMsg:
if !m.sso.current(msg.attempt) || m.sso.login == nil {
return m, nil
}
return m, pollDeviceCmd(m.client, m.serverURL, m.sso.attempt, m.sso.login.DeviceCode)
case devicePendingMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
if msg.err != nil {
if m.sso.failures++; m.sso.failures >= maxPollFailures {
return m.failSSO(msg.err), nil
}
} else {
m.sso.failures = 0
}
if msg.slower {
m.sso.interval += defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case deviceFailedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
return m.failSSO(msg.err), nil
case connectedMsg:
firstConnect := len(m.teams) == 0
@@ -376,7 +429,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeLogin:
var inputCmd tea.Cmd
if !m.loggingIn {
if !m.loggingIn && !m.sso.active && m.offersPasswords() {
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
@@ -1485,6 +1538,7 @@ func (m Model) requireLogin(note string) Model {
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
fresh.width, fresh.height = m.width, m.height
fresh.defaultTeam = m.defaultTeam
fresh.authInfo, fresh.authPref = m.authInfo, m.authPref
fresh.ticking = m.ticking
fresh.mode = modeLogin
fresh.loginInputs[loginUsername].SetValue(name)
@@ -1530,12 +1584,108 @@ func loginErrorText(err error) string {
return err.Error()
}
// ── Single sign-on ────────────────────────────────────────────────────────────
// current reports whether a message belongs to the attempt in progress. Anything
// else is the late answer of one that was cancelled, replaced or finished.
func (s ssoLogin) current(attempt int) bool { return s.active && attempt == s.attempt }
// canSSO is whether the server can sign in a client with no browser.
func (m Model) canSSO() bool { return m.authInfo != nil && m.authInfo.DeviceLogin }
// offersPasswords is whether the password form is worth showing. Until the
// server has answered it is: the form is what a server too old to be asked has.
func (m Model) offersPasswords() bool { return m.authInfo == nil || m.authInfo.PasswordLogin }
// autoStartsSSO is whether the form should start a single sign-on login by
// itself: when the config asks for it, and when the server has no passwords, so
// that there is nothing else to show.
func (m Model) autoStartsSSO() bool {
return m.canSSO() && !m.sso.active && (m.authPref == "sso" || !m.offersPasswords())
}
// ssoName is what the provider is called on screen.
func (m Model) ssoName() string {
if m.authInfo != nil && m.authInfo.OIDC.Name != "" {
return m.authInfo.OIDC.Name
}
return "single sign-on"
}
// enterLogin is what to do on arriving at the sign-in form other than by
// starting up: learn how the server can be signed in to if that is not known,
// and, when auto is set, start a single sign-on login if the config or the
// server's lack of passwords calls for one.
func (m Model) enterLogin(auto bool) (Model, tea.Cmd) {
if m.authInfo == nil {
return m, authConfigCmd(m.client)
}
if auto && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
}
// startSSO begins a device login, replacing any earlier attempt.
func (m Model) startSSO() (Model, tea.Cmd) {
m.sso = ssoLogin{attempt: m.sso.attempt + 1, active: true}
m.loginErr = ""
return m, startDeviceCmd(m.client, m.sso.attempt)
}
// cancelSSO abandons the attempt in progress. The server forgets the login when
// it expires; there is nothing to tell it.
func (m Model) cancelSSO() Model {
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
return m
}
// failSSO ends the attempt and says why on the form.
func (m Model) failSSO(err error) Model {
m = m.cancelSSO()
m.loginErr = ssoErrorText(err, m.ssoName())
return m
}
// ssoErrorText turns a failed single sign-on into something to act on.
func ssoErrorText(err error, name string) string {
var se *api.StatusError
switch {
case errors.Is(err, api.ErrDeviceExpired):
return "the sign-in expired before it was approved — start it again"
case errors.Is(err, api.ErrDeviceDenied):
return "the sign-in was refused in the browser"
case errors.As(err, &se) && se.Code == http.StatusNotFound:
return "this server does not offer sign-in with " + name
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
return "too many attempts — wait a few minutes and try again"
}
return "sign-in with " + name + " failed: " + err.Error()
}
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
case "ctrl+c":
return m, tea.Quit
case "esc":
// Backs out of a single sign-on wait before it quits the program, so a
// wrong turn does not cost the session.
if m.sso.active {
return m.cancelSSO(), nil
}
return m, tea.Quit
}
if m.loggingIn {
if m.loggingIn || m.sso.active {
return m, nil
}
if msg.String() == "ctrl+o" && m.canSSO() {
return m.startSSO()
}
// With no password form there is one thing to do, and enter does it.
if msg.String() == "enter" && !m.offersPasswords() {
if m.canSSO() {
return m.startSSO()
}
return m, nil
}