package api_test import ( "encoding/json" "net/http" "net/url" "strings" "testing" ) type deviceStart struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURL string `json:"verification_url"` Interval int `json:"interval"` ExpiresIn int `json:"expires_in"` } // startDevice is the terminal asking for a login. func startDevice(t *testing.T, s *ts) deviceStart { t.Helper() resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil) var d deviceStart decode(t, resp, &d) if d.DeviceCode == "" || d.UserCode == "" { t.Fatalf("device start returned %+v", d) } return d } // pollDevice is the terminal polling. It returns the status, and the session // cookie the response set, if any. func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) { t.Helper() resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code}) defer resp.Body.Close() var body map[string]any json.NewDecoder(resp.Body).Decode(&body) var cookie *http.Cookie for _, c := range resp.Cookies() { if c.Name == "terdut_session" { cookie = c } } msg, _ := body["error"].(string) if msg == "" { msg, _ = body["status"].(string) } return resp.StatusCode, cookie, msg } // readyToPoll lets the next poll through: the server holds a client to the // interval it was given, which a test has no wish to wait out. func (s *ts) readyToPoll(t *testing.T) { t.Helper() s.exec(t, "UPDATE device_logins SET last_polled_at = 0") } func decide(t *testing.T, b *browser, what, code string) int { t.Helper() resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code}) resp.Body.Close() return resp.StatusCode } func TestDevice_FullFlow(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) d := startDevice(t, s) if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") || !strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) { t.Errorf("verification url %q", d.VerificationURL) } if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 { t.Errorf("start: %+v", d) } if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" { t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg) } // The person signs in through the provider in some browser and approves. person := ssoBrowser(t, s) signInSSO(t, idp, person, alice) if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent { t.Fatalf("approve: %d", got) } s.readyToPoll(t) status, cookie, _ := pollDevice(t, s, d.DeviceCode) if status != http.StatusOK || cookie == nil { t.Fatalf("poll after approval: %d, cookie %v", status, cookie) } // The cookie is a working session for the person who approved. term := newBrowser(t, s.URL) req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil) req.AddCookie(cookie) resp, err := term.Do(req) if err != nil { t.Fatal(err) } var me struct { User struct { Username string `json:"username"` } `json:"user"` } decode(t, resp, &me) if me.User.Username != "alice" { t.Errorf("session belongs to %q, want alice", me.User.Username) } // Single use. if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" { t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg) } // The session was made for an SSO user, so it carries the ceiling. var ceiling *int64 s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling) if ceiling == nil { t.Error("a device session for an SSO user must carry the SSO session ceiling") } } func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) admin := signedIn(t, s) // password sign-in as the bootstrap admin d := startDevice(t, s) if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent { t.Fatalf("approve: %d", got) } s.readyToPoll(t) if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil { t.Fatalf("poll: %d %v", status, cookie) } var ceiling *int64 s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling) if ceiling != nil { t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling) } } func TestDevice_Denied(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) person := ssoBrowser(t, s) signInSSO(t, idp, person, alice) d := startDevice(t, s) if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent { t.Fatalf("deny: %d", got) } s.readyToPoll(t) if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" { t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg) } } func TestDevice_DecisionNeedsABrowserSession(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) d := startDevice(t, s) // Nobody signed in. if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized { t.Errorf("anonymous approve: %d, want 401", got) } // An API key is a credential for scripts, not for approving a terminal. resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("approve with an API key: %d, want 403", resp.StatusCode) } if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" { t.Errorf("the login must still be pending: %d %q", status, msg) } } func TestDevice_ApprovalIsFinal(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) first, second := ssoBrowser(t, s), ssoBrowser(t, s) signInSSO(t, idp, first, alice) signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}}) d := startDevice(t, s) if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent { t.Fatalf("approve: %d", got) } // A second browser cannot take the login over, nor refuse it. for _, what := range []string{"approve", "deny"} { if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound { t.Errorf("%s after approval: %d, want 404", what, got) } } s.readyToPoll(t) _, cookie, _ := pollDevice(t, s, d.DeviceCode) if cookie == nil { t.Fatal("no session") } var name string s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name) if name != "alice" { t.Errorf("session for %q, want alice", name) } } func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) person := ssoBrowser(t, s) signInSSO(t, idp, person, alice) d := startDevice(t, s) typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " ")) if got := decide(t, person, "approve", typed); got != http.StatusNoContent { t.Errorf("approve %q: %d, want 204", typed, got) } if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest { t.Errorf("approve nonsense: %d, want 400", got) } } func TestDevice_ExpiredAndUnknown(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) person := ssoBrowser(t, s) signInSSO(t, idp, person, alice) d := startDevice(t, s) s.exec(t, "UPDATE device_logins SET expires_at = 1") if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound { t.Errorf("approve expired: %d, want 404", got) } if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" { t.Errorf("poll expired: %d %q, want 410 expired", status, msg) } if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" { t.Errorf("poll unknown: %d %q, want 410 expired", status, msg) } } func TestDevice_PollingTooFastIsRefused(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) d := startDevice(t, s) if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted { t.Fatalf("first poll: %d", status) } if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" { t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg) } } func TestDevice_DisabledUserGetsNoSession(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) person := ssoBrowser(t, s) signInSSO(t, idp, person, alice) d := startDevice(t, s) decide(t, person, "approve", d.UserCode) s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'") s.readyToPoll(t) var before int s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before) if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil { t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie) } var after int s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after) if after != before { t.Error("a session was created for a disabled user") } } func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) { s := newTS(t) // no SSO resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode) } idp := newFakeIdP(t) for _, c := range []struct { name string s *ts want bool }{{"off", s, false}, {"on", newSSOTS(t, idp), true}} { var cfg struct { DeviceLogin bool `json:"device_login"` } decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg) if cfg.DeviceLogin != c.want { t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want) } } } func TestDevice_StartIsRateLimited(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) b := newBrowser(t, s.URL) var last int for range 32 { resp := b.do(t, http.MethodPost, "/api/oidc/device", nil) resp.Body.Close() last = resp.StatusCode } if last != http.StatusTooManyRequests { t.Errorf("32nd start: %d, want 429", last) } } // After signing in the browser is sent on to where the person was going, which // is how somebody without a session gets from /device?code=... through the // provider and back to it. Only paths on this server are honoured. func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) for _, c := range []struct{ next, want string }{ {"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"}, {"/team/members", "/team/members"}, {"", "/"}, {"//evil.example/x", "/"}, {"/\\evil.example", "/"}, {"https://evil.example/", "/"}, {"evil.example", "/"}, {"/api/users", "/"}, {"/ok\r\nSet-Cookie: x=y", "/"}, {"/" + strings.Repeat("a", 600), "/"}, } { b := ssoBrowser(t, s) resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil) resp.Body.Close() loc, _ := url.Parse(resp.Header.Get("Location")) q := loc.Query() got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state")) if got != c.want { t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want) } } }