diff --git a/cmd/server/main.go b/cmd/server/main.go
index cb3a207..a97022d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -19,6 +19,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -92,6 +93,17 @@ func run() error {
return fmt.Errorf("ui: %w", err)
}
+ 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 {
+ return fmt.Errorf("oidc: %w", err)
+ }
+ slog.Info("oidc enabled", "issuer", cfg.OIDC.Issuer, "display", cfg.OIDC.DisplayName)
+ }
+
deps := rmhttp.Deps{
Cfg: cfg,
Store: st,
@@ -102,6 +114,7 @@ func run() error {
NotificationHub: notifHub,
UI: renderer,
Version: version,
+ OIDC: oidcClient,
}
// First-run bootstrap: if the users table is empty, mint a one-time
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).
diff --git a/docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md b/docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md
new file mode 100644
index 0000000..f0cac64
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md
@@ -0,0 +1,215 @@
+# P4-05 — OIDC Login Design
+
+> **Date:** 2026-05-05
+> **Status:** brainstorm complete; ready for plan
+> **Closes:** P4-05 (OIDC login)
+
+## Goal
+
+Wire OpenID Connect authentication as a sign-in path alongside the existing local-user system, so a deployment that already has an IdP (Authelia, Authentik, Keycloak, Okta, Auth0, etc.) can use it for restic-manager logins.
+
+## Architecture
+
+OIDC sits on top of the local-user system rather than replacing it. The first time a user signs in via OIDC the server **just-in-time provisions** a local user row marked `auth_source='oidc'`, with role derived from the IdP's `roles` claim. Subsequent sign-ins look up the same row by stable `oidc_subject` and refresh role + email from the latest claims. Once the row exists it behaves like any other local user — admin can disable it, force-logout, see it in audit logs, etc. — except password-login is rejected because there's no password.
+
+The Authorization Code flow (with PKCE) is implemented against the discovered well-known config of a single configured issuer. Front-channel logout: clicking Sign out drops the local session + redirects the browser to the IdP's `end_session_endpoint` (when advertised). Back-channel logout deferred.
+
+## Locked decisions
+
+| Decision | Pick |
+|---|---|
+| User lifecycle | **B** — JIT-provision local rows on first OIDC login (`auth_source='oidc'`, `oidc_subject`) |
+| Role mapping config | **A** — YAML/env, claim name configurable (default `groups`, matching Authelia / Keycloak / Authentik), default = deny on no-match |
+| Username source | `preferred_username`, fallback to `email` |
+| Username collision with existing local user | **Refuse** with clear remediation message |
+| Provider config | **Single provider** — `providers:` array can come later |
+| Login page layout | SSO button **above** password form; password form labelled "or sign in with a local account" |
+| OIDC users + password login | **Disabled** — `auth_source='oidc'` rows have empty `password_hash`; password form rejects them |
+| Logout shape | **Front-channel only** — drop session + redirect to `end_session_endpoint` when advertised |
+| Role re-evaluation | **At login only** — claims read at the OIDC callback; admin can disable mid-session locally |
+
+## Schema changes
+
+Migration 0019 — `users` extensions for OIDC bookkeeping:
+
+```sql
+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;
+```
+
+Both column-level ALTERs (CLAUDE.md preference). The unique partial index defends the JIT-lookup invariant (one row per IdP subject) without blocking multiple rows with NULL oidc_subject (the local users).
+
+## Configuration
+
+```yaml
+# server config — extend existing config struct
+oidc:
+ issuer: https://auth.example.com # well-known config discovered from this
+ client_id: restic-manager
+ client_secret: ${RM_OIDC_CLIENT_SECRET} # or via _FILE
+ display_name: Authelia # button label "Sign in with "; default "SSO"
+ scopes: [openid, profile, email, groups]
+ role_claim: groups # default if absent (matches Authelia / Keycloak / Authentik)
+ role_mapping:
+ rm-admins: admin
+ rm-operators: operator
+ rm-viewers: viewer
+ # Optional — auto-derived from BaseURL if absent.
+ redirect_url: https://rm.example.com/auth/oidc/callback
+```
+
+Env-var overrides: `RM_OIDC_ISSUER`, `RM_OIDC_CLIENT_ID`, `RM_OIDC_CLIENT_SECRET`, `RM_OIDC_CLIENT_SECRET_FILE`. Mapping is YAML-only (env doesn't fit a multi-key string→string map cleanly).
+
+When `oidc.issuer` is empty or missing, OIDC is disabled (current behaviour). No restart-toggle UI; this is a deploy-time setting.
+
+## Auth flow
+
+### Login start
+
+`GET /auth/oidc/login` — only mounted when OIDC is configured.
+
+1. Generate `state` (32 random bytes, base64) and `code_verifier` (64 random bytes, base64); compute `code_challenge = base64(sha256(code_verifier))`.
+2. Store `(state, code_verifier, created_at)` in a new ephemeral table (or in memory with a 5-minute TTL — see "trade-off" below).
+3. Redirect to `?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
+
+### Callback
+
+`GET /auth/oidc/callback?code=...&state=...` — also OIDC-only mount.
+
+1. Validate `state` against the stored value (one-shot — delete row on read). Reject if missing/expired/already used.
+2. Exchange `code` + `code_verifier` for tokens at `token_endpoint`.
+3. Validate the `id_token` JWT: signature against the JWKS endpoint, `iss`, `aud`, `exp`, `iat`, `nonce` (if used).
+4. Extract `sub`, `preferred_username`, `email`, and the configured `role_claim` (default `roles`).
+5. Pick username: `preferred_username` if non-empty, else `email`. Lowercase / trim per the existing local-user rules.
+6. Pick role: first match in `role_mapping` against the array of role-claim values. **No match → deny with a clear error page**, no row created.
+7. Look up user by `oidc_subject`. Three cases:
+ - **Found** — refresh `email`, `role`, `last_login_at`. Don't touch `username` (changing it would break audit trails; if the IdP changes the username, that's an operator concern). Log `user.oidc_login`.
+ - **Not found, username free** — INSERT row with `auth_source='oidc'`, `oidc_subject=`, `password_hash=''`, `must_change_password=0`. Log `user.created` with payload `{"auth_source":"oidc"}` + `user.oidc_login`.
+ - **Not found, username taken by a local user** — render an error page: "This OIDC user (``) wants to sign in as `alice`, but a local user with that name already exists. Ask your administrator to either rename / remove the local user, or exclude this user from the OIDC mapping." 403, no row created. Log `user.oidc_login_blocked`.
+8. Drop a session cookie + `MarkUserLogin` (the existing helper).
+9. Redirect to `/`.
+
+### Logout
+
+`POST /logout` (existing handler) — augmented:
+
+1. Look up the session before deletion (we need the user row to know if they're an OIDC user).
+2. Delete the session as today.
+3. If the user is `auth_source='oidc'` AND the discovered `end_session_endpoint` is non-empty → 303 to `?id_token_hint=&post_logout_redirect_uri=/login`. Otherwise → existing 303 to `/login`.
+
+We need to keep the latest `id_token` per session to drive `id_token_hint`. Stash it in a new `sessions.id_token TEXT` column (one column-level ALTER on migration 0019 alongside the user columns), populated only for OIDC sessions.
+
+## State table
+
+Two reasonable shapes for the short-lived state used during the OAuth round-trip:
+
+- **In-memory map** with a 5-minute TTL sweeper. Simpler, but multi-process deployments lose it (no multi-process today, but Phase 5 OSS readiness might add).
+- **`oidc_state` table** — `(state_hash PK, code_verifier, created_at)`, swept on the same 60s alert-engine tick that already handles setup-token cleanup.
+
+I'll go with the **table**. Costs ~3 lines in the existing cleanup tick, behaves correctly under restarts, and survives a future scale-out. Migration 0019 includes:
+
+```sql
+CREATE TABLE oidc_state (
+ state_hash TEXT PRIMARY KEY, -- sha256(state) hex; raw state never persisted
+ code_verifier TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX oidc_state_created ON oidc_state(created_at);
+```
+
+## Login-page UI
+
+`/login` template branches based on `view.OIDCEnabled`:
+
+- **OIDC off** → current layout (just the password form).
+- **OIDC on** → an `Sign in with ` button at the top, then a faint divider line, then the existing password form labelled "Or sign in with a local account". Provider name comes from a new optional config `oidc.display_name` (defaults to "SSO").
+
+Failed-OIDC redirects (no role match, username collision, IdP error) land on `/login?oidc_error=` with a small banner above the buttons.
+
+## Audit actions
+
+New entries in the action vocabulary:
+
+- `user.oidc_login` (target_kind=user, target_id=user_id, payload `{"sub":"…"}`)
+- `user.oidc_login_blocked` (target_kind=user, target_id=oidc_subject when no row was created, payload `{"username":"…", "reason":"username_taken|no_role_match|other"}`)
+- `user.created` already exists; OIDC's first-time provisioning fires this with payload `{"auth_source":"oidc"}` so the audit log distinguishes admin-created from JIT-provisioned rows.
+
+## User-management UI changes
+
+Small additions, not new screens:
+
+- **Users list** — Status column adds a small `oidc` chip when `auth_source='oidc'` so admin can see at a glance which rows came from JIT-provisioning. Sortable by auth_source via the same sortable-headers pattern (lands as a small follow-up if anyone asks; out of scope for v1).
+- **Add user form** — disabled when OIDC is the only auth path, with a hint: "User provisioning is handled by your OIDC provider; users appear here on first sign-in." Configurable later via a `oidc.disable_local_users` flag if that becomes a real ask. Out of scope for v1; both paths stay open.
+- **Edit user form** — when `auth_source='oidc'`:
+ - Username field disabled (changing it would just be undone on next OIDC login)
+ - Role dropdown disabled, with a hint: "Role is managed by your OIDC provider's `roles` claim mapping. Edit the mapping in server config to change."
+ - Email field disabled (refreshed from IdP on each login)
+ - **Disable / Enable / Force logout** still work — disabling an OIDC user kicks their session and rejects future OIDC logins ("user disabled by administrator")
+ - **Regenerate setup link** hidden — there's no setup token for OIDC users
+- **Login UI** — password form rejects users with `auth_source='oidc'` ("This account uses single sign-on. Click the SSO button above.")
+
+## Middleware / handler changes
+
+- **Routes**: new public-band entries `GET /auth/oidc/login`, `GET /auth/oidc/callback`. Skipped entirely when OIDC isn't configured (`s.deps.OIDC == nil`).
+- **Logout handler** augmented to fetch the user row + decide between local logout (303 → `/login`) and OIDC logout (303 → `end_session_endpoint`).
+- **Login handler** rejects `auth_source='oidc'` users with the SSO-prompt error.
+- **Last-admin guard** — already covers OIDC users naturally because they live in the `users` table. The role-from-claims path could create a "every admin gets demoted to operator" situation if the IdP's claim mapping is wrong; the guard rejects that demotion at the moment it'd be applied (returns the user to the login page with `oidc_error=role_change_blocked` and audit entry; admin must fix the mapping or promote a local admin first).
+
+## Implementation outline
+
+1. **Schema** — migration 0019 (users.auth_source + oidc_subject, sessions.id_token, oidc_state table)
+2. **Config** — extend `internal/server/config` with the OIDC block + env-var overrides; load JWKS lazily
+3. **Discovery + JWKS** — small helper that fetches `/.well-known/openid-configuration` once at startup, caches `authorization_endpoint`, `token_endpoint`, `end_session_endpoint`, `jwks_uri`. JWKS refreshed on first failed verification.
+4. **Login start handler** — `/auth/oidc/login`
+5. **Callback handler** — `/auth/oidc/callback`, with the four claim-resolution branches
+6. **Logout handler augmentation** — branch on `auth_source`
+7. **Login form rejection** — local-user password form rejects OIDC accounts
+8. **State cleanup** — extend the alert engine's existing cleanup tick
+9. **UI** — `oidc` chip on users list, disabled fields on edit-form for OIDC users, login page SSO button + error banner
+10. **Tests** — config parse tests; happy-path callback test using a fake IdP (httptest server with a hand-rolled discovery doc + JWKS); username-collision test; no-role-match test; logout test
+11. **Sweep** — full Playwright walk against an actual IdP (Authelia in a Docker container) — admin gets in via OIDC, role mapping works, logout redirects through IdP, OIDC user can't password-login
+
+## Test strategy
+
+The IdP is the hard part to test cleanly. Two layers:
+
+- **Unit / integration tests** use a stub OIDC provider built into the test harness — `httptest.Server` exposing `.well-known/openid-configuration`, a token endpoint that signs minted JWTs with a test ECDSA key, and a JWKS endpoint serving the public key. This covers every code path without a real IdP. Pattern: each test mints its own claims and runs the callback against the stub.
+- **Smoke env** runs against a real Authelia container (existing `compose.smoke.yaml`-style file or one-liner `docker run`) for the final sweep — confirms the discovery doc isn't being misread, real JWT verification works, real `end_session_endpoint` redirect works.
+
+## Out of scope (deferred)
+
+- **Multi-provider** support (`providers:` array)
+- **Back-channel logout** (RFC 8138) — schema isn't blocked from adding it later
+- **UI-driven role mapping** (config-only in v1)
+- **Refresh tokens / mid-session role re-evaluation** — login-only refresh in v1
+- **`oidc.disable_local_users`** flag — both paths stay open in v1
+- **OIDC user dashboard chip / badges** beyond the small `oidc` indicator on the users list
+- **Per-user "auth source" filter on the users list** — sortable headers cover most of the use case
+
+## Risks / gotchas
+
+- **JWKS key rotation** — refresh on first failed verification is the standard fix; document the cache TTL (1h) in the config block.
+- **Clock skew** — accept `iat`/`exp` with a 60s leeway; matches what most OIDC libraries do.
+- **End-session 404 / not advertised** — degrade gracefully; just drop the session and 303 to `/login`. Don't 500 the logout because the IdP doesn't implement RP-initiated logout.
+- **Username changes at the IdP** — silently keep the local username (matches our locked decision: subject is the stable key, username is display-only). Document.
+- **Role claim is sometimes a string, sometimes an array, sometimes a comma-separated string** depending on IdP — normalise into `[]string` before mapping. Authelia/Keycloak emit arrays; some custom setups emit strings; handle both.
+- **Authelia `sub` is an opaque UUID, not the username** (Authelia 4.39+ default for new clients). Don't assume `sub` is human-readable; it's stable but display value is `preferred_username` or `email`. The locked design already keys lookups on `sub` and uses `preferred_username` for the display username, so this is just a correctness note.
+- **`end_session_endpoint` may not be published** (Authelia doesn't advertise it for many configs). The locked logout flow already degrades to "drop session + redirect to /login" when the discovery doc lacks it; no extra config needed.
+- **Password-form bypass for OIDC users via /api/auth/login (JSON)** — same rejection rule applies, not just the HTML form.
+
+## Acceptance
+
+- [ ] An OIDC user with `roles: ["rm-admins"]` can sign in, becomes an admin, is visible in `/settings/users` with an `oidc` chip
+- [ ] Same user signing in again resolves to the same row (no duplicate)
+- [ ] Same user with `roles: ["something-else"]` is denied, lands on `/login?oidc_error=no_role_match` with a banner, no row created
+- [ ] OIDC user can't password-login through `/login` or `/api/auth/login`
+- [ ] Admin disables an OIDC user → next OIDC login is rejected, existing session bounced (existing disable-mid-session)
+- [ ] Sign out as an OIDC user → 303 to IdP's end-session URL (when advertised); no end-session URL → 303 to `/login`
+- [ ] OIDC config absent → password login works exactly as today (zero behavioural change)
+- [ ] Username collision: a local `alice` exists, OIDC user with `preferred_username=alice` and a different `sub` → blocked at sign-in with the clear error page
+- [ ] Last-admin guard refuses to demote the only enabled admin even if the IdP's role mapping says otherwise
+- [ ] All existing tests pass; new test suite covers the four claim-resolution branches and logout
diff --git a/go.mod b/go.mod
index b392766..4a2e12d 100644
--- a/go.mod
+++ b/go.mod
@@ -3,22 +3,26 @@ module gitea.dcglab.co.uk/steve/restic-manager
go 1.25.0
require (
+ github.com/coder/websocket v1.8.14
+ github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-chi/chi/v5 v5.2.5
+ github.com/golang-jwt/jwt/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.1
+ github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.50.0
+ golang.org/x/oauth2 v0.36.0
+ golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.0
)
require (
- github.com/coder/websocket v1.8.14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/robfig/cron/v3 v3.0.1 // indirect
- golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index 3fcf455..b48b8bf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,15 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
+github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -25,6 +31,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/alert/engine.go b/internal/alert/engine.go
index e0205b9..607ed91 100644
--- a/internal/alert/engine.go
+++ b/internal/alert/engine.go
@@ -193,6 +193,9 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil {
slog.Warn("alert: cleanup expired setup tokens", "err", err)
}
+ if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
+ slog.Warn("alert: cleanup expired oidc state", "err", err)
+ }
hosts, err := e.store.ListHosts(ctx)
if err != nil {
diff --git a/internal/server/config/config.go b/internal/server/config/config.go
index 0d883cf..3106775 100644
--- a/internal/server/config/config.go
+++ b/internal/server/config/config.go
@@ -30,7 +30,9 @@ type Config struct {
// Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP
// testing — production deployments are always behind a TLS proxy
// and the cookie must be Secure.
- CookieSecure bool `yaml:"cookie_secure"`
+ CookieSecure bool `yaml:"cookie_secure"`
+ OIDCRaw *OIDCConfig `yaml:"oidc"`
+ OIDC *OIDCConfig `yaml:"-"`
}
// Load resolves config in this order:
@@ -91,6 +93,16 @@ func Load(yamlPath string) (Config, error) {
}
}
+ var rawOIDC OIDCConfig
+ if c.OIDCRaw != nil {
+ rawOIDC = *c.OIDCRaw
+ }
+ oidc, err := loadOIDC(envSnapshot(), rawOIDC)
+ if err != nil {
+ return c, err
+ }
+ c.OIDC = oidc
+
return c, c.validate()
}
diff --git a/internal/server/config/oidc.go b/internal/server/config/oidc.go
new file mode 100644
index 0000000..5681037
--- /dev/null
+++ b/internal/server/config/oidc.go
@@ -0,0 +1,103 @@
+// 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
+ }
+
+ if c.Issuer == "" {
+ return nil, nil
+ }
+
+ 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")
+ }
+
+ 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
+}
diff --git a/internal/server/config/oidc_test.go b/internal/server/config/oidc_test.go
new file mode 100644
index 0000000..ea01c17
--- /dev/null
+++ b/internal/server/config/oidc_test.go
@@ -0,0 +1,72 @@
+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)
+ }
+}
diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go
index 508c6b4..af37ea8 100644
--- a/internal/server/http/auth.go
+++ b/internal/server/http/auth.go
@@ -56,6 +56,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req
// existence to a probing attacker.
return nil, errInvalidCredentials
}
+ if u.AuthSource == "oidc" {
+ return nil, errInvalidCredentials
+ }
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
return nil, errInvalidCredentials
}
diff --git a/internal/server/http/oidc_handlers.go b/internal/server/http/oidc_handlers.go
new file mode 100644
index 0000000..45763c8
--- /dev/null
+++ b/internal/server/http/oidc_handlers.go
@@ -0,0 +1,205 @@
+// oidc_handlers.go — OIDC sign-in handlers. Public routes when oidc
+// is configured (s.deps.OIDC != nil), otherwise not mounted.
+package http
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ stdhttp "net/http"
+ "strings"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+// 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)
+}
+
+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
+ }
+
+ 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()
+
+ // Returning 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.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,
+ })
+ s.oidcDropSessionAndRedirect(w, r, existing.ID, rawIDToken, 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 — first check the username doesn't collide with
+ // a local user.
+ if _, err := s.deps.Store.GetUserByUsername(r.Context(), uname); err == nil {
+ _ = 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,
+ Payload: jsonMust(map[string]any{"auth_source": "oidc"}),
+ })
+ _ = 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; the IdP subject + reason go in the payload so
+// admin can correlate.
+func (s *Server) auditOIDCBlocked(r *stdhttp.Request, claims *oidc.Claims, reason string) error {
+ 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: jsonMust(map[string]any{
+ "sub": claims.Subject,
+ "username": claims.PreferredUsername,
+ "reason": reason,
+ }),
+ })
+}
+
+// jsonMust marshals to json.RawMessage; on error returns nil so the
+// audit row still lands without the payload (best-effort).
+func jsonMust(v any) json.RawMessage {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil
+ }
+ return json.RawMessage(b)
+}
diff --git a/internal/server/http/oidc_handlers_test.go b/internal/server/http/oidc_handlers_test.go
new file mode 100644
index 0000000..de48ffd
--- /dev/null
+++ b/internal/server/http/oidc_handlers_test.go
@@ -0,0 +1,293 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ stdhttp "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "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/server/oidc/oidctest"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+// newTestServerWithOIDC returns a Server wired to a stub IdP.
+// Returned ts is the httptest.Server fronting the actual server;
+// stub is the IdP for minting codes / configuring claims.
+func newTestServerWithOIDC(t *testing.T) (*Server, *httptest.Server, *oidctest.StubIdP) {
+ 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 := oidctest.New(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
+}
+
+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 != stdhttp.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
+}
+
+// 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 *oidctest.StubIdP, 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)
+ if err := srv.deps.Store.CreateUser(t.Context(), store.User{
+ ID: "local-alice", Username: "alice", PasswordHash: "x",
+ Role: store.RoleViewer, CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ 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)
+ res := runCallback(t, ts, stub, map[string]any{
+ "sub": "carol-sub",
+ "preferred_username": "carol",
+ "groups": []string{"rm-operators"},
+ "aud": "test-client",
+ })
+ res.Body.Close()
+ 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)
+ }
+}
+
+func TestOIDCLogoutRedirectsToEndSession(t *testing.T) {
+ t.Parallel()
+ srv, ts, stub := newTestServerWithOIDC(t)
+ endSessionURL := stub.URL() + "/logout-end"
+ stub.SetEndSessionEndpoint(endSessionURL)
+
+ // Rebuild the OIDC client because end_session_endpoint is read at
+ // New() time from the discovery doc.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ cfg := &config.OIDCConfig{
+ Issuer: stub.URL(), ClientID: "test-client", ClientSecret: "x",
+ Scopes: []string{"openid"}, RoleClaim: "groups",
+ RoleMapping: map[string]string{"rm-admins": "admin"},
+ }
+ newClient, err := oidc.New(ctx, cfg, "http://test")
+ if err != nil {
+ t.Fatalf("rebuild client: %v", err)
+ }
+ srv.deps.OIDC = newClient
+
+ // Sign in via the OIDC flow.
+ res := runCallback(t, ts, stub, map[string]any{
+ "sub": "logout-sub",
+ "preferred_username": "lo",
+ "groups": []string{"rm-admins"},
+ "aud": "test-client",
+ })
+ res.Body.Close()
+ cookies := res.Cookies()
+ if len(cookies) == 0 {
+ t.Fatal("expected session cookie after sign-in")
+ }
+ sessionCookie := cookies[0]
+
+ // POST /logout — should 303 to the end_session endpoint with
+ // id_token_hint + post_logout_redirect_uri.
+ c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
+ return stdhttp.ErrUseLastResponse
+ }}
+ req, _ := stdhttp.NewRequest("POST", ts.URL+"/logout", nil)
+ req.AddCookie(sessionCookie)
+ res, err = c.Do(req)
+ if err != nil {
+ t.Fatalf("logout: %v", err)
+ }
+ 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, "/logout-end") {
+ t.Errorf("location not at end_session: %q", loc)
+ }
+ if !strings.Contains(loc, "id_token_hint=") {
+ t.Errorf("location missing id_token_hint: %q", loc)
+ }
+ if !strings.Contains(loc, "post_logout_redirect_uri=") {
+ t.Errorf("location missing post_logout_redirect_uri: %q", loc)
+ }
+}
+
+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, err := stdhttp.Post(urlBase+"/api/auth/login",
+ "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnauthorized {
+ t.Errorf("status: got %d want 401", res.StatusCode)
+ }
+}
diff --git a/internal/server/http/server.go b/internal/server/http/server.go
index ba7c51a..41048ea 100644
--- a/internal/server/http/server.go
+++ b/internal/server/http/server.go
@@ -17,6 +17,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"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/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -45,6 +46,9 @@ type Deps struct {
// admin-bootstrap token printed in the server logs. While set, the
// /bootstrap endpoint accepts it to create the first admin user.
BootstrapToken string
+ // OIDC (optional). Non-nil when the operator has configured an
+ // IdP — handlers under /auth/oidc/* are mounted only when set.
+ OIDC *oidc.Client
}
// Server is the running HTTP server.
@@ -133,13 +137,19 @@ func (s *Server) routes(r chi.Router) {
r.Get("/ws/agent/pending", s.handlePendingWS)
r.Mount("/static/", staticHandler())
+ // POST /logout is always mounted — it handles both local and OIDC
+ // sessions and doesn't require the UI renderer.
+ r.Post("/logout", s.handleUILogoutPost)
if s.deps.UI != nil {
r.Get("/login", s.handleUILoginGet)
r.Post("/login", s.handleUILoginPost)
- r.Post("/logout", s.handleUILogoutPost)
r.Get("/setup", s.handleUISetupGet)
r.Post("/setup", s.handleUISetupPost)
}
+ if s.deps.OIDC != nil {
+ r.Get("/auth/oidc/login", s.handleOIDCLogin)
+ r.Get("/auth/oidc/callback", s.handleOIDCCallback)
+ }
// Viewer band — anyone authenticated can read.
r.Group(func(r chi.Router) {
diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go
index 734bada..798630e 100644
--- a/internal/server/http/ui_handlers.go
+++ b/internal/server/http/ui_handlers.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"log/slog"
stdhttp "net/http"
+ "net/url"
"strings"
"time"
@@ -921,7 +922,14 @@ func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
return
}
- view := ui.ViewData{Version: s.version()}
+ view := ui.ViewData{
+ Version: s.version(),
+ OIDCError: r.URL.Query().Get("oidc_error"),
+ }
+ if s.deps.OIDC != nil {
+ view.OIDCEnabled = true
+ view.OIDCDisplayName = s.deps.OIDC.DisplayName()
+ }
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
@@ -947,6 +955,10 @@ func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request)
Username: username,
Error: "Invalid username or password.",
}
+ if s.deps.OIDC != nil {
+ view.OIDCEnabled = true
+ view.OIDCDisplayName = s.deps.OIDC.DisplayName()
+ }
w.WriteHeader(stdhttp.StatusUnauthorized)
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login (post-fail)", "err", err)
@@ -956,12 +968,37 @@ func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}
-// handleUILogoutPost is the form-submit twin of /api/auth/logout. It
-// drops the session cookie and redirects to /login.
+// handleUILogoutPost is the form-submit twin of /api/auth/logout. For
+// local sessions it drops the cookie and redirects to /login. For OIDC
+// sessions, if the IdP advertised an end_session_endpoint it performs
+// RP-initiated logout by redirecting there with id_token_hint and
+// post_logout_redirect_uri.
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
- if c, err := r.Cookie(sessionCookieName); err == nil {
- _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
+ 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: "",
@@ -971,5 +1008,5 @@ func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request
Secure: s.deps.Cfg.CookieSecure,
SameSite: stdhttp.SameSiteLaxMode,
})
- stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
+ stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther)
}
diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go
index 08ee401..f9c1eec 100644
--- a/internal/server/http/ui_users.go
+++ b/internal/server/http/ui_users.go
@@ -51,6 +51,7 @@ type userRow struct {
LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never"
Disabled bool
MustChangePassword bool
+ AuthSource string
}
func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -104,6 +105,7 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
Role: string(ux.Role), LastLoginAt: ll,
Disabled: ux.DisabledAt != nil,
MustChangePassword: ux.MustChangePassword,
+ AuthSource: ux.AuthSource,
})
}
@@ -157,7 +159,8 @@ type userFormPage struct {
// to add a username that already exists (disabled). Triggers a
// banner on the edit page explaining why and steering them at
// the Re-enable button. See handleUIUserNewPost's collision branch.
- Reenable bool
+ Reenable bool
+ AuthSource string
}
func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -294,8 +297,9 @@ func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Reques
view.Page = userFormPage{
Mode: "edit", ID: target.ID, Username: target.Username,
Email: em, Role: string(target.Role),
- Disabled: target.DisabledAt != nil,
- Reenable: r.URL.Query().Get("reenable") == "1",
+ Disabled: target.DisabledAt != nil,
+ Reenable: r.URL.Query().Get("reenable") == "1",
+ AuthSource: target.AuthSource,
}
_ = s.deps.UI.Render(w, "user_edit", view)
}
@@ -315,6 +319,10 @@ func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Reque
stdhttp.NotFound(w, r)
return
}
+ if target.AuthSource == "oidc" {
+ stdhttp.Error(w, "OIDC users cannot have role/email edited locally", stdhttp.StatusForbidden)
+ return
+ }
role, ok := validRole(r.PostForm.Get("role"))
if !ok {
stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
diff --git a/internal/server/oidc/oidc.go b/internal/server/oidc/oidc.go
new file mode 100644
index 0000000..7643678
--- /dev/null
+++ b/internal/server/oidc/oidc.go
@@ -0,0 +1,208 @@
+// 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"
+ "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)
+ }
+ // Many IdPs (Authelia among them) only return minimal claims in
+ // the ID token and put profile/email/groups on /userinfo. Fetch
+ // userinfo and merge — id_token claims win on conflict so the
+ // signed assertion remains authoritative.
+ if ui, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(tok)); err == nil {
+ var uiClaims map[string]any
+ if err := ui.Claims(&uiClaims); err == nil {
+ for k, v := range uiClaims {
+ if _, present := raw[k]; !present {
+ raw[k] = v
+ }
+ }
+ }
+ }
+ 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:
+ 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 URL-safe base64-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
+ }
+ 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)
+}
diff --git a/internal/server/oidc/oidc_test.go b/internal/server/oidc/oidc_test.go
new file mode 100644
index 0000000..509feec
--- /dev/null
+++ b/internal/server/oidc/oidc_test.go
@@ -0,0 +1,49 @@
+package oidc
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc/oidctest"
+)
+
+func TestClientExchangeAgainstStub(t *testing.T) {
+ t.Parallel()
+ stub := oidctest.New(t)
+ cfg := &config.OIDCConfig{
+ Issuer: stub.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")
+ }
+}
diff --git a/internal/server/oidc/oidctest/stub.go b/internal/server/oidc/oidctest/stub.go
new file mode 100644
index 0000000..e1e4b95
--- /dev/null
+++ b/internal/server/oidc/oidctest/stub.go
@@ -0,0 +1,181 @@
+// Package oidctest provides a minimal OIDC provider for tests —
+// discovery doc, JWKS, and a token endpoint. Each test mints its
+// own claims; the stub signs them with an ECDSA P-256 key and the
+// production verifier accepts them because the JWKS is fetched live
+// from the stub.
+//
+// Usage:
+//
+// stub := oidctest.New(t)
+// code := stub.MintCode(map[string]any{
+// "sub": "abc",
+// "preferred_username": "alice",
+// "groups": []string{"rm-admins"},
+// })
+// // stub.URL() is the issuer URL; pass to oidc.New as Issuer
+package oidctest
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ stdhttp "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// StubIdP is an httptest-backed OIDC provider. Each test creates a
+// fresh one via New(t); cleanup is registered on t.
+type StubIdP struct {
+ t *testing.T
+ srv *httptest.Server
+
+ mu sync.Mutex
+ priv *ecdsa.PrivateKey
+ kid string
+ claims map[string]map[string]any // code → claims
+ endSession string // optional, set by SetEndSessionEndpoint
+}
+
+// New constructs a stub IdP listening on a random port. Cleanup is
+// registered on t.
+func New(t *testing.T) *StubIdP {
+ t.Helper()
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("oidctest: genkey: %v", err)
+ }
+ s := &StubIdP{
+ t: t,
+ priv: priv,
+ kid: "stub-key",
+ claims: map[string]map[string]any{},
+ }
+ 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
+}
+
+// URL returns the base URL of the stub — pass as Issuer to
+// oidc.New().
+func (s *StubIdP) URL() string { return s.srv.URL }
+
+// 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
+ return code
+}
+
+// SetEndSessionEndpoint configures the stub to advertise an
+// end_session_endpoint in its discovery doc. Used by the logout
+// test in E1.
+func (s *StubIdP) SetEndSessionEndpoint(url string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.endSession = url
+}
+
+func (s *StubIdP) discovery(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
+ s.mu.Lock()
+ endSession := s.endSession
+ s.mu.Unlock()
+
+ 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"},
+ }
+ if endSession != "" {
+ doc["end_session_endpoint"] = endSession
+ }
+ 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.claims, code)
+ }
+ s.mu.Unlock()
+ if !ok {
+ stdhttp.Error(w, "bad code", stdhttp.StatusBadRequest)
+ return
+ }
+ 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)
+}
+
+// padTo32 left-pads an integer big-endian byte slice to 32 bytes,
+// the size required by P-256 JWK x/y components.
+func padTo32(b []byte) []byte {
+ if len(b) >= 32 {
+ return b
+ }
+ out := make([]byte, 32)
+ copy(out[32-len(b):], b)
+ return out
+}
diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go
index 68f0e2e..3b3c446 100644
--- a/internal/server/ui/ui.go
+++ b/internal/server/ui/ui.go
@@ -56,6 +56,19 @@ type ViewData struct {
// today; other pages can adopt the same field.
Error string
+ // OIDCEnabled is true when the server has an OIDC provider
+ // configured. The login page uses it to show the SSO button.
+ OIDCEnabled bool
+
+ // OIDCDisplayName is the human-readable label for the OIDC
+ // provider (e.g. "Authelia"). Shown on the SSO button.
+ OIDCDisplayName string
+
+ // OIDCError holds an error code returned via ?oidc_error=… after
+ // a failed OIDC callback. The login page maps it to a user-facing
+ // message.
+ OIDCError string
+
// Page carries page-specific data. Concrete type is the page's
// own struct.
Page any
diff --git a/internal/store/migrations/0019_oidc.sql b/internal/store/migrations/0019_oidc.sql
new file mode 100644
index 0000000..1f6cea6
--- /dev/null
+++ b/internal/store/migrations/0019_oidc.sql
@@ -0,0 +1,35 @@
+-- 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);
diff --git a/internal/store/oidc_state.go b/internal/store/oidc_state.go
new file mode 100644
index 0000000..cb6a5c9
--- /dev/null
+++ b/internal/store/oidc_state.go
@@ -0,0 +1,65 @@
+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
+}
diff --git a/internal/store/oidc_state_test.go b/internal/store/oidc_state_test.go
new file mode 100644
index 0000000..a28b176
--- /dev/null
+++ b/internal/store/oidc_state_test.go
@@ -0,0 +1,64 @@
+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")
+ }
+ 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)
+
+ 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)
+ }
+}
diff --git a/internal/store/sessions.go b/internal/store/sessions.go
index a2ef31c..b02e90d 100644
--- a/internal/store/sessions.go
+++ b/internal/store/sessions.go
@@ -12,13 +12,14 @@ import (
// insert; the raw token is what the caller hands to the user (cookie).
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)
- VALUES (?, ?, ?, ?, ?, ?)`,
+ `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),
- sess.IP, sess.UA)
+ nullableStr(sess.IP), nullableStr(sess.UA),
+ nullableStr(sess.IDToken))
if err != nil {
return fmt.Errorf("store: create session: %w", err)
}
@@ -32,15 +33,15 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, tokenHash strin
// of valid token hashes.
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
+ `SELECT id, user_id, created_at, expires_at, ip, ua, id_token
FROM sessions
WHERE id = ? AND expires_at > ?`,
tokenHash, time.Now().UTC().Format(time.RFC3339Nano))
var sess Session
var created, expires string
- var ip, ua sql.NullString
- if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua); err != nil {
+ var ip, ua, idTok sql.NullString
+ if err := row.Scan(&sess.ID, &sess.UserID, &created, &expires, &ip, &ua, &idTok); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -62,6 +63,9 @@ func (s *Store) LookupSession(ctx context.Context, tokenHash string) (*Session,
if ua.Valid {
sess.UA = ua.String
}
+ if idTok.Valid {
+ sess.IDToken = idTok.String
+ }
return &sess, nil
}
diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go
index 81222ee..0dcd553 100644
--- a/internal/store/sessions_test.go
+++ b/internal/store/sessions_test.go
@@ -43,3 +43,34 @@ func TestDeleteSessionsByUserID(t *testing.T) {
t.Error("hash1 should be gone")
}
}
+
+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)
+ }
+}
diff --git a/internal/store/types.go b/internal/store/types.go
index 88758e4..0a69dee 100644
--- a/internal/store/types.go
+++ b/internal/store/types.go
@@ -16,8 +16,18 @@ type User struct {
Email *string // optional; nil = not set
DisabledAt *time.Time // nil = enabled
MustChangePassword bool
- CreatedAt time.Time
- LastLoginAt *time.Time
+ // 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
}
// Role enumerates the access tiers from spec.md §7.2.
@@ -40,6 +50,10 @@ type Session struct {
ExpiresAt time.Time
IP string
UA 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
}
// Host mirrors the hosts table. The P2 redesign moved repo-related
diff --git a/internal/store/users.go b/internal/store/users.go
index f414e92..ed0ddb6 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -18,12 +18,18 @@ func (s *Store) CreateUser(ctx context.Context, u User) error {
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, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ must_change_password, auth_source,
+ oidc_subject, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.PasswordHash, string(u.Role),
- nullable(u.Email), must,
+ 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)
@@ -31,24 +37,49 @@ func (s *Store) CreateUser(ctx context.Context, u User) error {
return nil
}
+// userSelectCols centralises the column list every read path uses so
+// scanUser stays in lockstep.
+const userSelectCols = `id, username, password_hash, role, email,
+ disabled_at, must_change_password,
+ auth_source, oidc_subject,
+ created_at, last_login_at`
+
// GetUserByUsername resolves a user case-insensitively.
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.db.QueryRowContext(ctx,
- `SELECT id, username, password_hash, role, email, disabled_at,
- must_change_password, created_at, last_login_at
- FROM users WHERE LOWER(username) = LOWER(?)`, username)
+ `SELECT `+userSelectCols+` FROM users WHERE LOWER(username) = LOWER(?)`,
+ username)
return scanUser(row.Scan)
}
// GetUserByID looks up a user by id. Returns ErrNotFound on miss.
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
row := s.db.QueryRowContext(ctx,
- `SELECT id, username, password_hash, role, email, disabled_at,
- must_change_password, created_at, last_login_at
- FROM users WHERE id = ?`, id)
+ `SELECT `+userSelectCols+` FROM users WHERE id = ?`, id)
return scanUser(row.Scan)
}
+// GetUserByOIDCSubject finds the user JIT-provisioned on a previous
+// OIDC 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.
+// Used by tests today; reserved for a future "link a local user to
+// OIDC" flow.
+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
+}
+
// UserSort selects the column ListUsers orders by. OrderBy is
// allowlisted in usersOrderColumn so callers can't inject SQL via
// this field. Empty / unknown OrderBy falls back to "username".
@@ -88,9 +119,8 @@ func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) {
// Default: username ASC (alphabetical), matching pre-sort behaviour.
asc = true
}
- q := `SELECT id, username, password_hash, role, email, disabled_at,
- must_change_password, created_at, last_login_at
- FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc)
+ 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)
@@ -220,11 +250,13 @@ func (s *Store) SetPasswordHash(ctx context.Context, id, hash string) error {
func scanUser(scan func(...any) error) (*User, error) {
var u User
var role string
- var email, disabledAt, lastLogin sql.NullString
+ 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, &created, &lastLogin); err != nil {
+ &email, &disabledAt, &must, &authSource, &oidcSub,
+ &created, &lastLogin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -240,6 +272,11 @@ func scanUser(scan func(...any) error) (*User, error) {
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 {
diff --git a/internal/store/users_test.go b/internal/store/users_test.go
index a7684a9..ce4679b 100644
--- a/internal/store/users_test.go
+++ b/internal/store/users_test.go
@@ -165,6 +165,54 @@ func TestCreateUserLowercasesUsername(t *testing.T) {
}
}
+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)
+ }
+ 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)
+ }
+}
+
func TestEnrollmentTokenSingleUse(t *testing.T) {
t.Parallel()
s := openTestStore(t)
diff --git a/tasks.md b/tasks.md
index 106114b..fc2b110 100644
--- a/tasks.md
+++ b/tasks.md
@@ -308,7 +308,10 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
> **Schema:** migration 0017 adds `email`, `disabled_at`, `must_change_password` plus a UNIQUE INDEX on LOWER(username) (lowercase normalisation in Go on every CreateUser); 0018 adds `user_setup_tokens`. Both column-level ALTERs per CLAUDE.md preference. Email is metadata only in v1 (no SMTP-the-link); the SMTP channel infrastructure from P3-06 makes that a one-page follow-up.
>
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
-- [ ] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
+- [x] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
+
+> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with ``** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against `https://auth.dcglab.co.uk`:** rm-admin → admin role + JIT row + chip + readonly edit; rm-operator → operator JIT, 403 on `/settings/users`; rm-viewer → viewer JIT, 403 on `/hosts/new`; rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged. Returning rm-admin login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
+
- [x] **P4-07** (S) Per-host tags + dashboard filtering by tag
> **As shipped (2026-05-05):** Tag column already existed on the hosts schema (JSON array, round-tripped through the Host struct since Phase 1) but had no edit UI or filter. Added `Store.SetHostTags` + `Store.DistinctHostTags` (the latter via `json_each` for autocomplete + chip-row population). Inline editor on the host detail header: `+ tag` button reveals a comma-separated input with `