Files
terdut-server/internal/api/oidc_test.go
T
Niklas Ye 5b4683febf Let each team name its own OIDC group, not a global mapping
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.

This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
2026-09-27 11:43:57 +02:00

816 lines
28 KiB
Go

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