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