# 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}}
  <div class="panel rounded-[7px] p-4 mb-5"
       style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
    <div class="text-bad text-[12.5px]">
      {{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}}
    </div>
  </div>
{{end}}

{{if $page.OIDCEnabled}}
  <a href="/auth/oidc/login" class="btn btn-primary btn-block btn-lg mb-4">
    Sign in with {{$page.OIDCDisplayName}}
  </a>
  <div class="flex items-center gap-3 my-5 text-[11px] text-ink-fade uppercase tracking-[0.08em]">
    <div class="flex-1 border-t border-line-soft"></div>
    <span>or sign in with a local account</span>
    <div class="flex-1 border-t border-line-soft"></div>
  </div>
{{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"}}<span class="tag" style="color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 60%); background: color-mix(in oklch, var(--accent), transparent 92%); margin-left: 4px;">oidc</span>{{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"}}
    <div class="text-[12.5px] text-ink-mute mt-1 mb-3">
      This user is provisioned via OIDC. Username, role, and email
      are managed by your IdP and refreshed on each sign-in.
    </div>
  {{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).
