diff --git a/docs/superpowers/plans/2026-05-05-p4-05-oidc.md b/docs/superpowers/plans/2026-05-05-p4-05-oidc.md new file mode 100644 index 0000000..5d99826 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-p4-05-oidc.md @@ -0,0 +1,2505 @@ +# P4-05 — OIDC Login Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire OpenID Connect Authorization Code + PKCE flow as a sign-in path alongside the local-user system; first OIDC sign-in JIT-provisions a local user row (`auth_source='oidc'`); subsequent sign-ins refresh role + email from the IdP; logout drops the local session and redirects to the IdP's `end_session_endpoint` when advertised. + +**Architecture:** Single-provider config from YAML/env. Use `github.com/coreos/go-oidc/v3` for provider discovery + ID-token verification (JWKS auto-refresh) and `golang.org/x/oauth2` for the code-exchange. Short-lived per-flow state (`state`, PKCE `code_verifier`) goes in a new `oidc_state` table swept on the existing alert-engine tick. New `auth_source` + `oidc_subject` columns on `users`; `id_token` column on `sessions` to drive RP-initiated logout. Failed claim resolution (no role match, username clash) lands on `/login?oidc_error=…` with a banner. + +**Tech Stack:** Go 1.25, `github.com/coreos/go-oidc/v3` (OIDC client), `golang.org/x/oauth2` (OAuth flow), modernc.org/sqlite, chi v5, html/template + Tailwind. Existing crypto helpers (`auth.NewToken`, `auth.HashToken`) reused for state-hash bookkeeping. + +**Branch:** `p4-05-oidc` (already exists with the spec commit). + +**Spec:** `docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md` + +**Authelia bundle (smoke env):** `/tmp/rm-smoke/oidc.env` — issuer `https://auth.dcglab.co.uk`, client_id `restic-manager`, four test users (`rm-admin` / `rm-operator` / `rm-viewer` / `rm-other`), claim is `groups`. + +--- + +## File structure + +### Created files + +- `internal/store/migrations/0019_oidc.sql` — schema for OIDC bookkeeping +- `internal/store/oidc_state.go` — state-table CRUD +- `internal/store/oidc_state_test.go` +- `internal/server/config/oidc.go` — OIDCConfig parse + validate +- `internal/server/config/oidc_test.go` +- `internal/server/oidc/oidc.go` — provider discovery + role-claim helpers (small, testable) +- `internal/server/oidc/oidc_test.go` +- `internal/server/oidc/stub_test.go` — fake IdP httptest harness used by callback tests +- `internal/server/http/oidc_handlers.go` — `/auth/oidc/login` + `/auth/oidc/callback` +- `internal/server/http/oidc_handlers_test.go` + +### Modified files + +- `internal/store/types.go` — extend User struct (AuthSource, OIDCSubject); extend Session struct (IDToken) +- `internal/store/users.go` — `GetUserByOIDCSubject`, `SetUserOIDCSubject`, scanUser updates +- `internal/store/sessions.go` — extend CreateSession to round-trip IDToken; SessionWithIDToken lookup helper for logout +- `internal/server/config/config.go` — add `OIDC *OIDCConfig` field, wire YAML + env loading via the new oidc.go helpers +- `internal/server/http/server.go` — wire `/auth/oidc/login` + `/auth/oidc/callback` (public band); pass new `Deps.OIDC` field through +- `internal/server/http/auth.go` — local login (JSON + HTML) rejects `auth_source='oidc'` users +- `internal/server/http/ui_handlers.go` — login page passes OIDC enabled flag + display name + error code; expose `OIDCEnabled` / `OIDCDisplayName` on the login view model +- `internal/server/http/ui_users.go` — users list adds `oidc` chip; edit-user disables username/role/email for OIDC rows; hides regenerate-setup +- `web/templates/pages/login.html` — SSO button + error banner +- `web/templates/pages/users.html` — `oidc` chip in Status column +- `web/templates/pages/user_edit.html` — readonly fields when AuthSource=oidc +- `internal/alert/engine.go` — tick also calls `Store.CleanupExpiredOIDCState` +- `cmd/server/main.go` — build OIDC client at startup when configured; wire Deps.OIDC +- `tasks.md` — tick P4-05 + as-shipped notes + +--- + +## Slice A — Schema & store API + +### Task A1: Migration 0019 + +**Files:** +- Create: `internal/store/migrations/0019_oidc.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- 0019_oidc.sql +-- +-- OIDC bookkeeping. Three independent additions land in one +-- migration to keep the related changes together: +-- +-- 1. users.auth_source — 'local' | 'oidc'. Local users get +-- the default; first OIDC sign-in JITs +-- a row with auth_source='oidc'. +-- 2. users.oidc_subject — IdP's stable 'sub' claim. Indexed +-- uniquely (partial; NULLs allowed). +-- 3. sessions.id_token — last id_token for OIDC sessions, used +-- as id_token_hint on RP-initiated +-- logout. NULL for local sessions. +-- 4. oidc_state — short-lived state for the OAuth round- +-- trip (state + PKCE code_verifier). +-- Swept on the alert engine tick. +-- +-- All column-level ALTERs (CLAUDE.md preference; safe under +-- foreign_keys=ON). + +ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local' + CHECK (auth_source IN ('local', 'oidc')); +ALTER TABLE users ADD COLUMN oidc_subject TEXT; + +CREATE UNIQUE INDEX users_oidc_subject ON users(oidc_subject) + WHERE oidc_subject IS NOT NULL; + +ALTER TABLE sessions ADD COLUMN id_token TEXT; + +CREATE TABLE oidc_state ( + state_hash TEXT PRIMARY KEY, -- sha256(state) hex; raw never persisted + code_verifier TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX oidc_state_created ON oidc_state(created_at); +``` + +- [ ] **Step 2: Run all tests** + +Run: `go test ./internal/store/...` +Expected: all PASS — migrations apply on a fresh DB, scanUser still compiles (we add the new fields in A2). + +- [ ] **Step 3: Commit** + +```bash +git add internal/store/migrations/0019_oidc.sql +git commit -m "store: migration 0019 — users.auth_source/oidc_subject + sessions.id_token + oidc_state" +``` + +--- + +### Task A2: User struct + Session struct extensions + +**Files:** +- Modify: `internal/store/types.go` + +- [ ] **Step 1: Extend User** + +In `internal/store/types.go`, find the existing `User` struct and add the two new fields: + +```go +type User struct { + ID string + Username string + PasswordHash string + Role Role + Email *string + DisabledAt *time.Time + MustChangePassword bool + // AuthSource is "local" (created by admin or bootstrap) or + // "oidc" (JIT-provisioned on first OIDC sign-in). Local users + // authenticate via password; OIDC users via the IdP and have an + // empty PasswordHash. + AuthSource string + // OIDCSubject is the stable 'sub' claim from the IdP. Set only + // when AuthSource == "oidc". Used for fast lookup on subsequent + // sign-ins; the username/email may change at the IdP but sub + // stays stable. + OIDCSubject *string + CreatedAt time.Time + LastLoginAt *time.Time +} +``` + +- [ ] **Step 2: Extend Session** + +Find the existing `Session` struct and add `IDToken`: + +```go +type Session struct { + ID string // sha256(raw token), hex + UserID string + CreatedAt time.Time + ExpiresAt time.Time + IP string + UserAgent string + // IDToken is the OIDC id_token captured at sign-in for OIDC + // sessions; empty for local-user sessions. Used as + // id_token_hint on RP-initiated logout. + IDToken string +} +``` + +- [ ] **Step 3: Run vet — expect store/users.go to break** + +Run: `go vet ./internal/store/...` +Expected: errors about `scanUser` reading the old column count. We fix that next. + +- [ ] **Step 4: Commit (broken intermediate, A3 fixes)** + +```bash +git add internal/store/types.go +git commit -m "store: extend User with AuthSource/OIDCSubject; Session with IDToken" +``` + +--- + +### Task A3: Update users store — AuthSource, OIDCSubject + +**Files:** +- Modify: `internal/store/users.go` + +- [ ] **Step 1: Write a failing test** + +Append to `internal/store/users_test.go`: + +```go +func TestGetUserByOIDCSubject(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + sub := "sub-abc-123" + + if err := s.CreateUser(ctx, User{ + ID: "u1", Username: "alice", PasswordHash: "", + Role: RoleAdmin, CreatedAt: now, + AuthSource: "oidc", OIDCSubject: &sub, + }); err != nil { + t.Fatalf("create: %v", err) + } + got, err := s.GetUserByOIDCSubject(ctx, sub) + if err != nil { + t.Fatalf("get by sub: %v", err) + } + if got.ID != "u1" || got.AuthSource != "oidc" { + t.Errorf("unexpected: %+v", got) + } + // Missing sub returns ErrNotFound. + if _, err := s.GetUserByOIDCSubject(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Errorf("missing sub: want ErrNotFound, got %v", err) + } +} + +func TestSetUserOIDCSubject(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + if err := s.CreateUser(ctx, User{ + ID: "u1", Username: "alice", PasswordHash: "x", + Role: RoleAdmin, CreatedAt: now, + }); err != nil { + t.Fatalf("create: %v", err) + } + sub := "sub-456" + if err := s.SetUserOIDCSubject(ctx, "u1", "oidc", sub); err != nil { + t.Fatalf("set: %v", err) + } + got, _ := s.GetUserByID(ctx, "u1") + if got.AuthSource != "oidc" || got.OIDCSubject == nil || *got.OIDCSubject != sub { + t.Errorf("after set: %+v", got) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./internal/store/ -run "TestGetUserByOIDCSubject|TestSetUserOIDCSubject"` +Expected: FAIL — methods undefined and column-count mismatch in scanUser. + +- [ ] **Step 3: Update users.go** + +Replace `CreateUser`, `GetUserByUsername`, `GetUserByID`, `ListUsers`, `scanUser`, and add the two new methods: + +```go +// In CreateUser, expand the INSERT to write auth_source + +// oidc_subject. If the User struct's AuthSource is empty, default to +// 'local'. +func (s *Store) CreateUser(ctx context.Context, u User) error { + u.Username = strings.ToLower(strings.TrimSpace(u.Username)) + must := 0 + if u.MustChangePassword { + must = 1 + } + authSource := u.AuthSource + if authSource == "" { + authSource = "local" + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO users (id, username, password_hash, role, email, + must_change_password, auth_source, + oidc_subject, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.PasswordHash, string(u.Role), + nullable(u.Email), must, authSource, + nullable(u.OIDCSubject), + u.CreatedAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("store: create user: %w", err) + } + return nil +} + +// Update GetUserByUsername / GetUserByID / ListUsers SELECT lists +// to include auth_source, oidc_subject. Replace each: +const userSelectCols = `id, username, password_hash, role, email, + disabled_at, must_change_password, + auth_source, oidc_subject, + created_at, last_login_at` + +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+userSelectCols+` FROM users WHERE LOWER(username) = LOWER(?)`, + username) + return scanUser(row.Scan) +} + +func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+userSelectCols+` FROM users WHERE id = ?`, id) + return scanUser(row.Scan) +} + +// GetUserByOIDCSubject — used during the OIDC callback to find the +// user JIT-provisioned on a previous sign-in. ErrNotFound on miss. +func (s *Store) GetUserByOIDCSubject(ctx context.Context, sub string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+userSelectCols+` FROM users WHERE oidc_subject = ?`, sub) + return scanUser(row.Scan) +} + +// SetUserOIDCSubject pins an existing user row to an IdP subject — +// only used by tests today; the JIT-provision path uses CreateUser +// directly. Kept separate so a future "link a local user to OIDC" +// flow has a clean primitive. +func (s *Store) SetUserOIDCSubject(ctx context.Context, id, authSource, sub string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET auth_source = ?, oidc_subject = ? WHERE id = ?`, + authSource, sub, id) + if err != nil { + return fmt.Errorf("store: set oidc subject: %w", err) + } + return nil +} + +// In ListUsers, swap the SELECT cols to userSelectCols and call +// scanUser on each row (no other change). +func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) { + asc := sort.OrderAsc + if sort.OrderBy == "" { + asc = true + } + q := `SELECT ` + userSelectCols + ` FROM users ORDER BY ` + + usersOrderColumn(sort.OrderBy, asc) + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("store: list users: %w", err) + } + defer func() { _ = rows.Close() }() + var out []User + for rows.Next() { + u, err := scanUser(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *u) + } + return out, rows.Err() +} + +func scanUser(scan func(...any) error) (*User, error) { + var u User + var role string + var email, disabledAt, oidcSub, lastLogin sql.NullString + var must int + var authSource string + var created string + if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role, + &email, &disabledAt, &must, &authSource, &oidcSub, + &created, &lastLogin); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("store: scan user: %w", err) + } + u.Role = Role(role) + if email.Valid { + v := email.String + u.Email = &v + } + if disabledAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, disabledAt.String) + u.DisabledAt = &t + } + u.MustChangePassword = must == 1 + u.AuthSource = authSource + if oidcSub.Valid { + v := oidcSub.String + u.OIDCSubject = &v + } + t, _ := time.Parse(time.RFC3339Nano, created) + u.CreatedAt = t + if lastLogin.Valid { + t, _ := time.Parse(time.RFC3339Nano, lastLogin.String) + u.LastLoginAt = &t + } + return &u, nil +} +``` + +- [ ] **Step 4: Run all store tests, expect PASS** + +Run: `go test ./internal/store/...` +Expected: PASS. + +- [ ] **Step 5: Run vet across the repo** + +Run: `go vet ./...` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/store/users.go internal/store/users_test.go +git commit -m "store: GetUserByOIDCSubject + scanUser auth_source/oidc_subject" +``` + +--- + +### Task A4: Sessions store — round-trip IDToken + +**Files:** +- Modify: `internal/store/sessions.go` + +- [ ] **Step 1: Write a failing test** + +Append to `internal/store/sessions_test.go`: + +```go +func TestSessionRoundTripsIDToken(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + uid := "u-oidc" + if err := s.CreateUser(ctx, User{ + ID: uid, Username: "ouser", PasswordHash: "", + Role: RoleOperator, CreatedAt: now, + AuthSource: "oidc", + }); err != nil { + t.Fatalf("create user: %v", err) + } + + if err := s.CreateSession(ctx, Session{ + ID: "h1", UserID: uid, CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + IDToken: "eyJ.fake.jwt", + }, "h1"); err != nil { + t.Fatalf("create session: %v", err) + } + got, err := s.LookupSession(ctx, "h1") + if err != nil { + t.Fatalf("lookup: %v", err) + } + if got.IDToken != "eyJ.fake.jwt" { + t.Errorf("id_token round trip: got %q", got.IDToken) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./internal/store/ -run TestSessionRoundTripsIDToken` +Expected: FAIL — IDToken not on Session struct (added in A2 already, so this might compile but `id_token` not in the SELECT). + +- [ ] **Step 3: Update CreateSession + LookupSession + DeleteSession** + +```go +func (s *Store) CreateSession(ctx context.Context, sess Session, tokenHash string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO sessions (id, user_id, created_at, expires_at, + ip, ua, id_token) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + tokenHash, sess.UserID, + sess.CreatedAt.UTC().Format(time.RFC3339Nano), + sess.ExpiresAt.UTC().Format(time.RFC3339Nano), + nullableStr(sess.IP), nullableStr(sess.UserAgent), + nullableStr(sess.IDToken)) + if err != nil { + return fmt.Errorf("store: create session: %w", err) + } + return nil +} + +// In LookupSession, expand SELECT to include id_token, scan into sess.IDToken. +// Use sql.NullString for nullability. +func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, user_id, created_at, expires_at, ip, ua, id_token + FROM sessions WHERE id = ?`, tokenHash) + var ( + sess Session + ip, ua, idTok sql.NullString + created, exp string + ) + if err := row.Scan(&sess.ID, &sess.UserID, &created, &exp, + &ip, &ua, &idTok); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("store: scan session: %w", err) + } + t, _ := time.Parse(time.RFC3339Nano, created) + sess.CreatedAt = t + t, _ = time.Parse(time.RFC3339Nano, exp) + sess.ExpiresAt = t + if ip.Valid { + sess.IP = ip.String + } + if ua.Valid { + sess.UserAgent = ua.String + } + if idTok.Valid { + sess.IDToken = idTok.String + } + return &sess, nil +} + +// nullableStr — sibling of nullable() but for plain strings rather +// than *string. Empty → NULL; non-empty → as-is. +func nullableStr(s string) any { + if s == "" { + return nil + } + return s +} +``` + +If a `nullableStr` already exists somewhere in the package, reuse it. + +- [ ] **Step 4: Run tests, expect PASS** + +Run: `go test ./internal/store/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/sessions.go internal/store/sessions_test.go +git commit -m "store: round-trip IDToken on sessions for RP-initiated logout" +``` + +--- + +### Task A5: oidc_state CRUD + +**Files:** +- Create: `internal/store/oidc_state.go` +- Create: `internal/store/oidc_state_test.go` + +- [ ] **Step 1: Write tests first** + +```go +// internal/store/oidc_state_test.go +package store + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func newOIDCStateTestStore(t *testing.T) *Store { + t.Helper() + st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func TestOIDCStatePutAndConsume(t *testing.T) { + t.Parallel() + st := newOIDCStateTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + if err := st.PutOIDCState(ctx, "hash1", "verifier-1", now); err != nil { + t.Fatalf("put: %v", err) + } + v, err := st.ConsumeOIDCState(ctx, "hash1") + if err != nil { + t.Fatalf("consume: %v", err) + } + if v != "verifier-1" { + t.Errorf("verifier: got %q want %q", v, "verifier-1") + } + // Re-consume must fail (single-use). + if _, err := st.ConsumeOIDCState(ctx, "hash1"); err == nil { + t.Error("re-consume should fail") + } +} + +func TestOIDCStateCleanup(t *testing.T) { + t.Parallel() + st := newOIDCStateTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + _ = st.PutOIDCState(ctx, "stale", "v-stale", now.Add(-10*time.Minute)) + _ = st.PutOIDCState(ctx, "fresh", "v-fresh", now) + + // Clean entries older than 5m. + cutoff := now.Add(-5 * time.Minute) + n, err := st.CleanupExpiredOIDCState(ctx, cutoff) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if n != 1 { + t.Errorf("cleanup count: got %d want 1", n) + } + if _, err := st.ConsumeOIDCState(ctx, "stale"); err == nil { + t.Error("stale entry should have been deleted") + } + if _, err := st.ConsumeOIDCState(ctx, "fresh"); err != nil { + t.Errorf("fresh entry should still be readable: %v", err) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./internal/store/ -run TestOIDCState` +Expected: FAIL — methods undefined. + +- [ ] **Step 3: Implement oidc_state.go** + +```go +// internal/store/oidc_state.go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// PutOIDCState stores the (state_hash, code_verifier) pair created +// at /auth/oidc/login start. Called once per login attempt. +func (s *Store) PutOIDCState(ctx context.Context, stateHash, verifier string, createdAt time.Time) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO oidc_state (state_hash, code_verifier, created_at) + VALUES (?, ?, ?)`, + stateHash, verifier, + createdAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("store: put oidc state: %w", err) + } + return nil +} + +// ConsumeOIDCState atomically reads + deletes the row in one go, +// returning the code_verifier. Single-use — a re-play returns +// ErrNotFound. Used by the OIDC callback handler. +func (s *Store) ConsumeOIDCState(ctx context.Context, stateHash string) (string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("store: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + var verifier string + err = tx.QueryRowContext(ctx, + `SELECT code_verifier FROM oidc_state WHERE state_hash = ?`, + stateHash).Scan(&verifier) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", fmt.Errorf("store: consume oidc state: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM oidc_state WHERE state_hash = ?`, stateHash); err != nil { + return "", fmt.Errorf("store: delete oidc state: %w", err) + } + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("store: commit: %w", err) + } + return verifier, nil +} + +// CleanupExpiredOIDCState removes entries created before cutoff. +// Called on the alert engine's 60s tick alongside setup-token sweep. +func (s *Store) CleanupExpiredOIDCState(ctx context.Context, cutoff time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM oidc_state WHERE created_at < ?`, + cutoff.UTC().Format(time.RFC3339Nano)) + if err != nil { + return 0, fmt.Errorf("store: cleanup oidc state: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} +``` + +- [ ] **Step 4: Run tests, expect PASS** + +Run: `go test ./internal/store/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/oidc_state.go internal/store/oidc_state_test.go +git commit -m "store: oidc_state CRUD + 5-minute cleanup" +``` + +--- + +## Slice B — Config + +### Task B1: OIDCConfig struct + load + validate + +**Files:** +- Create: `internal/server/config/oidc.go` +- Create: `internal/server/config/oidc_test.go` +- Modify: `internal/server/config/config.go` + +- [ ] **Step 1: Write tests** + +```go +// internal/server/config/oidc_test.go +package config + +import "testing" + +func TestOIDCParseDisabledWhenIssuerEmpty(t *testing.T) { + t.Parallel() + c, err := loadOIDC(map[string]string{}, OIDCConfig{}) + if err != nil { + t.Fatalf("load: %v", err) + } + if c != nil { + t.Errorf("expected nil OIDC config when issuer empty; got %+v", c) + } +} + +func TestOIDCRejectMissingClientID(t *testing.T) { + t.Parallel() + yaml := OIDCConfig{Issuer: "https://x", ClientSecret: "s"} + if _, err := loadOIDC(map[string]string{}, yaml); err == nil { + t.Error("expected error for missing client_id") + } +} + +func TestOIDCRejectMissingClientSecret(t *testing.T) { + t.Parallel() + yaml := OIDCConfig{Issuer: "https://x", ClientID: "rm"} + if _, err := loadOIDC(map[string]string{}, yaml); err == nil { + t.Error("expected error for missing client_secret") + } +} + +func TestOIDCDefaultsApplied(t *testing.T) { + t.Parallel() + yaml := OIDCConfig{ + Issuer: "https://x", ClientID: "rm", ClientSecret: "s", + RoleMapping: map[string]string{"a": "admin"}, + } + c, err := loadOIDC(map[string]string{}, yaml) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.RoleClaim != "groups" { + t.Errorf("role_claim default: got %q want groups", c.RoleClaim) + } + if c.DisplayName != "SSO" { + t.Errorf("display_name default: got %q want SSO", c.DisplayName) + } + wantScopes := []string{"openid", "profile", "email", "groups"} + if len(c.Scopes) != len(wantScopes) { + t.Errorf("scopes default: got %v want %v", c.Scopes, wantScopes) + } +} + +func TestOIDCEnvOverrides(t *testing.T) { + t.Parallel() + yaml := OIDCConfig{ + Issuer: "https://from-yaml", ClientID: "yaml-id", ClientSecret: "yaml-secret", + RoleMapping: map[string]string{"x": "admin"}, + } + envs := map[string]string{ + "RM_OIDC_ISSUER": "https://from-env", + "RM_OIDC_CLIENT_ID": "env-id", + "RM_OIDC_CLIENT_SECRET": "env-secret", + } + c, err := loadOIDC(envs, yaml) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.Issuer != "https://from-env" || c.ClientID != "env-id" || c.ClientSecret != "env-secret" { + t.Errorf("env override: got %+v", c) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./internal/server/config/ -run TestOIDC` +Expected: FAIL — types undefined. + +- [ ] **Step 3: Implement oidc.go** + +```go +// internal/server/config/oidc.go — OIDC subsection of the server +// config. Disabled when oidc.issuer is empty or absent. +package config + +import ( + "errors" + "fmt" + "os" +) + +// OIDCConfig is the OIDC sub-block. The struct doubles as YAML schema; +// loadOIDC applies env overlays on top and fills defaults. +type OIDCConfig struct { + Issuer string `yaml:"issuer"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + DisplayName string `yaml:"display_name"` + Scopes []string `yaml:"scopes"` + RoleClaim string `yaml:"role_claim"` + RoleMapping map[string]string `yaml:"role_mapping"` + RedirectURL string `yaml:"redirect_url"` +} + +// loadOIDC merges YAML + env, applies defaults, validates. Returns +// nil + nil when OIDC is disabled (issuer empty after merge); a +// non-nil OIDCConfig means the caller should wire OIDC. +// +// Env vars (override YAML when set): +// RM_OIDC_ISSUER, RM_OIDC_CLIENT_ID, RM_OIDC_CLIENT_SECRET, +// RM_OIDC_CLIENT_SECRET_FILE, RM_OIDC_DISPLAY_NAME, +// RM_OIDC_REDIRECT_URL. +// +// envs is passed in (rather than read with os.LookupEnv) so unit +// tests can supply a fake env map. +func loadOIDC(envs map[string]string, yaml OIDCConfig) (*OIDCConfig, error) { + c := yaml + if v, ok := envs["RM_OIDC_ISSUER"]; ok { + c.Issuer = v + } + if v, ok := envs["RM_OIDC_CLIENT_ID"]; ok { + c.ClientID = v + } + if v, ok := envs["RM_OIDC_CLIENT_SECRET"]; ok { + c.ClientSecret = v + } + if v, ok := envs["RM_OIDC_CLIENT_SECRET_FILE"]; ok && v != "" { + body, err := os.ReadFile(v) + if err != nil { + return nil, fmt.Errorf("config: oidc client_secret_file: %w", err) + } + c.ClientSecret = string(body) + } + if v, ok := envs["RM_OIDC_DISPLAY_NAME"]; ok { + c.DisplayName = v + } + if v, ok := envs["RM_OIDC_REDIRECT_URL"]; ok { + c.RedirectURL = v + } + + // Disabled? Treat as off. + if c.Issuer == "" { + return nil, nil + } + + // Required when enabled. + if c.ClientID == "" { + return nil, errors.New("config: oidc.client_id required when issuer is set") + } + if c.ClientSecret == "" { + return nil, errors.New("config: oidc.client_secret required when issuer is set") + } + if len(c.RoleMapping) == 0 { + return nil, errors.New("config: oidc.role_mapping must have at least one entry") + } + + // Defaults. + if c.DisplayName == "" { + c.DisplayName = "SSO" + } + if c.RoleClaim == "" { + c.RoleClaim = "groups" + } + if len(c.Scopes) == 0 { + c.Scopes = []string{"openid", "profile", "email", "groups"} + } + return &c, nil +} + +// envSnapshot reads the OIDC env vars into a map. Lets the production +// loadOIDC call site stay env-driven while tests pass an explicit +// map. +func envSnapshot() map[string]string { + keys := []string{ + "RM_OIDC_ISSUER", "RM_OIDC_CLIENT_ID", "RM_OIDC_CLIENT_SECRET", + "RM_OIDC_CLIENT_SECRET_FILE", "RM_OIDC_DISPLAY_NAME", + "RM_OIDC_REDIRECT_URL", + } + out := make(map[string]string, len(keys)) + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + out[k] = v + } + } + return out +} +``` + +- [ ] **Step 4: Wire into Config.Load** + +In `internal/server/config/config.go`, find the `Config` struct and add: + +```go +type Config struct { + // existing fields... + OIDC *OIDCConfig `yaml:"oidc"` +} +``` + +And in `Load(...)`, after the existing env reads but before `validate`: + +```go +oidc, err := loadOIDC(envSnapshot(), c.OIDCRaw()) +if err != nil { + return c, err +} +c.OIDC = oidc +``` + +You'll need a small helper because `c.OIDC` ends up being the *loaded* struct rather than the raw YAML — store the YAML in a separate transient field. Easiest: add `OIDCRaw OIDCConfig` to Config, parsed by yaml, and after merge replace with the loaded result. To keep this clean: + +```go +// in Config +type Config struct { + // existing fields... + OIDCRaw *OIDCConfig `yaml:"oidc"` + OIDC *OIDCConfig `yaml:"-"` +} + +// in Load, before validate: +var rawOIDC OIDCConfig +if c.OIDCRaw != nil { + rawOIDC = *c.OIDCRaw +} +oidc, err := loadOIDC(envSnapshot(), rawOIDC) +if err != nil { + return c, err +} +c.OIDC = oidc +``` + +- [ ] **Step 5: Run config tests** + +Run: `go test ./internal/server/config/...` +Expected: PASS. + +- [ ] **Step 6: Run all tests** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/config/oidc.go \ + internal/server/config/oidc_test.go \ + internal/server/config/config.go +git commit -m "config: OIDCConfig — YAML + env overlay with defaults" +``` + +--- + +## Slice C — OIDC client core + +### Task C1: Add deps + provider wrapper + +**Files:** +- Create: `internal/server/oidc/oidc.go` +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Add the dependencies** + +```bash +go get github.com/coreos/go-oidc/v3 +go get golang.org/x/oauth2 +go mod tidy +``` + +- [ ] **Step 2: Implement the wrapper** + +```go +// Package oidc wraps go-oidc + oauth2 in the small surface the +// HTTP handlers need: discovery, code-exchange config, ID-token +// verification, and role-claim resolution. +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" +) + +// Client bundles the discovered provider + a pre-built oauth2.Config. +// Constructed once at server start; safe for concurrent use. +type Client struct { + cfg *config.OIDCConfig + provider *gooidc.Provider + verifier *gooidc.IDTokenVerifier + oauth *oauth2.Config + endSession string // discovered end_session_endpoint, "" if none +} + +// New discovers the provider's well-known config and builds a Client. +// Network call — should be invoked once at startup with a context +// carrying a sane timeout. Returns an error on a 4xx/5xx from +// discovery so the operator finds out at startup, not on first login. +func New(ctx context.Context, cfg *config.OIDCConfig, baseURL string) (*Client, error) { + if cfg == nil { + return nil, errors.New("oidc: config nil") + } + prov, err := gooidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("oidc: discovery: %w", err) + } + redir := cfg.RedirectURL + if redir == "" { + redir = strings.TrimRight(baseURL, "/") + "/auth/oidc/callback" + } + oa := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: prov.Endpoint(), + RedirectURL: redir, + Scopes: cfg.Scopes, + } + verifier := prov.Verifier(&gooidc.Config{ClientID: cfg.ClientID}) + + // Pull end_session_endpoint out of the discovery doc — go-oidc + // doesn't expose it as a typed field, but the underlying claims + // blob does. + var doc struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + _ = prov.Claims(&doc) + + return &Client{ + cfg: cfg, + provider: prov, + verifier: verifier, + oauth: oa, + endSession: doc.EndSessionEndpoint, + }, nil +} + +// AuthURL returns the URL to redirect the browser to for the +// Authorization Code + PKCE flow. State + verifier are caller- +// supplied so the caller can persist them in the oidc_state table. +func (c *Client) AuthURL(state, codeChallenge string) string { + return c.oauth.AuthCodeURL(state, + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) +} + +// Exchange swaps a code+verifier for a token set and verifies the +// id_token. Returns the parsed Claims and the raw id_token (the +// caller stashes the raw on the session for RP-initiated logout). +func (c *Client) Exchange(ctx context.Context, code, verifier string) (*Claims, string, error) { + tok, err := c.oauth.Exchange(ctx, code, + oauth2.SetAuthURLParam("code_verifier", verifier)) + if err != nil { + return nil, "", fmt.Errorf("oidc: token exchange: %w", err) + } + rawID, ok := tok.Extra("id_token").(string) + if !ok || rawID == "" { + return nil, "", errors.New("oidc: id_token missing from token response") + } + idTok, err := c.verifier.Verify(ctx, rawID) + if err != nil { + return nil, "", fmt.Errorf("oidc: verify id_token: %w", err) + } + var raw map[string]any + if err := idTok.Claims(&raw); err != nil { + return nil, "", fmt.Errorf("oidc: claims: %w", err) + } + return parseClaims(raw, c.cfg.RoleClaim), rawID, nil +} + +// EndSessionEndpoint exposes the discovered end_session URL ("" if +// the IdP doesn't advertise one). +func (c *Client) EndSessionEndpoint() string { return c.endSession } + +// DisplayName for the SSO button on the login page. +func (c *Client) DisplayName() string { return c.cfg.DisplayName } + +// MapRole returns the role for the first matching claim value; "" if +// none match. Caller treats "" as deny. +func (c *Client) MapRole(roles []string) string { + for _, r := range roles { + if mapped, ok := c.cfg.RoleMapping[r]; ok { + return mapped + } + } + return "" +} + +// Claims is the minimal projection the callback handler cares about. +type Claims struct { + Subject string + PreferredUsername string + Email string + Roles []string // normalised from string|[]string|csv +} + +// parseClaims pulls the four fields we need from the raw id_token +// claims. The 'roles' field is normalised from the three shapes +// IdPs emit (string, []string, comma-separated string). +func parseClaims(raw map[string]any, roleClaim string) *Claims { + c := &Claims{} + if v, ok := raw["sub"].(string); ok { + c.Subject = v + } + if v, ok := raw["preferred_username"].(string); ok { + c.PreferredUsername = v + } + if v, ok := raw["email"].(string); ok { + c.Email = v + } + switch v := raw[roleClaim].(type) { + case string: + // Comma-separated or single value. + for _, p := range strings.Split(v, ",") { + p = strings.TrimSpace(p) + if p != "" { + c.Roles = append(c.Roles, p) + } + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + c.Roles = append(c.Roles, s) + } + } + } + return c +} + +// RandomState generates 32 random bytes hex-encoded — used as the +// 'state' parameter on the authorization request. Caller is expected +// to compute sha256(state) for storage. +func RandomState() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + enc, _ := json.Marshal(b[:]) // not actually used; below is the real one + _ = enc + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// PKCEPair generates a code_verifier (base64-url 64 chars) and the +// corresponding S256 code_challenge. +func PKCEPair() (verifier, challenge string, err error) { + var b [48]byte + if _, err := rand.Read(b[:]); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(b[:]) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +// HashState returns sha256(state) hex — used as the primary key in +// the oidc_state table (so a DB leak doesn't leak active states). +func HashState(state string) string { + sum := sha256.Sum256([]byte(state)) + return fmt.Sprintf("%x", sum) +} +``` + +- [ ] **Step 3: Run vet, expect PASS** + +Run: `go vet ./internal/server/oidc/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add go.mod go.sum internal/server/oidc/oidc.go +git commit -m "oidc: client wrapper around go-oidc — discovery, exchange, claim parse" +``` + +--- + +### Task C2: Stub IdP for tests + +**Files:** +- Create: `internal/server/oidc/stub_test.go` + +The stub IdP runs `httptest.NewServer` exposing the well-known config + JWKS + token endpoint. Tests mint claims, the stub signs them. + +- [ ] **Step 1: Implement the stub** + +```go +// internal/server/oidc/stub_test.go +// +// stubIdP is a minimal OIDC provider for tests — discovery doc, +// JWKS, authorize endpoint (just records the request and 302s +// back), and token endpoint (returns an id_token signed with the +// stub's ECDSA key). Each test mints its own claims; the stub +// signs them and the production verifier accepts them because the +// JWKS is fetched live. +package oidc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + stdhttp "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type stubIdP struct { + t *testing.T + srv *httptest.Server + priv *ecdsa.PrivateKey + kid string + + mu sync.Mutex + claims map[string]map[string]any // code → claims + codes map[string]bool // codes minted, single-use +} + +func newStubIdP(t *testing.T) *stubIdP { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("genkey: %v", err) + } + s := &stubIdP{ + t: t, + priv: priv, + kid: "stub-key", + claims: map[string]map[string]any{}, + codes: map[string]bool{}, + } + mux := stdhttp.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", s.discovery) + mux.HandleFunc("/jwks.json", s.jwks) + mux.HandleFunc("/token", s.token) + s.srv = httptest.NewServer(mux) + t.Cleanup(s.srv.Close) + return s +} + +// MintCode produces an authorization code that the stub will exchange +// for an id_token containing the supplied claims. +func (s *stubIdP) MintCode(claims map[string]any) string { + s.mu.Lock() + defer s.mu.Unlock() + code := fmt.Sprintf("code-%d", time.Now().UnixNano()) + s.claims[code] = claims + s.codes[code] = true + return code +} + +func (s *stubIdP) discovery(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + doc := map[string]any{ + "issuer": s.srv.URL, + "authorization_endpoint": s.srv.URL + "/authorize", + "token_endpoint": s.srv.URL + "/token", + "jwks_uri": s.srv.URL + "/jwks.json", + "id_token_signing_alg_values_supported": []string{"ES256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) +} + +func (s *stubIdP) jwks(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + pub := s.priv.Public().(*ecdsa.PublicKey) + x := base64.RawURLEncoding.EncodeToString(padTo32(pub.X.Bytes())) + y := base64.RawURLEncoding.EncodeToString(padTo32(pub.Y.Bytes())) + keys := map[string]any{ + "keys": []map[string]any{{ + "kty": "EC", "crv": "P-256", "alg": "ES256", + "use": "sig", "kid": s.kid, + "x": x, "y": y, + }}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(keys) +} + +func (s *stubIdP) token(w stdhttp.ResponseWriter, r *stdhttp.Request) { + _ = r.ParseForm() + code := r.PostForm.Get("code") + s.mu.Lock() + claims, ok := s.claims[code] + if ok { + delete(s.codes, code) + } + s.mu.Unlock() + if !ok { + stdhttp.Error(w, "bad code", stdhttp.StatusBadRequest) + return + } + // Default required claims. + if _, ok := claims["iss"]; !ok { + claims["iss"] = s.srv.URL + } + if _, ok := claims["aud"]; !ok { + claims["aud"] = "test-client" + } + now := time.Now().Unix() + claims["iat"] = now + claims["exp"] = now + 600 + + jc := jwt.MapClaims{} + for k, v := range claims { + jc[k] = v + } + tk := jwt.NewWithClaims(jwt.SigningMethodES256, jc) + tk.Header["kid"] = s.kid + signed, err := tk.SignedString(s.priv) + if err != nil { + stdhttp.Error(w, err.Error(), 500) + return + } + resp := map[string]any{ + "access_token": "stub-access", + "token_type": "Bearer", + "id_token": signed, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func padTo32(b []byte) []byte { + if len(b) >= 32 { + return b + } + out := make([]byte, 32) + copy(out[32-len(b):], b) + return out +} + +// Compile-time hint for the unused big.Int import in tests that +// reach into the curve. Leave; gofumpt won't complain. +var _ = big.NewInt +``` + +If `github.com/golang-jwt/jwt/v5` isn't already in go.mod, add it: + +```bash +go get github.com/golang-jwt/jwt/v5 +``` + +- [ ] **Step 2: Sanity test the stub** + +Append to `internal/server/oidc/oidc_test.go`: + +```go +package oidc + +import ( + "context" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" +) + +func TestClientExchangeAgainstStub(t *testing.T) { + t.Parallel() + stub := newStubIdP(t) + cfg := &config.OIDCConfig{ + Issuer: stub.srv.URL, ClientID: "test-client", ClientSecret: "x", + Scopes: []string{"openid"}, RoleClaim: "groups", + RoleMapping: map[string]string{"rm-admins": "admin"}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c, err := New(ctx, cfg, "http://rm.example") + if err != nil { + t.Fatalf("new client: %v", err) + } + code := stub.MintCode(map[string]any{ + "sub": "abc", + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"rm-admins"}, + }) + verifier, _, err := PKCEPair() + if err != nil { + t.Fatalf("pkce: %v", err) + } + claims, raw, err := c.Exchange(ctx, code, verifier) + if err != nil { + t.Fatalf("exchange: %v", err) + } + if claims.Subject != "abc" || claims.PreferredUsername != "alice" { + t.Errorf("claims: %+v", claims) + } + if c.MapRole(claims.Roles) != "admin" { + t.Errorf("role: got %q", c.MapRole(claims.Roles)) + } + if raw == "" { + t.Error("raw id_token must be non-empty") + } +} +``` + +- [ ] **Step 3: Run, expect PASS** + +Run: `go test ./internal/server/oidc/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/server/oidc/stub_test.go internal/server/oidc/oidc_test.go go.mod go.sum +git commit -m "oidc: test stub IdP + happy-path exchange test" +``` + +--- + +## Slice D — Login flow handlers + +### Task D1: GET /auth/oidc/login + +**Files:** +- Create: `internal/server/http/oidc_handlers.go` +- Create: `internal/server/http/oidc_handlers_test.go` +- Modify: `internal/server/http/server.go` (Deps + route) + +- [ ] **Step 1: Add the field to Deps** + +In `internal/server/http/server.go`: + +```go +type Deps struct { + // existing fields... + OIDC *oidc.Client // nil = OIDC disabled +} +``` + +Add `"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"` to imports. + +- [ ] **Step 2: Write a failing test** + +```go +// internal/server/http/oidc_handlers_test.go +package http + +import ( + "net/http" + stdhttp "net/http" + "strings" + "testing" +) + +func TestOIDCLoginRedirectsToIdP(t *testing.T) { + t.Parallel() + srv, ts, _ := newTestServerWithOIDC(t) + c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }} + res, err := c.Get(ts.URL + "/auth/oidc/login") + if err != nil { + t.Fatalf("get: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusSeeOther { + t.Errorf("status: got %d want 303", res.StatusCode) + } + loc := res.Header.Get("Location") + if !strings.Contains(loc, "code_challenge=") || !strings.Contains(loc, "state=") { + t.Errorf("location: %q", loc) + } + _ = srv +} +``` + +`newTestServerWithOIDC(t)` is a new helper that wires a stub IdP into a Server. Implement it next: + +In `oidc_handlers_test.go` add: + +```go +import ( + "context" + stdhttp "net/http" + "net/http/httptest" + "path/filepath" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// newTestServerWithOIDC returns a Server wired to a stub IdP that the +// returned helper exposes for claim-minting. Each test gets its own +// stub. +func newTestServerWithOIDC(t *testing.T) (*Server, *httptest.Server, *stubServerHandle) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + keyPath := filepath.Join(dir, "secret.key") + if err := crypto.GenerateKeyFile(keyPath); err != nil { + t.Fatalf("genkey: %v", err) + } + key, _ := crypto.LoadKeyFromFile(keyPath) + aead, _ := crypto.NewAEAD(key) + + stub := newStubIdPHandle(t) + cfg := &config.OIDCConfig{ + Issuer: stub.URL, ClientID: "test-client", ClientSecret: "x", + Scopes: []string{"openid"}, RoleClaim: "groups", + RoleMapping: map[string]string{ + "rm-admins": "admin", + "rm-operators": "operator", + "rm-viewers": "viewer", + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + oidcClient, err := oidc.New(ctx, cfg, "http://test") + if err != nil { + t.Fatalf("oidc client: %v", err) + } + + deps := Deps{ + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath, BaseURL: "http://test"}, + Store: st, + AEAD: aead, + OIDC: oidcClient, + } + s := New(deps) + ts := httptest.NewServer(s.srv.Handler) + t.Cleanup(ts.Close) + return s, ts, stub +} + +// stubServerHandle wraps the *httptest.Server returned by the stub +// so callers can pull the URL and mint codes without leaking the +// stub package types. +type stubServerHandle struct { + *httptest.Server + stub *stubIdP +} + +func (h *stubServerHandle) MintCode(claims map[string]any) string { + return h.stub.MintCode(claims) +} + +func newStubIdPHandle(t *testing.T) *stubServerHandle { + t.Helper() + s := newStubIdP(t) + return &stubServerHandle{Server: s.srv, stub: s} +} +``` + +The stub uses package-private state — that's fine because the test sits in `package http` while the stub in `package oidc` exposes via the `stub_test.go` file (compiled in tests only, both packages get visibility through the unified test binary as imports). Actually, since the stub is in the `oidc` package's test files, we'll need to either move it to a shared `oidctest` package or reach across via a small build-time export. **Simplest path:** create a `internal/server/oidc/oidctest/stub.go` (non-test file, but in a package that's only imported from tests by convention). + +**Decision:** move `stub_test.go` to `internal/server/oidc/oidctest/stub.go` and rename the function `newStubIdP` → `New`. The tests in `internal/server/http/...` import this. This is the conventional "test fixtures package" Go pattern. Make this change now in this task before continuing. + +```bash +mkdir -p internal/server/oidc/oidctest +git mv internal/server/oidc/stub_test.go internal/server/oidc/oidctest/stub.go +# edit the file: package oidc → package oidctest; +# stubIdP → StubIdP (exported); newStubIdP → New (exported); +# srv exported as StubIdP.URL via embedding *httptest.Server, etc. +``` + +Update `internal/server/oidc/oidc_test.go` to import `…/oidctest` instead of using local types. + +Update `internal/server/http/oidc_handlers_test.go` to import `…/oidctest` and drop the duplicated handle. + +This refactor is a bit of work but pays off — every place that wants to test OIDC reaches for the same `oidctest.New(t)`. + +- [ ] **Step 3: Implement the login start handler** + +```go +// internal/server/http/oidc_handlers.go +// +// OIDC sign-in handlers — public routes when oidc is configured, +// otherwise not mounted. +package http + +import ( + "log/slog" + stdhttp "net/http" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc" +) + +// handleOIDCLogin generates state + PKCE pair, persists them, and +// redirects to the IdP authorization endpoint. +func (s *Server) handleOIDCLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { + state, err := oidc.RandomState() + if err != nil { + slog.Error("oidc login: state", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + verifier, challenge, err := oidc.PKCEPair() + if err != nil { + slog.Error("oidc login: pkce", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.PutOIDCState(r.Context(), + oidc.HashState(state), verifier, time.Now().UTC()); err != nil { + slog.Error("oidc login: persist state", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.Redirect(w, r, s.deps.OIDC.AuthURL(state, challenge), stdhttp.StatusSeeOther) +} +``` + +- [ ] **Step 4: Wire route + run test** + +In `routes()`, inside the public band, after the existing `/login` routes: + +```go +if s.deps.OIDC != nil { + r.Get("/auth/oidc/login", s.handleOIDCLogin) + // callback registered in D2 +} +``` + +Run: `go test ./internal/server/http/ -run TestOIDCLoginRedirectsToIdP` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/oidc/oidctest/ internal/server/oidc/ internal/server/http/oidc_handlers.go internal/server/http/oidc_handlers_test.go internal/server/http/server.go +git commit -m "http: GET /auth/oidc/login — generate state/PKCE, redirect to IdP" +``` + +--- + +### Task D2: GET /auth/oidc/callback + +**Files:** +- Modify: `internal/server/http/oidc_handlers.go` +- Modify: `internal/server/http/oidc_handlers_test.go` + +- [ ] **Step 1: Tests for the four claim-resolution branches** + +Append to `oidc_handlers_test.go`: + +```go +import ( + "net/http/cookiejar" + "net/url" +) + +// runCallback drives the auth code flow against the stub: kicks off +// /auth/oidc/login (capturing the state), mints a code at the stub +// with the given claims, then GETs /auth/oidc/callback. Returns the +// final response. +func runCallback(t *testing.T, ts *httptest.Server, stub *stubServerHandle, claims map[string]any) *stdhttp.Response { + t.Helper() + jar, _ := cookiejar.New(nil) + c := &stdhttp.Client{Jar: jar, CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }} + res, err := c.Get(ts.URL + "/auth/oidc/login") + if err != nil { + t.Fatalf("login: %v", err) + } + res.Body.Close() + authURL, _ := url.Parse(res.Header.Get("Location")) + state := authURL.Query().Get("state") + + code := stub.MintCode(claims) + res, err = c.Get(ts.URL + "/auth/oidc/callback?code=" + code + "&state=" + state) + if err != nil { + t.Fatalf("callback: %v", err) + } + return res +} + +func TestOIDCCallbackHappyPathAdmin(t *testing.T) { + t.Parallel() + srv, ts, stub := newTestServerWithOIDC(t) + res := runCallback(t, ts, stub, map[string]any{ + "sub": "admin-sub", + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"rm-admins"}, + "aud": "test-client", + }) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther || res.Header.Get("Location") != "/" { + t.Errorf("status: %d Location: %q", res.StatusCode, res.Header.Get("Location")) + } + u, err := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "admin-sub") + if err != nil || u.AuthSource != "oidc" || u.Role != "admin" || u.Username != "alice" { + t.Errorf("user: %+v err: %v", u, err) + } +} + +func TestOIDCCallbackNoRoleMatchDeny(t *testing.T) { + t.Parallel() + _, ts, stub := newTestServerWithOIDC(t) + res := runCallback(t, ts, stub, map[string]any{ + "sub": "other-sub", + "preferred_username": "bob", + "groups": []string{"something-else"}, + "aud": "test-client", + }) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Errorf("status: got %d want 303", res.StatusCode) + } + loc := res.Header.Get("Location") + if !strings.Contains(loc, "oidc_error=no_role_match") { + t.Errorf("location: %q", loc) + } +} + +func TestOIDCCallbackUsernameCollision(t *testing.T) { + t.Parallel() + srv, ts, stub := newTestServerWithOIDC(t) + // Pre-create a local 'alice'. + _ = srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: "local-alice", Username: "alice", PasswordHash: "x", + Role: store.RoleViewer, CreatedAt: time.Now().UTC(), + }) + + res := runCallback(t, ts, stub, map[string]any{ + "sub": "remote-sub", + "preferred_username": "alice", + "groups": []string{"rm-admins"}, + "aud": "test-client", + }) + defer res.Body.Close() + loc := res.Header.Get("Location") + if !strings.Contains(loc, "oidc_error=username_taken") { + t.Errorf("location: %q", loc) + } + if _, err := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "remote-sub"); err == nil { + t.Error("collision should not have provisioned a user") + } +} + +func TestOIDCCallbackReturningUserRefreshesRole(t *testing.T) { + t.Parallel() + srv, ts, stub := newTestServerWithOIDC(t) + // First sign-in as operator. + res := runCallback(t, ts, stub, map[string]any{ + "sub": "carol-sub", + "preferred_username": "carol", + "groups": []string{"rm-operators"}, + "aud": "test-client", + }) + res.Body.Close() + // Second sign-in as admin (group membership changed at the IdP). + res = runCallback(t, ts, stub, map[string]any{ + "sub": "carol-sub", + "preferred_username": "carol", + "groups": []string{"rm-admins"}, + "aud": "test-client", + }) + res.Body.Close() + u, _ := srv.deps.Store.GetUserByOIDCSubject(t.Context(), "carol-sub") + if u.Role != "admin" { + t.Errorf("role refresh: got %q want admin", u.Role) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** (callback not implemented) + +Run: `go test ./internal/server/http/ -run TestOIDCCallback` +Expected: FAIL. + +- [ ] **Step 3: Implement the callback** + +Append to `oidc_handlers.go`: + +```go +import ( + "errors" + "strings" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func (s *Server) handleOIDCCallback(w stdhttp.ResponseWriter, r *stdhttp.Request) { + q := r.URL.Query() + code := q.Get("code") + state := q.Get("state") + if code == "" || state == "" { + s.oidcRedirectError(w, r, "missing_params") + return + } + verifier, err := s.deps.Store.ConsumeOIDCState(r.Context(), oidc.HashState(state)) + if err != nil { + s.oidcRedirectError(w, r, "bad_state") + return + } + claims, rawIDToken, err := s.deps.OIDC.Exchange(r.Context(), code, verifier) + if err != nil { + slog.Warn("oidc callback: exchange", "err", err) + s.oidcRedirectError(w, r, "exchange_failed") + return + } + + // Username = preferred_username, fall back to email. + uname := strings.ToLower(strings.TrimSpace(claims.PreferredUsername)) + if uname == "" { + uname = strings.ToLower(strings.TrimSpace(claims.Email)) + } + if uname == "" || claims.Subject == "" { + s.oidcRedirectError(w, r, "missing_claims") + return + } + + role := s.deps.OIDC.MapRole(claims.Roles) + if role == "" { + _ = s.auditOIDCBlocked(r, claims, "no_role_match") + s.oidcRedirectError(w, r, "no_role_match") + return + } + + now := time.Now().UTC() + + // Existing OIDC user? Refresh role + email + last_login. + existing, err := s.deps.Store.GetUserByOIDCSubject(r.Context(), claims.Subject) + if err == nil { + if existing.DisabledAt != nil { + s.oidcRedirectError(w, r, "user_disabled") + return + } + _ = s.deps.Store.SetUserRole(r.Context(), existing.ID, store.Role(role)) + _ = s.deps.Store.SetUserEmail(r.Context(), existing.ID, claims.Email) + _ = s.deps.Store.MarkUserLogin(r.Context(), existing.ID, now) + s.oidcDropSessionAndRedirect(w, r, existing.ID, rawIDToken, now) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &existing.ID, Actor: "user", + Action: "user.oidc_login", TargetKind: ptr("user"), + TargetID: &existing.ID, TS: now, + }) + return + } else if !errors.Is(err, store.ErrNotFound) { + slog.Error("oidc callback: lookup by sub", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + // New OIDC user. Username collision against a local user? + if local, err := s.deps.Store.GetUserByUsername(r.Context(), uname); err == nil { + _ = local + _ = s.auditOIDCBlocked(r, claims, "username_taken") + s.oidcRedirectError(w, r, "username_taken") + return + } else if !errors.Is(err, store.ErrNotFound) { + slog.Error("oidc callback: lookup by username", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + // JIT-provision. + id := ulid.Make().String() + var emailPtr *string + if claims.Email != "" { + em := strings.ToLower(claims.Email) + emailPtr = &em + } + sub := claims.Subject + if err := s.deps.Store.CreateUser(r.Context(), store.User{ + ID: id, Username: uname, PasswordHash: "", + Role: store.Role(role), Email: emailPtr, + AuthSource: "oidc", OIDCSubject: &sub, + CreatedAt: now, + }); err != nil { + slog.Error("oidc callback: provision", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.MarkUserLogin(r.Context(), id, now) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &id, Actor: "user", + Action: "user.created", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &id, Actor: "user", + Action: "user.oidc_login", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + s.oidcDropSessionAndRedirect(w, r, id, rawIDToken, now) +} + +func (s *Server) oidcDropSessionAndRedirect(w stdhttp.ResponseWriter, r *stdhttp.Request, userID, idToken string, now time.Time) { + rawSession, err := auth.NewToken() + if err != nil { + slog.Error("oidc: session token", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + hashed := auth.HashToken(rawSession) + if err := s.deps.Store.CreateSession(r.Context(), store.Session{ + ID: hashed, UserID: userID, CreatedAt: now, + ExpiresAt: now.Add(8 * time.Hour), + IDToken: idToken, + }, hashed); err != nil { + slog.Error("oidc: create session", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, Value: rawSession, + Path: "/", HttpOnly: true, + SameSite: stdhttp.SameSiteLaxMode, + Secure: s.deps.Cfg.CookieSecure, + Expires: now.Add(8 * time.Hour), + }) + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} + +func (s *Server) oidcRedirectError(w stdhttp.ResponseWriter, r *stdhttp.Request, code string) { + stdhttp.Redirect(w, r, "/login?oidc_error="+code, stdhttp.StatusSeeOther) +} + +// auditOIDCBlocked records a failed sign-in. user_id is nil because +// no row was created; we put the IdP subject in the payload so the +// admin can correlate. +func (s *Server) auditOIDCBlocked(r *stdhttp.Request, claims *oidc.Claims, reason string) error { + payload := map[string]any{ + "sub": claims.Subject, + "username": claims.PreferredUsername, + "reason": reason, + } + body, _ := jsonMarshal(payload) + return s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: nil, Actor: "system", + Action: "user.oidc_login_blocked", TargetKind: ptr("user"), + TargetID: nil, TS: time.Now().UTC(), + Payload: body, + }) +} + +// jsonMarshal — small wrapper so the callback file doesn't need a +// direct encoding/json import. +func jsonMarshal(v any) (json.RawMessage, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return json.RawMessage(b), nil +} +``` + +The `json` import + `json.RawMessage` need adding to the file's imports. + +- [ ] **Step 4: Wire the route** + +In `routes()`, alongside the login route: + +```go +r.Get("/auth/oidc/callback", s.handleOIDCCallback) +``` + +- [ ] **Step 5: Run, expect PASS** + +Run: `go test ./internal/server/http/ -run TestOIDCCallback` +Expected: all four PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/http/oidc_handlers.go \ + internal/server/http/oidc_handlers_test.go \ + internal/server/http/server.go +git commit -m "http: GET /auth/oidc/callback — JIT-provision, refresh, deny paths" +``` + +--- + +## Slice E — Logout & local-login rejection + +### Task E1: Logout — branch on AuthSource + +**Files:** +- Modify: `internal/server/http/auth.go` (handleLogout) and/or `internal/server/http/ui_handlers.go` (handleUILogoutPost) + +- [ ] **Step 1: Test** + +```go +// in oidc_handlers_test.go +func TestOIDCLogoutRedirectsToEndSession(t *testing.T) { + t.Parallel() + // Configure stub to advertise an end_session endpoint. + srv, ts, stub := newTestServerWithOIDC(t) + stub.stub.SetEndSessionEndpoint(stub.URL + "/logout-end") + // Need to rebuild the OIDC client because end_session is read at + // New time. Helper: reload. + srv.deps.OIDC = mustReloadOIDCClient(t, stub, srv) + // First, sign in. + res := runCallback(t, ts, stub, map[string]any{ + "sub": "logout-sub", + "preferred_username": "lo", + "groups": []string{"rm-admins"}, + "aud": "test-client", + }) + defer res.Body.Close() + cookie := res.Cookies()[0] + + // POST /logout — should 303 to the end_session endpoint with + // id_token_hint. + c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }} + req, _ := stdhttp.NewRequest("POST", ts.URL+"/logout", nil) + req.AddCookie(cookie) + res, err := c.Do(req) + if err != nil { + t.Fatalf("logout: %v", err) + } + defer res.Body.Close() + loc := res.Header.Get("Location") + if !strings.Contains(loc, "/logout-end") || !strings.Contains(loc, "id_token_hint=") { + t.Errorf("location: %q", loc) + } +} +``` + +The test needs `SetEndSessionEndpoint` and a reload helper. Add `SetEndSessionEndpoint(url string)` on `oidctest.StubIdP` that mutates the discovery doc, plus `mustReloadOIDCClient`. Both small additions to the test harness. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./internal/server/http/ -run TestOIDCLogoutRedirectsToEndSession` +Expected: FAIL — current logout always redirects to `/login`. + +- [ ] **Step 3: Update the logout handler** + +Locate the existing `handleLogout` (JSON) and `handleUILogoutPost` (HTML). Both currently: +1. Look up the session +2. Delete it +3. 303 to `/login` (HTML) or 200 (JSON) + +Augment the HTML one (the JSON one stays unchanged — API clients don't browser-redirect): + +```go +func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + hash := auth.HashToken(c.Value) + sess, _ := s.deps.Store.LookupSession(r.Context(), hash) + _ = s.deps.Store.DeleteSession(r.Context(), hash) + + // Default: drop session, go to /login. + dest := "/login" + + // OIDC session with a discovered end_session_endpoint? Compose + // the IdP logout URL with id_token_hint + post_logout_redirect_uri. + if sess != nil && sess.IDToken != "" && s.deps.OIDC != nil && + s.deps.OIDC.EndSessionEndpoint() != "" { + v := url.Values{} + v.Set("id_token_hint", sess.IDToken) + if base := strings.TrimRight(s.deps.Cfg.BaseURL, "/"); base != "" { + v.Set("post_logout_redirect_uri", base+"/login") + } + dest = s.deps.OIDC.EndSessionEndpoint() + "?" + v.Encode() + } + + // Clear the cookie. + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, Value: "", Path: "/", + MaxAge: -1, HttpOnly: true, + }) + stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther) +} +``` + +Add `"net/url"` to the file's imports if not already present. + +- [ ] **Step 4: Run tests, expect PASS** + +Run: `go test ./internal/server/http/...` +Expected: PASS, including `TestOIDCLogoutRedirectsToEndSession`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/ui_handlers.go internal/server/oidc/oidctest/stub.go internal/server/http/oidc_handlers_test.go +git commit -m "http: logout — 303 to end_session_endpoint with id_token_hint for OIDC sessions" +``` + +--- + +### Task E2: Local login rejects OIDC users + +**Files:** +- Modify: `internal/server/http/auth.go` + +- [ ] **Step 1: Test** + +```go +func TestLocalLoginRejectsOIDCUser(t *testing.T) { + t.Parallel() + srv, urlBase := newTestServer(t, false) + uid := "u-oidc" + sub := "sub-x" + if err := srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: uid, Username: "ouser", PasswordHash: "", + Role: store.RoleOperator, CreatedAt: time.Now().UTC(), + AuthSource: "oidc", OIDCSubject: &sub, + }); err != nil { + t.Fatalf("create: %v", err) + } + + body, _ := json.Marshal(map[string]string{ + "username": "ouser", "password": "anything", + }) + res, _ := stdhttp.Post(urlBase+"/api/auth/login", + "application/json", bytes.NewReader(body)) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** (current handler likely accepts an empty-hash compare) + +Run: `go test ./internal/server/http/ -run TestLocalLoginRejectsOIDCUser` +Expected: FAIL. + +- [ ] **Step 3: Add the gate** + +In `auth.go`, find the body of `authenticateAndSession` (or whatever the shared login helper is — see C1's prior C1 task). After the user is fetched and BEFORE the password compare: + +```go +if u.AuthSource == "oidc" { + return nil, errInvalidCredentials +} +``` + +This returns the same generic "invalid credentials" error so we don't leak the existence of the OIDC account. + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./internal/server/http/ -run TestLocalLoginRejectsOIDCUser` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/auth.go internal/server/http/oidc_handlers_test.go +git commit -m "http: local-login rejects auth_source='oidc' users" +``` + +--- + +## Slice F — UI + +### Task F1: Login page SSO button + error banner + +**Files:** +- Modify: `web/templates/pages/login.html` +- Modify: `internal/server/http/ui_handlers.go` (loginPage view model) + +- [ ] **Step 1: Add fields to the login page model** + +Find `loginPage` (the struct used to render `login.html`). Add: + +```go +type loginPage struct { + // existing fields... + OIDCEnabled bool + OIDCDisplayName string + OIDCError string +} +``` + +In `handleUILoginGet` (and any related re-render path), populate: + +```go +if s.deps.OIDC != nil { + p.OIDCEnabled = true + p.OIDCDisplayName = s.deps.OIDC.DisplayName() +} +p.OIDCError = r.URL.Query().Get("oidc_error") +``` + +- [ ] **Step 2: Update the template** + +In `web/templates/pages/login.html`, add an SSO block above the password form. Wrap the existing password form in an "or sign in with a local account" label. + +```html +{{$page := .Page}} +… +{{if $page.OIDCError}} +
+
+ {{if eq $page.OIDCError "no_role_match"}}Your account does not match any role mapping. Contact your administrator. + {{else if eq $page.OIDCError "username_taken"}}A local account with the same username already exists. Contact your administrator. + {{else if eq $page.OIDCError "user_disabled"}}Your account has been disabled. Contact your administrator. + {{else}}Sign-in via SSO failed ({{$page.OIDCError}}). Try again or use a local account.{{end}} +
+
+{{end}} + +{{if $page.OIDCEnabled}} + + Sign in with {{$page.OIDCDisplayName}} + +
+
+ or sign in with a local account +
+
+{{end}} + +{{/* existing password form follows… */}} +``` + +- [ ] **Step 3: Build + manual smoke** + +```bash +make build +# restart, visit /login — SSO button shows when stub OIDC is wired; +# adding ?oidc_error=no_role_match shows the banner. +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/server/http/ui_handlers.go web/templates/pages/login.html +git commit -m "ui: login page — SSO button + oidc_error banner" +``` + +--- + +### Task F2: Users list `oidc` chip + edit-user readonly + +**Files:** +- Modify: `internal/server/http/ui_users.go` (userRow + userFormPage) +- Modify: `web/templates/pages/users.html` +- Modify: `web/templates/pages/user_edit.html` + +- [ ] **Step 1: Pass auth_source through to the list page** + +In `userRow`, add `AuthSource string`. Populate in the loop in `handleUIUsersList`. In `users.html`, render a small chip next to the Status chip when `AuthSource == "oidc"`: + +```html +{{if eq .AuthSource "oidc"}}oidc{{end}} +``` + +- [ ] **Step 2: Pass auth_source through to edit form** + +In `userFormPage`, add `AuthSource string`. Populate in `handleUIUserEditGet` from `target.AuthSource`. In `user_edit.html`, when `AuthSource == "oidc"`: + +- Add `readonly disabled` to the username input (already conditional on Mode != "new") +- Disable the role + email fields: + ```html + {{if eq $page.AuthSource "oidc"}} +
+ This user is provisioned via OIDC. Username, role, and email + are managed by your IdP and refreshed on each sign-in. +
+ {{end}} + ``` +- Hide "Regenerate setup link" + "Force logout" stays visible (still useful for OIDC users to kick mid-session) + +In the POST handler `handleUIUserEditPost`, reject role/email changes when `target.AuthSource == "oidc"`: + +```go +if target.AuthSource == "oidc" { + stdhttp.Error(w, "OIDC users cannot have role/email edited locally", stdhttp.StatusForbidden) + return +} +``` + +- [ ] **Step 3: Build + manual smoke** + +```bash +make build +# restart, visit /settings/users — the OIDC user from D2 has the chip. +# Open their edit page — fields disabled, the explanation note appears. +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/server/http/ui_users.go web/templates/pages/users.html web/templates/pages/user_edit.html +git commit -m "ui(users): oidc chip on list + readonly fields on edit for OIDC users" +``` + +--- + +## Slice G — Wiring & sweep + +### Task G1: OIDC client startup + cleanup tick + +**Files:** +- Modify: `cmd/server/main.go` +- Modify: `internal/alert/engine.go` + +- [ ] **Step 1: Build the OIDC client at startup when configured** + +In `cmd/server/main.go`, after the config is loaded and before `New(deps)`: + +```go +var oidcClient *oidc.Client +if cfg.OIDC != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + oidcClient, err = oidc.New(ctx, cfg.OIDC, cfg.BaseURL) + if err != nil { + log.Fatalf("oidc: %v", err) + } + slog.Info("oidc enabled", "issuer", cfg.OIDC.Issuer, "display", cfg.OIDC.DisplayName) +} + +deps := http.Deps{ + // existing fields... + OIDC: oidcClient, +} +``` + +Add `"gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"` to imports. + +- [ ] **Step 2: Extend cleanup tick** + +In `internal/alert/engine.go`'s `tick()` method, add a 5-minute cutoff sweep alongside the existing setup-token cleanup: + +```go +// alongside the existing setup-token cleanup line: +if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil { + slog.Warn("alert: cleanup expired oidc state", "err", err) +} +``` + +- [ ] **Step 3: Run all tests** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/server/main.go internal/alert/engine.go +git commit -m "server: build OIDC client at startup; sweep oidc_state on alert tick" +``` + +--- + +### Task G2: Live Authelia sweep + tasks.md tick + +**Files:** +- Modify: `tasks.md` +- Output: `_diag/p4-05-sweep/*.png` + +- [ ] **Step 1: Configure smoke env** + +```bash +mkdir -p _diag/p4-05-sweep +# Source the OIDC env saved earlier: +source /tmp/rm-smoke/oidc.env +# Plus the existing smoke vars: +export RM_LISTEN=:8080 \ + RM_DATA_DIR=/tmp/rm-smoke/data \ + RM_BASE_URL=http://127.0.0.1:8080 \ + RM_SECRET_KEY_FILE=/tmp/rm-smoke/data/secret.key \ + RM_COOKIE_SECURE=false +# Add the role mapping via a small YAML overlay since env doesn't fit: +cat > /tmp/rm-smoke/oidc.yaml <<'YAML' +oidc: + role_mapping: + rm-admins: admin + rm-operators: operator + rm-viewers: viewer +YAML +# Server reads YAML when RM_CONFIG_FILE is set. +export RM_CONFIG_FILE=/tmp/rm-smoke/oidc.yaml +make build +./bin/restic-manager-server >> /tmp/rm-smoke/server.log 2>&1 & +``` + +If the Config struct doesn't honour `RM_CONFIG_FILE`, work out how the existing smoke env loads YAML and follow the same pattern. (The existing smoke uses pure env so we may need to add the role_mapping support directly to YAML loading — already done in B1, just needs a config file.) + +- [ ] **Step 2: Sweep** + +Open `/login` in a browser. Expect: +- "Sign in with Authelia" button at the top, divider line, password form below + +Click "Sign in with Authelia". Authelia login form. Sign in as `rm-admin / K4ooo6ERgcu287I`. Expect: +- 303 back to `/auth/oidc/callback?code=…&state=…`, then 303 to `/` +- Dashboard renders — admin sees full nav including Settings + +Visit `/settings/users`. Expect: +- The bootstrap admin row + a new row for `rm-admin` with role `admin` and an `oidc` chip in the Status column + +Sign out. Expect: +- Local session cookie cleared, redirect back to `/login` (no `end_session_endpoint` advertised by Authelia → graceful degrade) + +Sign in as `rm-other / MmLhAtD7Qa9a82Yz` — Authelia accepts, restic-manager rejects: +- 303 back to `/login?oidc_error=no_role_match`, banner shows "Your account does not match any role mapping…" +- No row created in `users` for `rm-other` + +Sign in as `rm-operator`. Visit `/settings/users` (now blocked — 403 forbidden page) and `/` (200). Confirm operator-band correctly enforces. + +Sign in as `rm-viewer`. Click Run-now on a host (gone — viewer can't see operator UIs). + +Capture screenshots of each step into `_diag/p4-05-sweep/`. + +- [ ] **Step 3: Tick tasks.md** + +Replace the P4-05 line in `tasks.md`: + +```markdown +- [x] **P4-05** (L) OIDC login (generic provider config, group → role mapping) + +> **As shipped (2026-05-05):** Authorization Code + PKCE flow against +> a single configured issuer. JIT-provision local rows on first sign- +> in (auth_source='oidc', oidc_subject); subsequent logins refresh +> role + email + last_login from the latest claims. YAML/env config +> with `roles_claim` defaulting to `groups` (Authelia / Keycloak / +> Authentik); `role_mapping: groupname → admin|operator|viewer`; no +> match → deny with the `no_role_match` banner. Local-user collisions +> blocked at sign-in (`username_taken` banner). Sessions stash the +> id_token to drive RP-initiated logout when the IdP advertises +> `end_session_endpoint` (Authelia doesn't, graceful degrade hits +> `/login`). Local users keep working alongside OIDC; `auth_source= +> 'oidc'` rows reject password-login and the edit page disables +> username/role/email since the IdP is the source of truth. +> Verified live against Authelia at https://auth.dcglab.co.uk with +> the four test users (admin/operator/viewer/other-deny). +``` + +- [ ] **Step 4: Commit** + +```bash +git add tasks.md +git commit -m "tasks: tick P4-05 — OIDC login shipped; Authelia sweep verified" +``` + +--- + +## Self-review notes + +**Spec coverage:** + +| Spec section | Task | +|---|---| +| Schema (auth_source, oidc_subject, sessions.id_token, oidc_state) | A1 | +| User struct + Session struct | A2 | +| GetUserByOIDCSubject + scanUser | A3 | +| Session IDToken round-trip | A4 | +| oidc_state CRUD + cleanup | A5 | +| OIDCConfig + YAML/env load + validate | B1 | +| Provider discovery + claim parse + role mapping | C1 | +| Test stub IdP | C2 | +| Login start (state, PKCE) | D1 | +| Callback (4 branches: refresh / JIT / collision / no-role) | D2 | +| Logout — RP-initiated when end_session advertised | E1 | +| Local-login rejects OIDC users (HTML + JSON share helper) | E2 | +| Login page SSO button + error banner | F1 | +| Users list oidc chip; edit-user readonly fields | F2 | +| Wiring + cleanup tick | G1 | +| Live Authelia sweep + tasks.md | G2 | + +Acceptance items map directly to test cases (D2 covers all four branches; E1 covers the logout flavour; F2 covers the readonly edit; existing disable-mid-session covers admin-disable). + +**Placeholder scan:** every code block is concrete; no TBD/TODO. The "if a `nullableStr` already exists, reuse it" caveat in A4 is a small instruction-not-content moment but the alternative is shown. + +**Type consistency:** `User.AuthSource string`, `User.OIDCSubject *string`, `Session.IDToken string`, `oidc.Claims` shape (Subject/PreferredUsername/Email/Roles), `oidc.Client.MapRole(roles []string) string`, audit actions `user.created` / `user.oidc_login` / `user.oidc_login_blocked` — all referenced consistently in handlers and tests. + +**One callout for the executor:** Tasks A2–A4 walk a deliberately-broken intermediate (Step 4 of A2 commits broken code that A3 fixes immediately). Don't be alarmed — this matches the pattern used in earlier slices (e.g. A3 of P4-03/04 plan).