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:
+155
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user