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.
75 KiB
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 bookkeepinginternal/store/oidc_state.go— state-table CRUDinternal/store/oidc_state_test.gointernal/server/config/oidc.go— OIDCConfig parse + validateinternal/server/config/oidc_test.gointernal/server/oidc/oidc.go— provider discovery + role-claim helpers (small, testable)internal/server/oidc/oidc_test.gointernal/server/oidc/stub_test.go— fake IdP httptest harness used by callback testsinternal/server/http/oidc_handlers.go—/auth/oidc/login+/auth/oidc/callbackinternal/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 updatesinternal/store/sessions.go— extend CreateSession to round-trip IDToken; SessionWithIDToken lookup helper for logoutinternal/server/config/config.go— addOIDC *OIDCConfigfield, wire YAML + env loading via the new oidc.go helpersinternal/server/http/server.go— wire/auth/oidc/login+/auth/oidc/callback(public band); pass newDeps.OIDCfield throughinternal/server/http/auth.go— local login (JSON + HTML) rejectsauth_source='oidc'usersinternal/server/http/ui_handlers.go— login page passes OIDC enabled flag + display name + error code; exposeOIDCEnabled/OIDCDisplayNameon the login view modelinternal/server/http/ui_users.go— users list addsoidcchip; edit-user disables username/role/email for OIDC rows; hides regenerate-setupweb/templates/pages/login.html— SSO button + error bannerweb/templates/pages/users.html—oidcchip in Status columnweb/templates/pages/user_edit.html— readonly fields when AuthSource=oidcinternal/alert/engine.go— tick also callsStore.CleanupExpiredOIDCStatecmd/server/main.go— build OIDC client at startup when configured; wire Deps.OIDCtasks.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 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.
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/orinternal/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:
- Look up the session
- Delete it
- 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 disabledto 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-adminwith roleadminand anoidcchip in the Status column
Sign out. Expect:
- Local session cookie cleared, redirect back to
/login(noend_session_endpointadvertised 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
usersforrm-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 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).