Files
terdut-server/internal/api/oidc_test.go
T
Niklas Ye a27ff49171 Sign in through an OpenID Connect provider, and from a terminal
terdut can now sign people in through any OIDC provider (written against
Authentik), and let groups at the provider decide who may sign in, which
teams they belong to and whether they administer the install. Password
login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off,
and is refused at startup unless SSO is configured. With no TERDUT_OIDC_*
setting nothing changes, so every existing install behaves as before.

Identity is (issuer, subject), never email or username: those are mutable
at the provider and a recycled address must not inherit an account. An
existing user is linked by email only when the provider marks it verified,
or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs.

Group grants are marked source='oidc' on team_members and users, and the
sync changes only those rows. Hand-made memberships and administrators
are left alone, and the sync bypasses the last-owner and last-admin guards
because the provider is the source of truth for what it grants. Editing
managed access by hand is refused with 409, since the next sign-in would
undo it. The web UI badges it as SSO and disables the controls.

Groups are read only at sign-in, so an SSO session carries a hard ceiling
(sessions.max_expires_at, 12h by default) that sliding never extends.
There is no refresh token, which means API keys of somebody removed at the
provider stay valid until an administrator disables the user. That is
accepted and documented, not fixed.

A client with no browser, the TUI over SSH, signs in with a device code
run by terdut itself (POST /api/oidc/device and /device/token), so the
terminal never talks to the provider and ends up with the ordinary
terdut_session cookie. Only a browser session can approve a code; an API
key cannot. /device?code= sends a signed-out visitor through sign-in and
back, which is what oidc_logins.next is for.

oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile
builds on 1.25.

Migrations 011 and 012 add tables and defaulted columns only.
2026-09-26 21:37:40 +02:00

776 lines
26 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, with the mapping the tests
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
// grant roles in the SRE team.
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",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
SessionMaxAge: 12 * time.Hour,
}
return c
}
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)
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)
}
}
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)
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)
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.exec(t, "INSERT INTO teams (name) VALUES ('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 mapping only says 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)
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)
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)
}
}