From ee25552a53a198eb06d455a04f1811448f97e3e8 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sat, 26 Sep 2026 21:47:13 +0200 Subject: [PATCH] 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 " 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. --- internal/api/client.go | 92 ++++++++ internal/api/client_test.go | 103 +++++++++ internal/api/types.go | 29 +++ internal/config/config.go | 16 +- internal/config/config_test.go | 23 ++ internal/tui/login_test.go | 17 +- internal/tui/model.go | 112 +++++++++- internal/tui/sso_test.go | 378 +++++++++++++++++++++++++++++++++ internal/tui/update.go | 160 +++++++++++++- internal/tui/view.go | 51 ++++- main.go | 1 + 11 files changed, 970 insertions(+), 12 deletions(-) create mode 100644 internal/tui/sso_test.go diff --git a/internal/api/client.go b/internal/api/client.go index 81503e2..f000c28 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -79,6 +79,98 @@ func (c *Client) Login(username, password string) (string, error) { return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie) } +// AuthConfig asks how the server can be signed in to. It is unauthenticated, so +// it works before anybody has signed in. +func (c *Client) AuthConfig() (AuthConfig, error) { + var cfg AuthConfig + req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil) + if err != nil { + return cfg, err + } + req.Header.Set("Accept", "application/json") + err = c.do(req, &cfg) + return cfg, err +} + +// The ways a device login poll can end other than with a session. +var ( + // ErrDevicePending means nobody has approved yet: poll again after the + // interval. + ErrDevicePending = errors.New("waiting for approval") + + // ErrDeviceSlowDown means the server was polled faster than it asked. It is + // not a failure; poll again, a little slower. + ErrDeviceSlowDown = errors.New("polling too fast") + + // ErrDeviceExpired means the person took too long, or the server forgot the + // login. ErrDeviceDenied means they refused it. + ErrDeviceExpired = errors.New("the sign-in expired") + ErrDeviceDenied = errors.New("the sign-in was refused") +) + +// StartDeviceLogin asks the server to begin a device login. +func (c *Client) StartDeviceLogin() (*DeviceLogin, error) { + req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{}) + if err != nil { + return nil, err + } + req.Header.Del("Cookie") + var d DeviceLogin + if err := c.do(req, &d); err != nil { + return nil, err + } + if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" { + return nil, errors.New("server started a sign-in but sent no code") + } + return &d, nil +} + +// PollDeviceLogin asks whether the person has approved. On approval it returns +// the session token, which the client also keeps; until then it returns one of +// the ErrDevice* errors. +func (c *Client) PollDeviceLogin(deviceCode string) (string, error) { + req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token", + struct { + DeviceCode string `json:"device_code"` + }{deviceCode}) + if err != nil { + return "", err + } + req.Header.Del("Cookie") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusAccepted: + return "", ErrDevicePending + case http.StatusTooManyRequests: + return "", ErrDeviceSlowDown + case http.StatusGone: + var e struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error == "denied" { + return "", ErrDeviceDenied + } + return "", ErrDeviceExpired + } + if resp.StatusCode >= 400 { + return "", statusError(resp) + } + for _, ck := range resp.Cookies() { + if ck.Name == SessionCookie && ck.Value != "" { + c.session = ck.Value + return ck.Value, nil + } + } + return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie) +} + // Logout ends the session on the server and forgets it here. func (c *Client) Logout() error { req, err := c.newRequest(http.MethodPost, "/api/logout") diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 934876c..6616fa8 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -584,3 +584,106 @@ func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) { t.Errorf("message changed: %q", err.Error()) } } + +func TestAuthConfig_ReadsWhatTheServerOffers(t *testing.T) { + c, got := stub(t, http.StatusOK, + `{"password_login":false,"oidc":{"enabled":true,"name":"Authentik"},"device_login":true}`) + cfg, err := c.AuthConfig() + if err != nil { + t.Fatal(err) + } + if got.method != http.MethodGet || got.path != "/api/auth/config" { + t.Errorf("wrong request: %s %s", got.method, got.path) + } + if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" || !cfg.DeviceLogin { + t.Errorf("config: %+v", cfg) + } +} + +func TestAuthConfig_OldServerAnswers404(t *testing.T) { + c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`) + _, err := c.AuthConfig() + var se *StatusError + if !errors.As(err, &se) || se.Code != http.StatusNotFound { + t.Errorf("want a 404 StatusError, got %v", err) + } +} + +func TestStartDeviceLogin_SendsNoSessionAndReturnsTheCodes(t *testing.T) { + c, got := stub(t, http.StatusOK, `{"device_code":"dev","user_code":"BCDF-GHJK", + "verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`) + d, err := c.StartDeviceLogin() + if err != nil { + t.Fatal(err) + } + if got.method != http.MethodPost || got.path != "/api/oidc/device" { + t.Errorf("wrong request: %s %s", got.method, got.path) + } + // A stale session must not ride along on a request that replaces it. + if got.cookie != "" { + t.Errorf("sent the old session %q", got.cookie) + } + if d.DeviceCode != "dev" || d.UserCode != "BCDF-GHJK" || d.Interval != 5 || d.ExpiresIn != 600 || + d.VerificationURL != "https://terdut.example.com/device?code=BCDF-GHJK" { + t.Errorf("login: %+v", d) + } +} + +func TestStartDeviceLogin_AReplyWithoutCodesIsAnError(t *testing.T) { + c, _ := stub(t, http.StatusOK, `{}`) + if _, err := c.StartDeviceLogin(); err == nil { + t.Error("an empty reply must not be taken for a started login") + } +} + +func TestPollDeviceLogin_Outcomes(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want error + }{ + {"pending", http.StatusAccepted, `{"status":"pending"}`, ErrDevicePending}, + {"slow down", http.StatusTooManyRequests, `{"error":"slow_down"}`, ErrDeviceSlowDown}, + {"expired", http.StatusGone, `{"error":"expired"}`, ErrDeviceExpired}, + {"denied", http.StatusGone, `{"error":"denied"}`, ErrDeviceDenied}, + } { + t.Run(tc.name, func(t *testing.T) { + c, got := stub(t, tc.status, tc.body) + tok, err := c.PollDeviceLogin("dev") + if !errors.Is(err, tc.want) || tok != "" { + t.Errorf("got %q, %v; want %v", tok, err, tc.want) + } + if got.path != "/api/oidc/device/token" || !strings.Contains(got.body, `"device_code":"dev"`) { + t.Errorf("wrong request: %s %s", got.path, got.body) + } + if c.HasSession() && c.session == "" { + t.Error("session state corrupted") + } + }) + } +} + +func TestPollDeviceLogin_ApprovalKeepsTheSessionFromTheCookie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "granted"}) + io.WriteString(w, `{"user":{}}`) + })) + t.Cleanup(srv.Close) + c := NewClient(srv.URL) + tok, err := c.PollDeviceLogin("dev") + if err != nil || tok != "granted" { + t.Fatalf("got %q, %v", tok, err) + } + if !c.HasSession() { + t.Error("the client must keep the session it was given") + } +} + +func TestPollDeviceLogin_ApprovalWithoutACookieIsAnError(t *testing.T) { + c, _ := stub(t, http.StatusOK, `{"user":{}}`) + c.SetSession("") + if _, err := c.PollDeviceLogin("dev"); err == nil { + t.Error("a 200 with no session cookie is not a sign-in") + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 602c3ac..6e4ba7b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -32,6 +32,35 @@ type Alert struct { ResolutionSource *string `json:"resolution_source,omitempty"` } +// AuthConfig is how the server can be signed in to, from the unauthenticated +// GET /api/auth/config. A server too old to have the endpoint answers 404, which +// callers treat as "passwords only". +type AuthConfig struct { + PasswordLogin bool `json:"password_login"` + OIDC struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` + } `json:"oidc"` + + // DeviceLogin is whether the server can sign in a client that has no browser, + // by showing a code (see StartDeviceLogin). + DeviceLogin bool `json:"device_login"` +} + +// DeviceLogin is a sign-in the server has started for this client: the person +// opens VerificationURL, checks UserCode, and approves; the client polls with +// DeviceCode until the server hands over a session. +type DeviceLogin struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURL string `json:"verification_url"` + + // Interval is how many seconds to wait between polls, and ExpiresIn how many + // the person has to approve. + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` +} + // Incident statuses. const ( StatusTriggered = "triggered" diff --git a/internal/config/config.go b/internal/config/config.go index 2615fa0..9a7c069 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,12 @@ type Config struct { // Team is the team to start on, by name or id. Empty shows every team the // key's user belongs to. Team string + + // Auth is how to sign in when the server offers a choice: "sso" starts a + // single sign-on login straight away, "password" (or empty) shows the + // password form. The server decides what is on offer; this only picks the + // default among it. + Auth string } type rawConfig struct { @@ -33,6 +39,7 @@ type rawConfig struct { RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds Theme string `yaml:"theme,omitempty"` Team string `yaml:"team,omitempty"` + Auth string `yaml:"auth,omitempty"` } func Load() (*Config, error) { @@ -45,7 +52,7 @@ func Load() (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path) + return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on\n auth: sso # optional, sso or password: how to sign in by default", path) } return nil, fmt.Errorf("cannot read config file: %w", err) } @@ -59,6 +66,12 @@ func Load() (*Config, error) { return nil, fmt.Errorf("config: 'server_url' is required") } + switch raw.Auth { + case "", "password", "sso": + default: + return nil, fmt.Errorf("config: 'auth' must be sso or password, not %q", raw.Auth) + } + interval := defaultRefreshInterval if raw.RefreshInterval > 0 { interval = time.Duration(raw.RefreshInterval) * time.Second @@ -71,5 +84,6 @@ func Load() (*Config, error) { RefreshInterval: interval, Theme: raw.Theme, Team: raw.Team, + Auth: raw.Auth, }, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 56cbc54..2175e04 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,3 +59,26 @@ func TestLoad_NoAPIKeyNeeded(t *testing.T) { t.Error("expected the leftover api_key to be noted") } } + +func TestLoad_AuthIsOptionalAndChecked(t *testing.T) { + for _, tc := range []struct { + yaml, want string + bad bool + }{ + {"", "", false}, + {"auth: password\n", "password", false}, + {"auth: sso\n", "sso", false}, + {"auth: oidc\n", "", true}, + } { + writeConfig(t, "server_url: https://terdut.example.com\n"+tc.yaml) + cfg, err := Load() + switch { + case tc.bad && err == nil: + t.Errorf("%q: expected an error", tc.yaml) + case !tc.bad && err != nil: + t.Errorf("%q: %v", tc.yaml, err) + case !tc.bad && cfg.Auth != tc.want: + t.Errorf("%q: auth %q, want %q", tc.yaml, cfg.Auth, tc.want) + } + } +} diff --git a/internal/tui/login_test.go b/internal/tui/login_test.go index 1bebc69..eb14d81 100644 --- a/internal/tui/login_test.go +++ b/internal/tui/login_test.go @@ -27,8 +27,21 @@ func TestStartsOnTheFormWithoutASession(t *testing.T) { if m.mode != modeLogin { t.Fatalf("expected the sign-in form, got mode %v", m.mode) } - if m.Init() != nil { - t.Error("with no session there is nothing to connect with yet") + // Nothing is connected without a session. The one thing started is asking + // how the server can be signed in to, and a server too old to be asked (404) + // must leave the password form as it was. + srv := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + cmd := signedOut(srv.URL).Init() + if cmd == nil { + t.Fatal("expected the form to ask the server how it can be signed in to") + } + msg, ok := cmd().(authConfigMsg) + if !ok { + t.Fatalf("expected authConfigMsg, got %#v", cmd()) + } + if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin { + t.Errorf("an old server offers passwords only, got %+v", msg.cfg) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index b1c4f56..c4e1422 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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. diff --git a/internal/tui/sso_test.go b/internal/tui/sso_test.go new file mode 100644 index 0000000..68f40d2 --- /dev/null +++ b/internal/tui/sso_test.go @@ -0,0 +1,378 @@ +package tui + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/session" + tea "github.com/charmbracelet/bubbletea" +) + +// fakeServer is the device-login half of terdut-server: it starts a login, +// answers polls with whatever poll says, and records what it was asked. +type fakeServer struct { + *httptest.Server + mu sync.Mutex + polls int + // poll is called for each poll and writes the response. + poll func(w http.ResponseWriter, n int) +} + +func newFakeServer(t *testing.T) *fakeServer { + t.Helper() + f := &fakeServer{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/oidc/device": + io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK", + "verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`) + case "/api/oidc/device/token": + f.mu.Lock() + f.polls++ + n := f.polls + f.mu.Unlock() + var body struct { + DeviceCode string `json:"device_code"` + } + json.NewDecoder(r.Body).Decode(&body) + if body.DeviceCode != "dev-1" { + t.Errorf("polled with %q", body.DeviceCode) + } + f.poll(w, n) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(f.Close) + return f +} + +func pending(w http.ResponseWriter, _ int) { + w.WriteHeader(http.StatusAccepted) + io.WriteString(w, `{"status":"pending"}`) +} + +// offering is a signed-out model that has been told what the server offers. +func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) { + t.Helper() + m := signedOut(url).WithAuth(pref) + next, cmd := m.Update(authConfigMsg{cfg}) + return next.(Model), cmd +} + +func both() api.AuthConfig { + c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true} + c.OIDC.Enabled, c.OIDC.Name = true, "Authentik" + return c +} + +func ssoOnly() api.AuthConfig { + c := both() + c.PasswordLogin = false + return c +} + +func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + return next.(Model), cmd +} + +func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} } + +func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) { + m, cmd := offering(t, "http://test", both(), "") + if cmd != nil || m.sso.active { + t.Fatal("with passwords on offer nothing should start until asked") + } + view := m.View() + for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} { + if !strings.Contains(view, want) { + t.Errorf("expected %q on the form:\n%s", want, view) + } + } +} + +func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) { + m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "") + if strings.Contains(m.View(), "ctrl+o") { + t.Error("a server with no SSO must not advertise it") + } + if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active { + t.Error("ctrl+o must do nothing when the server has no SSO") + } +} + +func TestSSO_FullFlow(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + f := newFakeServer(t) + f.poll = func(w http.ResponseWriter, n int) { + if n < 3 { + pending(w, n) + return + } + http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"}) + io.WriteString(w, `{"user":{}}`) + } + m, _ := offering(t, f.URL, both(), "") + + // ctrl+o asks the server for a login. + m, cmd := update(t, m, ctrlO()) + if !m.sso.active || cmd == nil { + t.Fatal("ctrl+o should start a single sign-on login") + } + if !strings.Contains(m.View(), "Contacting the server") { + t.Errorf("before the server answers:\n%s", m.View()) + } + started, ok := cmd().(deviceStartedMsg) + if !ok { + t.Fatalf("expected deviceStartedMsg, got %#v", cmd()) + } + + // The link and the code are shown, and a poll is scheduled. + m, cmd = update(t, m, started) + if cmd == nil || m.sso.interval != 5*time.Second { + t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval) + } + view := m.View() + for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK", + "BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} { + if !strings.Contains(view, want) { + t.Errorf("expected %q while waiting:\n%s", want, view) + } + } + if strings.Contains(view, "Username:") { + t.Error("the password form must be out of the way while waiting") + } + + // Two polls that find nothing, each scheduling the next. + for i := 1; i <= 2; i++ { + m, cmd = update(t, m, devicePollMsg{m.sso.attempt}) + if cmd == nil { + t.Fatalf("poll %d: expected a request", i) + } + pend, ok := cmd().(devicePendingMsg) + if !ok { + t.Fatalf("poll %d: expected devicePendingMsg", i) + } + if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active { + t.Fatalf("poll %d: expected to keep waiting", i) + } + } + + // The third is approved: the session is saved and it moves on and connects. + m, cmd = update(t, m, devicePollMsg{m.sso.attempt}) + done := cmd() + if _, ok := done.(loginDoneMsg); !ok { + t.Fatalf("expected loginDoneMsg, got %#v", done) + } + if got := session.Load(f.URL); got != "tok-sso" { + t.Errorf("session saved for next time: %q", got) + } + m, connect := update(t, m, done) + if m.mode != modeDashboard || m.sso.active || connect == nil { + t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active) + } + if f.polls != 3 { + t.Errorf("%d polls, want 3", f.polls) + } +} + +func TestSSO_EscCancelsBeforeItQuits(t *testing.T) { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}}) + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil { + t.Fatal("the first esc backs out of the wait; it must not quit") + } + if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") { + t.Errorf("expected the password form back, active %v", m.sso.active) + } + if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil { + t.Error("esc on the form quits, as it always did") + } else if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("expected a quit, got %#v", cmd()) + } +} + +// The answers of an attempt that was cancelled arrive late and must change nothing. +func TestSSO_StaleMessagesAreIgnored(t *testing.T) { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + old := m.sso.attempt + m = m.cancelSSO() + + for name, msg := range map[string]tea.Msg{ + "started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}}, + "poll": devicePollMsg{old}, + "pending": devicePendingMsg{attempt: old}, + "failed": deviceFailedMsg{old, api.ErrDeviceExpired}, + } { + next, cmd := update(t, m, msg) + if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" { + t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr) + } + } + + // A new attempt is not confused by the old one's messages either. + m, _ = update(t, m, ctrlO()) + if m.sso.attempt == old { + t.Fatal("a new attempt must have a new number") + } + if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil { + t.Error("the old attempt's poll must not run in the new one") + } +} + +func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) { + m, cmd := offering(t, "http://test", ssoOnly(), "") + if !m.sso.active || cmd == nil { + t.Fatal("a server with no passwords should start signing in with SSO straight away") + } + m = m.cancelSSO() + view := m.View() + if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") { + t.Errorf("no password form on an SSO-only server:\n%s", view) + } + if !strings.Contains(view, "enter·sign in with Authentik") { + t.Errorf("the footer should say what enter does:\n%s", view) + } + if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil { + t.Error("enter should start it again") + } +} + +func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) { + if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil { + t.Error("auth: sso should start by itself when the server offers it") + } + // ...and shows the password form when it does not, rather than a dead end. + m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso") + if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") { + t.Error("auth: sso against a server without SSO must fall back to the form") + } + if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil { + t.Error("auth: password must not start SSO") + } +} + +func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) { + for name, tc := range map[string]struct { + err error + want string + }{ + "expired": {api.ErrDeviceExpired, "expired"}, + "refused": {api.ErrDeviceDenied, "refused"}, + "no sso": {&api.StatusError{Code: 404}, "does not offer"}, + "limited": {&api.StatusError{Code: 429}, "too many"}, + "other": {errors.New("dial tcp: refused"), "Authentik failed"}, + } { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err}) + if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) { + t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want) + } + if !strings.Contains(m.View(), tc.want) { + t.Errorf("%s: the reason is not shown:\n%s", name, m.View()) + } + } +} + +func TestSSO_SlowDownLengthensTheInterval(t *testing.T) { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}}) + m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true}) + if m.sso.interval != 10*time.Second || cmd == nil { + t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil) + } +} + +func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}}) + blip := errors.New("connection reset") + + // Two failures, then a good answer: the count starts over. + for range 2 { + m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip}) + } + m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt}) + if !m.sso.active || m.sso.failures != 0 { + t.Fatalf("a good answer should reset the failures: %+v", m.sso) + } + + // Three in a row is a dead connection. + var cmd tea.Cmd + for range maxPollFailures { + m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip}) + } + if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") { + t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr) + } +} + +func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) { + m, _ := offering(t, "http://test", both(), "") + m, _ = update(t, m, ctrlO()) + m = typeInto(t, m, "hunter2") + if got := m.loginInputs[m.loginFocus].Value(); got != "" { + t.Errorf("keys typed during the wait ended up in a field: %q", got) + } + if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil { + t.Error("enter during the wait must not start anything") + } +} + +// After the session ends the form comes back; it must still know what the +// server offers, and follow the config's preference without a second question. +func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) { + m, _ := offering(t, "http://test", both(), "sso") + m = m.cancelSSO() + m.mode = modeDashboard // signed in, as loginDoneMsg leaves it + m, _ = update(t, m, connectedMsg{}) + m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}}) + if m.mode != modeLogin || m.authInfo == nil { + t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo) + } + if !m.sso.active || cmd == nil { + t.Error("with auth: sso an ended session should go straight to SSO") + } +} + +func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) { + m, _ := offering(t, "http://test", both(), "sso") + m = m.cancelSSO() + m.mode = modeDashboard + m, _ = update(t, m, connectedMsg{}) + m, cmd := update(t, m, logoutDoneMsg{}) + if m.mode != modeLogin || m.sso.active || cmd != nil { + t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active) + } +} + +// A session that ends before the server was ever asked (the TUI started on a +// saved session) still has to learn what to offer. +func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) { + c := api.NewClient("http://test") + c.SetSession("saved") + m := NewModel(c, "http://test", time.Minute, signedOut("x").theme) + m.width, m.height = 120, 40 + m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}}) + if m.mode != modeLogin || m.authInfo != nil || cmd == nil { + t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index e3e8b76..d66abd0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 } diff --git a/internal/tui/view.go b/internal/tui/view.go index 8a338c5..e4c09a2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -42,17 +42,62 @@ func (m Model) renderLogin() string { if m.loginNote != "" { b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n") } - b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n") - b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n") + if m.sso.active { + b.WriteString(m.renderSSOWait()) + return b.String() + } + if m.offersPasswords() { + b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n") + b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n") + } else { + b.WriteString(m.styles.Muted.Render(" This server signs in with "+m.ssoName()+".") + "\n\n") + } switch { case m.loggingIn: b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n") case m.loginErr != "": b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n") } + if m.canSSO() && m.offersPasswords() { + b.WriteString("\n" + m.styles.Muted.Render(" or press ctrl+o to sign in with "+m.ssoName()) + "\n") + } return b.String() } +// renderSSOWait is the single sign-on screen: the link to open and the code to +// check against it, while the client waits for the approval. +func (m Model) renderSSOWait() string { + var b strings.Builder + l := m.sso.login + if l == nil { + b.WriteString(m.styles.Muted.Render(" Contacting the server…") + "\n") + return b.String() + } + b.WriteString(" " + m.styles.Header.Render("Sign in with "+m.ssoName()) + "\n\n") + b.WriteString(" Open this link in a browser, on any device, and approve the sign-in:\n\n") + b.WriteString(" " + m.styles.Accent.Render(l.VerificationURL) + "\n\n") + b.WriteString(" " + m.styles.Header.Render("Code: ") + m.styles.Bold.Render(l.UserCode) + + m.styles.Muted.Render(" it should match the code on that page") + "\n\n") + b.WriteString(m.styles.Muted.Render(fmt.Sprintf(" Waiting for approval… good for %d minutes", (l.ExpiresIn+59)/60)) + "\n") + return b.String() +} + +// loginHelp is the sign-in footer, which depends on what the server offers. +func (m Model) loginHelp() string { + switch { + case m.sso.active: + return " esc·cancel" + case !m.offersPasswords(): + if m.canSSO() { + return " enter·sign in with " + m.ssoName() + " esc·quit" + } + return " esc·quit" + case m.canSSO(): + return " tab·next field enter·sign in ctrl+o·" + m.ssoName() + " esc·quit" + } + return " tab·next field enter·sign in esc·quit" +} + // activeTeamLabel names what the lists are narrowed to. func (m Model) activeTeamLabel() string { if t, ok := m.activeTeam(); ok { @@ -187,7 +232,7 @@ func (m Model) renderFooter() string { return withStatus(" tab·next field enter·set password esc·cancel") case modeLogin: - return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit") + return "\n" + m.styles.Footer.Render(m.loginHelp()) default: switch m.activeSection { diff --git a/main.go b/main.go index b14d360..c480f31 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,7 @@ func main() { } model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th). WithDefaultTeam(cfg.Team). + WithAuth(cfg.Auth). WithLogin(cfg.Username, note) p := tea.NewProgram(model, tea.WithAltScreen())