Files
restic-manager/docs/superpowers/plans/2026-05-05-p4-05-oidc.md
T
steve cdbd8eeb88 plan: P4-05 — OIDC login implementation plan
Bite-sized TDD tasks across 7 slices (A schema, B config, C OIDC
client core + stub IdP, D login + callback, E logout + local-login
rejection, F UI, G wiring + Authelia sweep). Each task is one
commit with concrete code blocks and test cases — no placeholders.

Refs spec at docs/superpowers/specs/2026-05-05-p4-05-oidc-design.md.
Authelia bundle for the sweep stashed at /tmp/rm-smoke/oidc.env.
2026-05-05 13:04:39 +01:00

75 KiB
Raw Blame History

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.goGetUserByOIDCSubject, 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.htmloidc 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

-- 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
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:

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:

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

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:

// 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
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:

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

// 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
// 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
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

// 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
// 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:

type Config struct {
	// existing fields...
	OIDC *OIDCConfig `yaml:"oidc"`
}

And in Load(...), after the existing env reads but before validate:

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:

// 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
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

go get github.com/coreos/go-oidc/v3
go get golang.org/x/oauth2
go mod tidy
  • Step 2: Implement the wrapper
// 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
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
// 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:

go get github.com/golang-jwt/jwt/v5
  • Step 2: Sanity test the stub

Append to internal/server/oidc/oidc_test.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
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:

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
// 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:

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 newStubIdPNew. 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.

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
// 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:

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
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:

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:

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:

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

// 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):

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

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:

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
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:

type loginPage struct {
	// existing fields...
	OIDCEnabled     bool
	OIDCDisplayName string
	OIDCError       string
}

In handleUILoginGet (and any related re-render path), populate:

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.

{{$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
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
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":

{{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:
    {{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":

if target.AuthSource == "oidc" {
	stdhttp.Error(w, "OIDC users cannot have role/email edited locally", stdhttp.StatusForbidden)
	return
}
  • Step 3: Build + manual smoke
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
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):

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:

// 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
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

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:

- [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
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 A2A4 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).