package api_test import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "math/big" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "git.ryuvia.com/niklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/config" ) // fakeIdP is just enough of an OpenID Connect provider for terdut to sign // somebody in against: discovery, a key set and a token endpoint that checks the // PKCE verifier. There is no authorize endpoint; the tests read the URL terdut // redirects to and play the part of the browser and the person themselves. type fakeIdP struct { *httptest.Server key *rsa.PrivateKey mu sync.Mutex codes map[string]pendingCode } type pendingCode struct { claims map[string]any challenge string } const ( idpClientID = "terdut" idpClientSecret = "s3cret" ) func newFakeIdP(t *testing.T) *fakeIdP { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } f := &fakeIdP{key: key, codes: map[string]pendingCode{}} mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "issuer": f.URL, "authorization_endpoint": f.URL + "/authorize", "token_endpoint": f.URL + "/token", "jwks_uri": f.URL + "/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}, "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, }) }) mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { b64 := base64.RawURLEncoding.EncodeToString json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ "kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256", "n": b64(key.N.Bytes()), "e": b64(big.NewInt(int64(key.E)).Bytes()), }}}) }) mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() user, pass, basic := r.BasicAuth() if !basic { user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret") } if user != idpClientID || pass != idpClientSecret { http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) return } f.mu.Lock() p, ok := f.codes[r.PostForm.Get("code")] delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider f.mu.Unlock() sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier"))) if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge { http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) return } // oauth2 picks the parser from the content type; without this it reads // the body as a form, finds no token and retries, spending the code. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "unused", "token_type": "Bearer", "expires_in": 300, "id_token": f.sign(t, p.claims), }) }) f.Server = httptest.NewServer(mux) t.Cleanup(f.Close) return f } // sign returns claims as an RS256 JWT. func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string { t.Helper() enc := func(v any) string { b, _ := json.Marshal(v) return base64.RawURLEncoding.EncodeToString(b) } signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims) sum := sha256.Sum256([]byte(signing)) sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:]) if err != nil { t.Fatal(err) } return signing + "." + base64.RawURLEncoding.EncodeToString(sig) } // idpUser is who signs in, as the provider describes them. type idpUser struct { sub, username, email string unverified bool groups []string badNonce bool } // ssoConfig is a terdut configuration wired to idp: terdut-users may sign in, // terdut-admins administer. Which groups grant which team is not config // anymore — it is each team's own oidc_member_group/oidc_owner_group, so a // test that needs one seeds it with seedTeam. func ssoConfig(idp *fakeIdP) config.Config { c := testConfig() c.OIDC = config.OIDC{ Issuer: idp.URL, ClientID: idpClientID, ClientSecret: idpClientSecret, Name: "Authentik", Scopes: []string{"openid", "profile", "email"}, UsernameClaim: "preferred_username", EmailClaim: "email", GroupsClaim: "groups", AllowedGroups: []string{"terdut-users"}, AdminGroup: "terdut-admins", SessionMaxAge: 12 * time.Hour, } return c } // seedTeam creates a team with an OIDC group binding, the way an owner would // set one from the Members tab. Teams are no longer created by the sync // itself, so a test whose groups should grant something needs the team to // already exist. An empty group means that role is not granted by one. func (s *ts) seedTeam(t *testing.T, name, memberGroup, ownerGroup string) int64 { t.Helper() var id int64 err := s.db.QueryRow(` INSERT INTO teams (name, oidc_member_group, oidc_owner_group) VALUES ($1, NULLIF($2, ''), NULLIF($3, '')) RETURNING id`, name, memberGroup, ownerGroup).Scan(&id) if err != nil { t.Fatalf("seed team %q: %v", name, err) } return id } func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts { t.Helper() c := ssoConfig(idp) for _, f := range tweak { f(&c) } return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c) } // ssoBrowser is a browser that does not follow redirects, so a test can read // where each step sends it. func ssoBrowser(t *testing.T, s *ts) *browser { t.Helper() b := newBrowser(t, s.URL) b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } return b } // startLogin visits /api/oidc/login and returns what terdut asked the provider // for: the state, nonce and PKCE challenge. func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) { t.Helper() resp := b.do(t, http.MethodGet, "/api/oidc/login", nil) resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("login start: %d", resp.StatusCode) } loc, err := url.Parse(resp.Header.Get("Location")) if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") { t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location")) } q := loc.Query() if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID || q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" { t.Fatalf("unexpected authorization request: %v", q) } return q.Get("state"), q.Get("nonce"), q.Get("code_challenge") } // issueCode has the provider authenticate u and hand back an authorization code. func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string { if u.badNonce { nonce = "not-the-nonce" } claims := map[string]any{ "iss": f.URL, "sub": u.sub, "aud": idpClientID, "iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(), "nonce": nonce, "preferred_username": u.username, "email": u.email, "email_verified": !u.unverified, "groups": u.groups, } f.mu.Lock() defer f.mu.Unlock() code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6)) f.codes[code] = pendingCode{claims: claims, challenge: challenge} return code } // callback delivers the provider's answer to terdut and returns where terdut // sends the browser next. func callback(t *testing.T, b *browser, code, state string) string { t.Helper() resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("callback: %d", resp.StatusCode) } return resp.Header.Get("Location") } // signInSSO runs a whole sign-in and returns the Location the callback ended on. func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string { t.Helper() state, nonce, challenge := startLogin(t, idp, b) return callback(t, b, idp.issueCode(u, nonce, challenge), state) } var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}} func withGroups(u idpUser, groups ...string) idpUser { u.groups = groups return u } // meOf reads /api/me over the browser's session. func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) { t.Helper() resp := b.do(t, http.MethodGet, "/api/me", nil) defer resp.Body.Close() var me struct { User struct { Username string `json:"username"` IsAdmin bool `json:"is_admin"` } `json:"user"` HasPassword bool `json:"has_password"` } json.NewDecoder(resp.Body).Decode(&me) return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword } // memberships lists a user's teams as name -> "role/source". func (s *ts) memberships(t *testing.T, username string) map[string]string { t.Helper() rows, err := s.db.Query(` SELECT t.name, m.role, m.source FROM team_members m JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id WHERE u.username = $1`, username) if err != nil { t.Fatal(err) } defer rows.Close() out := map[string]string{} for rows.Next() { var name, role, source string rows.Scan(&name, &role, &source) out[name] = role + "/" + source } return out } func sameMap(a, b map[string]string) bool { if len(a) != len(b) { return false } for k, v := range a { if b[k] != v { return false } } return true } func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.seedTeam(t, "SRE", "sre", "sre-leads") s.seedTeam(t, "Platform", "platform", "") b := ssoBrowser(t, s) if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" { t.Fatalf("signed in and was sent to %q, want /", loc) } status, name, isAdmin, hasPassword := meOf(t, b) if status != http.StatusOK || name != "alice" || isAdmin || hasPassword { t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword) } want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"} if got := s.memberships(t, "alice"); !sameMap(got, want) { t.Errorf("memberships %v, want %v", got, want) } } // A group matching no team's own binding grants nothing and creates nothing: // unlike the old global mapping, the sync never creates a team by name. func TestSSO_NoAutoCreateTeam(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) var before int s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&before) signInSSO(t, idp, ssoBrowser(t, s), alice) // groups include "sre"; no team names it if got := s.memberships(t, "alice"); len(got) != 0 { t.Errorf("memberships %v, want none: no team's oidc_member_group/oidc_owner_group is set", got) } var after int s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&after) if after != before { t.Errorf("team count %d -> %d, want no team created", before, after) } } func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) b := ssoBrowser(t, s) loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins")) if loc != "/?sso_error=not_allowed" { t.Fatalf("sent to %q, want the not_allowed error", loc) } if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { t.Errorf("a refused sign-in must not leave a session: /api/me %d", status) } var n int s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n) if n != 0 { t.Error("a refused sign-in must not create the user") } } func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins")) var isAdmin bool var source string read := func() { s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source) } if read(); !isAdmin || source != "oidc" { t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source) } signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users")) if read(); isAdmin || source != "manual" { t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source) } } func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true }) // The bootstrap administrator is a manual one. Signing in through the // provider without the admin group must not take that away. signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}}) var isAdmin bool var source string s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source) if !isAdmin || source != "manual" { t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source) } } func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.seedTeam(t, "SRE", "sre", "sre-leads") signInSSO(t, idp, ssoBrowser(t, s), alice) // Somebody adds alice to another team by hand. s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')") s.exec(t, `INSERT INTO team_members (team_id, user_id, role) SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`) signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users")) want := map[string]string{"Hand": "member/manual"} if got := s.memberships(t, "alice"); !sameMap(got, want) { t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want) } } func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.seedTeam(t, "SRE", "sre", "sre-leads") signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads")) if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) { t.Errorf("both groups: %v, want owner", got) } signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre")) if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) { t.Errorf("lead group dropped: %v, want member", got) } } func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) // alice exists locally, is a manual owner of SRE, and is linked by email. s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')") s.seedTeam(t, "SRE", "sre", "") s.exec(t, `INSERT INTO team_members (team_id, user_id, role) VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`) signInSSO(t, idp, ssoBrowser(t, s), alice) // the group only grants member if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) { t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got) } } func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')") b := ssoBrowser(t, s) signInSSO(t, idp, b, alice) if _, name, _, _ := meOf(t, b); name != "alice-local" { t.Errorf("signed in as %q, want the existing local user", name) } var users, identities int s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users) s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities) if users != 2 || identities != 1 { // admin + alice-local t.Errorf("%d users, %d identities: linking must not create a second user", users, identities) } } func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) { idp := newFakeIdP(t) unverified := alice unverified.unverified = true s := newSSOTS(t, idp) s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')") if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" { t.Errorf("unverified email: sent to %q, want email_conflict", loc) } trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true }) trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')") b := ssoBrowser(t, trusting) if loc := signInSSO(t, idp, b, unverified); loc != "/" { t.Fatalf("trusted email: sent to %q, want /", loc) } if _, name, _, _ := meOf(t, b); name != "alice-local" { t.Errorf("signed in as %q, want the existing local user", name) } } func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) signInSSO(t, idp, ssoBrowser(t, s), alice) // A different person at the provider, same address. other := alice other.sub = "sub-someone-else" if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" { t.Errorf("sent to %q, want email_conflict", loc) } } func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')") b := ssoBrowser(t, s) signInSSO(t, idp, b, alice) if _, name, _, _ := meOf(t, b); name != "alice-2" { t.Errorf("username %q, want alice-2", name) } } func TestSSO_ProfileFollowsTheProvider(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) signInSSO(t, idp, ssoBrowser(t, s), alice) renamed := alice renamed.username, renamed.email = "alice.smith", "alice.smith@example.com" b := ssoBrowser(t, s) signInSSO(t, idp, b, renamed) if _, name, _, _ := meOf(t, b); name != "alice.smith" { t.Errorf("username %q, want the provider's new one", name) } var email string s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email) if email != "alice.smith@example.com" { t.Errorf("email %q", email) } } func TestSSO_DisabledUserIsRefused(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) signInSSO(t, idp, ssoBrowser(t, s), alice) s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'") b := ssoBrowser(t, s) if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" { t.Errorf("sent to %q, want disabled", loc) } if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { t.Errorf("/api/me %d, want 401", status) } } func TestSSO_NoEmailIsRefused(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) noEmail := alice noEmail.email = "" if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" { t.Errorf("sent to %q, want no_email", loc) } } func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) b := ssoBrowser(t, s) signInSSO(t, idp, b, alice) var expires, ceiling int64 s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling) inTwelveHours := time.Now().Add(12 * time.Hour).Unix() if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling { t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours) } // Age the session so the next request would slide it, with a ceiling well // inside the ordinary 30 days. s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200") if status, _, _, _ := meOf(t, b); status != http.StatusOK { t.Fatalf("/api/me %d", status) } var after int64 s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after) if after > ceiling { t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling) } } func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) { s := newTS(t) b := signedIn(t, s) 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 session has a ceiling %d, want none", *ceiling) } s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200") var before, after int64 s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before) meOf(t, b) s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after) if after <= before { t.Errorf("expiry %d -> %d, want it to slide forward", before, after) } } func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) // Replaying a callback finds no state. b := ssoBrowser(t, s) state, nonce, challenge := startLogin(t, idp, b) code := idp.issueCode(alice, nonce, challenge) if loc := callback(t, b, code, state); loc != "/" { t.Fatalf("first callback sent to %q", loc) } if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" { t.Errorf("replayed state: sent to %q, want expired", loc) } // A callback from a browser that did not start the login is refused, which // is what stops a login being planted on somebody else. victim := ssoBrowser(t, s) state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" { t.Errorf("foreign browser: sent to %q, want expired", loc) } if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized { t.Errorf("the victim has a session: /api/me %d", status) } } func TestSSO_WrongNonceIsRefused(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) bad := alice bad.badNonce = true b := ssoBrowser(t, s) if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" { t.Errorf("sent to %q, want failed", loc) } if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { t.Errorf("/api/me %d, want 401", status) } } func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) b := ssoBrowser(t, s) resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil) resp.Body.Close() if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" { t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc) } } func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.seedTeam(t, "SRE", "sre", "") signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins")) var aliceID, sreID int64 s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID) s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID) teamPath := fmt.Sprintf("/api/teams/%d/members", sreID) // The bootstrap admin is a system administrator, so may manage SRE. for _, c := range []struct { name, method, path string body any }{ {"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}}, {"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil}, {"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}}, } { resp := s.req(t, c.method, c.path, c.body) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Errorf("%s: %d, want 409", c.name, resp.StatusCode) } } if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) { t.Errorf("memberships changed by a refused edit: %v", got) } } func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true }) b := newBrowser(t, s.URL) resp := b.login(t, "admin", "whatever-password") resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("login: %d, want 403", resp.StatusCode) } resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("signup: %d, want 403", resp.StatusCode) } var cfg struct { PasswordLogin bool `json:"password_login"` OIDC struct { Enabled bool `json:"enabled"` Name string `json:"name"` } `json:"oidc"` } resp = b.do(t, http.MethodGet, "/api/auth/config", nil) defer resp.Body.Close() json.NewDecoder(resp.Body).Decode(&cfg) if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" { t.Errorf("auth config: %+v", cfg) } } func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) { s := newTS(t) var cfg struct { PasswordLogin bool `json:"password_login"` OIDC struct { Enabled bool `json:"enabled"` } `json:"oidc"` } resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil) defer resp.Body.Close() json.NewDecoder(resp.Body).Decode(&cfg) if !cfg.PasswordLogin || cfg.OIDC.Enabled { t.Errorf("auth config: %+v", cfg) } // With SSO off the routes do not exist, rather than answering with an error // page a person could land on. resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode) } } func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) idp.Close() // the provider goes down after terdut has started b := ssoBrowser(t, s) resp := b.do(t, http.MethodGet, "/api/oidc/login", nil) resp.Body.Close() if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" { t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc) } } func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) { idp := newFakeIdP(t) s := newSSOTS(t, idp) s.seedTeam(t, "SRE", "sre", "") b := ssoBrowser(t, s) signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins")) var aliceID, sreID int64 s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID) s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID) // Users: alice's administrator flag is the groups', the bootstrap admin's is not. var users []struct { Username string `json:"username"` AdminSource string `json:"admin_source"` } decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users) got := map[string]string{} for _, u := range users { got[u.Username] = u.AdminSource } if got["alice"] != "oidc" || got["admin"] != "manual" { t.Errorf("admin_source by user: %v", got) } // The team's own member list, as a member sees it. var members []struct { Username string `json:"username"` Source string `json:"source"` } resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil) decode(t, resp, &members) if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" { t.Errorf("team members: %+v", members) } // The administrator's view of the same team, and of alice's teams. var adminTeam struct { Members []struct { Username string `json:"username"` Source string `json:"source"` } `json:"members"` } decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam) if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" { t.Errorf("admin team members: %+v", adminTeam.Members) } var teams []struct { Name string `json:"name"` Source string `json:"source"` } decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams) if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" { t.Errorf("user teams: %+v", teams) } // The bootstrap admin's own membership is manual. var mine []struct { Source string `json:"source"` } decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine) if len(mine) == 0 || mine[0].Source != "manual" { t.Errorf("bootstrap admin's teams: %+v", mine) } }