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())