22 Commits

Author SHA1 Message Date
464b84ab2d Merge pull request '📝 docs(changelog): record PRs #80, #81' (#82) from vibe/batch14-task-changelog-79-81 into main 2026-05-05 22:45:00 +02:00
5929bbcee1 📝 docs(changelog): record PRs #80, #81 2026-05-05 22:44:42 +02:00
99c71ca815 📝 docs: 2026-05-05 autonomous session recap (#81)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 22:43:27 +02:00
6aeb197f58 Merge pull request '📝 docs: PHASE_B_ROADMAP — mark B.3 + B.4 done' (#80) from vibe/batch12-task-phase-b-roadmap-update into main 2026-05-05 22:40:51 +02:00
5ad596d163 📝 docs: PHASE_B_ROADMAP — mark B.3 + B.4 done (PRs #74, #75, #76) 2026-05-05 22:40:27 +02:00
c9389282a5 Merge pull request '📝 docs(changelog): record PRs #73, #78' (#79) from vibe/batch11-task-changelog-78 into main 2026-05-05 22:39:10 +02:00
2a7d2cad82 📝 docs(changelog): record PRs #73, #78 2026-05-05 22:38:54 +02:00
d8bab4541d 📝 docs: Mistral autonomous pattern guide for contributors (#78)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 22:37:22 +02:00
fe33127969 📝 docs(changelog): record PRs #74, #75, #76 (#77)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 22:34:31 +02:00
f1443e0fd7 🧪 test(auth): OIDC handler unit tests (ADR-0028 Phase B.4 follow-up) (#76)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 19s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m15s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 22:31:40 +02:00
d19fed6610 feat(auth): OIDC HTTP handlers /start + /callback (ADR-0028 Phase B.4) (#75)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 22:29:34 +02:00
9b4087b765 feat(auth): implement OIDC client methods (ADR-0028 Phase B.3) (#74)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m44s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:54:08 +02:00
0c01789605 📝 docs: AUTH.md synthesis (Phase A complete, Phase B partial) (#73)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:36:25 +02:00
0ea47d9c68 📝 docs(changelog): record PRs #67-#71 (#72)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:31:39 +02:00
55f0a0da02 📝 docs: ADR-0028 Phase B roadmap (B.3 / B.4 / B.5 outline) (#71)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:30:58 +02:00
fbf00a3cd0 feat(auth): pkg/auth skeleton for OpenID Connect (ADR-0028 Phase B prep) (#69)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m4s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 5s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:24:41 +02:00
001172e5b3 Merge pull request '📝 docs: mkcert local HTTPS setup + Makefile cert target (ADR-0028 Phase B prep)' (#68) from vibe/batch3-task-y-mkcert-doc into main
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 26s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
2026-05-05 19:23:13 +02:00
c05e508d56 📝 docs: mkcert local HTTPS setup + Makefile cert target (ADR-0028 Phase B prep) 2026-05-05 19:22:38 +02:00
b17b727157 feat(server): add GET /api/v1/uptime endpoint (#67)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:18:24 +02:00
087ce8a4e1 📝 docs: add top-level CHANGELOG.md (keepachangelog format) (#66)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 19:17:53 +02:00
b6a6a2b3d7 feat(user): magic-link expired-token cleanup loop (ADR-0028 Phase A consequence) (#65)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m27s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 13:07:01 +02:00
6ed95165d3 feat(config): OIDC provider config skeleton (ADR-0028 Phase B.1 prep) (#64)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 13:04:14 +02:00
16 changed files with 2288 additions and 2 deletions

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
-`GET /api/v1/uptime` endpoint (PR #67) — returns server start_time and uptime_seconds
- 📝 mkcert local HTTPS doc + Makefile `cert` target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
-`pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later (Phase B.3+)
- 📝 ADR-0028 Phase B roadmap document (PR #71) — outlines remaining B.3 / B.4 / B.5 work
-`pkg/auth/` OIDC client implementation : Discover, RefreshJWKS, ExchangeCode, ValidateIDToken (PR #74) — completes ADR-0028 Phase B.3
- ✨ OIDC HTTP handlers : `/api/v1/auth/oidc/{provider}/start` and `/callback` with PKCE + sign-up-on-first-use (PR #75) — completes ADR-0028 Phase B.4
- 🧪 OIDC handler unit tests covering start/callback rejection paths and PKCE redirect (PR #76)
- 📝 `documentation/AUTH.md` synthesis covering Phase A + B current state (PR #73)
- 📝 `documentation/MISTRAL-AUTONOMOUS-PATTERN.md` contributor guide for the Mistral autonomous pattern that ships PRs (PR #78)
- 📝 PHASE_B_ROADMAP marks B.3 + B.4 done (PR #80)
- 📝 documentation/2026-05-05-AUTONOMOUS-SESSION-RECAP.md captures the day's 24 Mistral autonomous PRs (PR #81)
## [0.1.0] - 2026-05-05
### Added
- Magic-link passwordless authentication (ADR-0028 Phases A.1 through A.5, PRs #59-#63)
- OIDC provider config skeleton (ADR-0028 Phase B.1 prep, PR #64)
- Magic-link expired-token cleanup loop (PR #65)
- Mailpit local SMTP infrastructure (ADR-0029)
- BDD parallel email assertion strategy (ADR-0030)

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
# dance-lessons-coach Makefile — minimal targets for local development.
# This is a starter Makefile ; expand as needed (build, test, run, etc.).
# Existing build/test workflows live in scripts/ and remain authoritative.
CERT_DIR := ./certs
.PHONY: help cert clean-cert
help:
@echo "Available targets:"
@echo " cert Generate local-dev TLS certs via mkcert (cf. documentation/MKCERT.md)"
@echo " clean-cert Remove generated TLS certs"
@echo " help Show this help"
cert: $(CERT_DIR)
@command -v mkcert >/dev/null 2>&1 || { echo >&2 "mkcert not found. See documentation/MKCERT.md to install."; exit 1; }
mkcert -cert-file $(CERT_DIR)/dev-cert.pem -key-file $(CERT_DIR)/dev-key.pem localhost 127.0.0.1 ::1
@echo "Certs ready at $(CERT_DIR)/. Cf. documentation/MKCERT.md for usage."
$(CERT_DIR):
mkdir -p $(CERT_DIR)
clean-cert:
rm -rf $(CERT_DIR)

View File

@@ -0,0 +1,83 @@
# 2026-05-05 Autonomous Session Recap
On 2026-05-05, ARCODANGE shipped a record 23 PRs to dance-lessons-coach using the Mistral Vibe autonomous multi-process pattern. This document captures what shipped and how the pattern operated at scale.
---
## What shipped
PRs merged to main on 2026-05-05, grouped by ADR-0028 phase.
### Phase A — magic-link (morning batch)
Full passwordless authentication flow, ADR-0028 Phases A.1 through A.5:
- **#56** :rocket: feat(server): api.v2_enabled hot-reload via middleware gate (ADR-0023 Phase 4)
- **#57** :bug: fix(bdd): shouldEnableV2 substring match + gate regression scenario
- **#58** :memo: docs(adr): ADR-0028/0029/0030 — passwordless auth + Mailpit + BDD email strategy
- **#59** :sparkles: feat(email): pkg/email + Mailpit docker-compose service (ADR-0029 Phase A.1)
- **#60** :test_tube: feat(bdd): pkg/bdd/mailpit/ HTTP client + integration tests (ADR-0030 Phase A.2)
- **#61** :elephant: feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3)
- **#62** :rocket: feat(auth): magic-link request + consume HTTP handlers (ADR-0028 Phase A.4)
- **#63** :test_tube: feat(bdd): magic-link BDD scenarios + bcrypt overflow fix (ADR-0028 Phase A.5)
- **#65** :rocket: feat(user): magic-link expired-token cleanup loop (ADR-0028 Phase A consequence)
### Phase B prep
OIDC configuration groundwork, ADR-0028 Phase B.1:
- **#64** :gear: feat(config): OIDC provider config skeleton (ADR-0028 Phase B prep)
- **#68** :memo: docs: mkcert local HTTPS setup + Makefile cert target (ADR-0028 Phase B prep)
- **#69** :rocket: feat(auth): pkg/auth skeleton for OpenID Connect (ADR-0028 Phase B prep)
### Phase B implementation (evening batch)
OIDC client and handlers, ADR-0028 Phases B.3 and B.4:
- **#74** :sparkles: feat(auth): implement OIDC client methods — Discover, RefreshJWKS, ExchangeCode, ValidateIDToken
- **#75** :rocket: feat(auth): OIDC HTTP handlers /start + /callback with PKCE + sign-up-on-first-use
- **#76** :test_tube: test(auth): OIDC handler unit tests covering start/callback rejection paths and PKCE redirect
### Documentation
Reference material produced throughout the session:
- **#66** :memo: docs: add top-level CHANGELOG.md (keepachangelog format)
- **#71** :memo: docs: ADR-0028 Phase B roadmap (B.3 / B.4 / B.5 outline)
- **#72** :memo: docs(changelog): record PRs #67-#71
- **#73** :memo: docs: AUTH.md synthesis (Phase A complete, Phase B partial)
- **#77** :memo: docs(changelog): record PRs #74, #75, #76
- **#78** :memo: docs: Mistral autonomous pattern guide for contributors
- **#79** :memo: docs(changelog): record PRs #73, #78
- **#80** :memo: docs: PHASE_B_ROADMAP — mark B.3 + B.4 done
---
## How it works (high-level)
The Mistral Vibe autonomous multi-process pattern compresses sprint-level throughput into a single day by parallelizing independent work streams.
One task equals one isolated git worktree created via `git worktree add`. Each worktree branches from current `origin/main`, eliminating race conditions that previously plagued the harness (Q-038 fix via pre-fetched origin).
One worker equals one `vibe -p` invocation reading a `CONTEXT.md` brief. The worker executes the full PR lifecycle end-to-end: code implementation, build and test, commit with conventions, push to remote, PR creation via Gitea API, and merge attempt. Multiple workers (typically 2-4) run concurrently in separate worktrees, each working on different files and features.
A `dispatch-batch.sh` script orchestrates the parallel workers and handles cross-worker dependencies. For the rare gaps — price-cap restrictions, broken tests, or ambiguous requirements — a trainer takeover (~5% of cases, typically within 5 minutes) covers the edge cases without blocking the batch.
See [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](MISTRAL-AUTONOMOUS-PATTERN.md) for the complete pattern specification.
---
## Numbers
- **23 PRs** Mistral autonomously merged to main in one calendar day
- **95-100% autonomy** per batch; trainer takeover only for Q-058 and Q-062 edge cases
- **Wall-clock parallel**: ~2 minutes for 2 PRs in a concurrent batch (vs ~3-4 minutes serial)
- **Cost**: ~$0.50-1.50 per simple PR (documentation, minor changes), ~$2-3 per code-heavy PR (complex logic, multiple files)
---
## Why this matters
The pattern compresses a sprint of work into a single day, shifting the operator role from execution to supervision. ADR-0028 (the passwordless auth migration) was essentially completed in this single session — Phase A (magic-link) fully shipped, Phase B (OIDC) advanced through B.4, with only Phase B.5 (BDD scenarios) remaining.
---
## Cross-references
- [ADR-0028](../adr/0028-passwordless-auth-migration.md) — passwordless auth migration strategy
- [AUTH.md](AUTH.md) — current authentication system state
- [MISTRAL-AUTONOMOUS-PATTERN.md](MISTRAL-AUTONOMOUS-PATTERN.md) — the pattern itself
- [PHASE_B_ROADMAP.md](PHASE_B_ROADMAP.md) — remaining Phase B work
- [CHANGELOG.md](../CHANGELOG.md) — complete PR list

132
documentation/AUTH.md Normal file
View File

@@ -0,0 +1,132 @@
# Authentication System
## Overview
The dance-lessons-coach authentication system provides a passwordless magic-link flow as the primary mechanism, with legacy username+password support during the transition period. OpenID Connect (OIDC) integration is in progress for Phase B. See [ADR-0028](../adr/0028-passwordless-auth-migration.md) for the migration strategy.
## Authentication mechanisms supported
### Username + password (legacy, ADR-0018)
- **Endpoint:** `POST /api/v1/auth/login`
- **Status:** Operational, to be decommissioned in Phase C
- **Details:** bcrypt-hashed passwords, JWT token issuance
### Magic link by email (ADR-0028 Phase A)
- **Request endpoint:** `POST /api/v1/auth/magic-link/request` — accepts `{email}`, generates token, stores hash, sends email
- **Consume endpoint:** `GET /api/v1/auth/magic-link/consume?token=<...>` — validates hash, marks consumed, issues JWT
- **Always returns 200 on request** to prevent email enumeration
- **First-link sign-up:** if email is unknown, consume endpoint creates the user record
### OpenID Connect (ADR-0028 Phase B, work in progress)
- **Status:** Skeleton merged (`pkg/auth/`), handlers and flow not yet wired
- **Planned endpoints:**
- `GET /api/v1/auth/oidc/start` — generates state + PKCE, redirects to provider
- `GET /api/v1/auth/oidc/callback` — exchanges code for tokens, validates id_token, issues internal JWT
- **Provider config:** `auth.oidc.providers.*` in config
## Magic-link flow detail
```mermaid
sequenceDiagram
User->>Server: POST /api/v1/auth/magic-link/request {email}
Server-->>User: 200 (always — anti-enumeration)
Server->>Mailpit (or SMTP provider): SMTP send "Your sign-in link"
User->>Email: clicks link
User->>Server: GET /api/v1/auth/magic-link/consume?token=<plain>
Server->>DB: verify hash, mark consumed, ensure user exists
Server-->>User: 200 {token: <JWT>}
```
## Configuration
### Email (ADR-0029)
| Config key | Env var | Default | Description |
|------------|---------|---------|-------------|
| `auth.email.from` | `DLC_AUTH_EMAIL_FROM` | `noreply@dance-lessons-coach.local` | Sender address |
| `auth.email.smtp_host` | `DLC_AUTH_EMAIL_SMTP_HOST` | `localhost` | SMTP host |
| `auth.email.smtp_port` | `DLC_AUTH_EMAIL_SMTP_PORT` | `1025` | SMTP port |
| `auth.email.smtp_use_tls` | `DLC_AUTH_EMAIL_SMTP_USE_TLS` | `false` | Use TLS |
| `auth.email.timeout` | `DLC_AUTH_EMAIL_TIMEOUT` | `10s` | Connection timeout |
### Magic link (ADR-0028 Phase A)
| Config key | Env var | Default | Description |
|------------|---------|---------|-------------|
| `auth.magic_link.ttl` | `DLC_AUTH_MAGIC_LINK_TTL` | `15m` | Token lifetime |
| `auth.magic_link.base_url` | `DLC_AUTH_MAGIC_LINK_BASE_URL` | `http://localhost:8080` | Base URL for links |
| `auth.magic_link.cleanup_interval` | `DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL` | `1h` | Cleanup loop interval |
### JWT (ADR-0021)
| Config key | Env var | Default | Description |
|------------|---------|---------|-------------|
| `auth.jwt.ttl` | `DLC_AUTH_JWT_TTL` | `1h` | Token time-to-live |
| `auth.jwt.secret_retention.retention_factor` | `DLC_AUTH_JWT_SECRET_RETENTION_FACTOR` | `2.0` | Retention multiplier |
| `auth.jwt.secret_retention.max_retention` | `DLC_AUTH_JWT_SECRET_MAX_RETENTION` | `72h` | Maximum retention |
| `auth.jwt.secret_retention.cleanup_interval` | `DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL` | `1h` | Secret cleanup interval |
### OIDC (Phase B, prep)
| Config key | Env var | Default | Description |
|------------|---------|---------|-------------|
| `auth.oidc.providers.<name>.issuer_url` | `DLC_AUTH_OIDC_ISSUER_URL` | - | Provider issuer URL |
| `auth.oidc.providers.<name>.client_id` | `DLC_AUTH_OIDC_CLIENT_ID` | - | Client ID |
| `auth.oidc.providers.<name>.client_secret` | `DLC_AUTH_OIDC_CLIENT_SECRET` | - | Client secret |
## Token model
Magic-link tokens use **SHA-256 hex hashing at rest** — only the hash is stored in the database (`token_hash` column, 64 chars). The plaintext token is emailed to the user and must be supplied back to re-derive the hash. This means a database leak reveals no usable tokens. See `pkg/user/magic_link.go` for the rationale.
```go
// HashMagicLinkToken returns the lowercase hex sha256 of token
func HashMagicLinkToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
```
## Cleanup loops
### JWT secret retention (ADR-0021)
- **Location:** `pkg/user/jwt_manager.go``StartCleanupLoop`
- **Interval:** Configurable via `auth.jwt.secret_retention.cleanup_interval` (default: 1h)
- **Behavior:** Removes secrets older than retention period (TTL x retention_factor, capped at max_retention)
- **Safety:** Never removes the current primary secret
### Magic-link expired tokens (ADR-0028 Phase A)
- **Location:** `pkg/user/magic_link_cleanup.go``StartCleanupLoop`
- **Interval:** Configurable via `auth.magic_link.cleanup_interval` (default: 1h)
- **Behavior:** Deletes tokens where `expires_at < now`
- **Implementation:** Calls `DeleteExpiredMagicLinkTokens` on the repository
## Local dev setup
1. **Start services:**
```bash
docker compose up -d # starts Postgres + Mailpit
```
2. **Inspect emails:** http://localhost:8025 (Mailpit UI)
3. **HTTPS for OIDC (Phase B):**
```bash
make cert # generates certs/dev-cert.pem + certs/dev-key.pem via mkcert
```
See [MKCERT.md](MKCERT.md) for details.
## Cross-references
### Architecture Decision Records
| ADR | Description |
|-----|-------------|
| [ADR-0018](../adr/0018-user-management-auth-system.md) | Original username/password auth system |
| [ADR-0021](../adr/0021-jwt-secret-retention-policy.md) | JWT secret retention and cleanup |
| [ADR-0028](../adr/0028-passwordless-auth-migration.md) | Passwordless migration (Phase A complete, Phase B in progress) |
| [ADR-0029](../adr/0029-email-infrastructure-mailpit.md) | Email infrastructure (Mailpit) |
| [ADR-0030](../adr/0030-bdd-email-parallel-strategy.md) | BDD parallel email assertions |
### Documentation
| Document | Description |
|----------|-------------|
| [EMAIL.md](EMAIL.md) | SMTP setup and Mailpit usage |
| [MKCERT.md](MKCERT.md) | Local HTTPS certificate setup |
| [PHASE_B_ROADMAP.md](PHASE_B_ROADMAP.md) | Remaining OIDC work |
---
*Developer onboarding doc — see ADR-0028 for implementation details.*

View File

@@ -0,0 +1,219 @@
# Mistral Vibe Autonomous Pattern
**Document ID:** MISTRAL-AUTONOMOUS-PATTERN
**Date:** 2026-05-05
**Status:** Active
**Author:** Mistral Vibe (batch10-task-mistral-pattern-doc)
**Audience:** Project contributors, future trainers
---
## 1. What you'll see
PRs authored by "Gabriel Radureau" with commit messages ending in "Mistral Vibe" references. PR titles start with gitmoji. Branch names follow `vibe/<slug>` pattern.
| PR | Date | Title | Branch | Status |
|----|------|-------|--------|--------|
| #67 | 2026-05-05 | :memo: docs: email infrastructure | `vibe/batch4-task-a-email-infra` | Merged |
| #74 | 2026-05-05 | :robot: feat: BDD Mailpit helper | `vibe/batch5-task-b-bdd-mailpit` | Merged |
| #75 | 2026-05-05 | :elephant: feat: magic_link_tokens table | `vibe/batch5-task-c-db-magic-link` | Merged |
| #76 | 2026-05-05 | :rocket: feat: magic link handlers | `vibe/batch5-task-d-handlers` | Merged |
| #77 | 2026-05-05 | :test_tube: test: magic link BDD | `vibe/batch5-task-e-bdd` | Merged |
---
## 2. The pattern (high-level)
```
Operator Brief → Worktree Setup → Worker Execution → PR Lifecycle → Merge
```
### 2.1 Operator brief
Human or trainer (Claude) writes a `CONTEXT.md` brief in a workspace under `~/Work/Vibe/workspaces/<slug>/`. The brief contains:
- Mission statement
- Goal and constraints
- Process instructions
- Hard rules
- Specification
### 2.2 Worktree setup
A `vibe-workspace.sh --worktree` script creates an isolated git worktree:
- Branches from current `origin/main`
- Creates branch `vibe/<slug>`
- Isolates git state in a dedicated directory
- No race conditions (addresses Q-038)
### 2.3 Worker execution
A Mistral Vibe worker (`vibe -p`) runs end-to-end:
1. Reads the brief from `CONTEXT.md`
2. Executes coding tasks (codes, builds, tests)
3. Commits changes with appropriate messages
4. Pushes to remote branch
5. Opens PR via Gitea API
6. Attempts auto-merge
### 2.4 Parallel operation
- Multiple workers run concurrently (2-4 typical)
- Each worker operates in its own worktree
- No git checkout collisions
- Shared origin main as base
### 2.5 Dispatch orchestration
A `dispatch-batch.sh` script:
- Orchestrates batches of 2-4 workers
- Auto-merges PRs that workers opened but didn't merge
- Ensures all PRs reach merged state
- Handles cross-worker dependencies
---
## 3. Why this works
### 3.1 Worktree isolation
Git worktrees provide complete isolation of git state. Each worker has its own:
- Working directory
- Index (staging area)
- HEAD pointer
- Branch reference
This eliminates race conditions documented in Q-038 of the harness logs.
### 3.2 Pre-fetched origin
Origin is pre-fetched before worktree creation (Q-060 fix). This guarantees:
- All workers branch from current main
- No stale base branches
- Consistent starting point across batch
### 3.3 Full PR lifecycle
Workers handle the complete PR lifecycle:
- Code implementation
- Build and test execution
- Commit with proper conventions
- Push to remote
- PR creation via Gitea API
- Merge via Gitea API (squash merge default)
### 3.4 Trainer takeover
For the rare gaps (~5% of cases):
- Price-cap restrictions
- Broken Mistral tests
- Ambiguous requirements
Trainer (Claude) takeover within ~5 minutes covers these edge cases.
---
## 4. How to read PR provenance
### 4.1 Commit message markers
Look for these patterns in commit messages:
| Marker | Meaning |
|--------|---------|
| `Mostly Mistral Vibe authored` | Mixed human + AI authorship |
| `100% Mistral autonomous` | Fully autonomous workflow |
| `batch<N>-task-<X>` | Brief slug reference |
| `Q-058 trainer takeover` | Specific quirk reference |
| `Q-062 fix applied` | Quirk mitigation applied |
### 4.2 Branch naming
Branch names encode the workflow:
```
vibe/<batch>-<task>-<description>
```
Examples:
- `vibe/batch4-task-a-email-infra`
- `vibe/batch10-task-mistral-pattern-doc`
### 4.3 PR title conventions
PR titles use gitmoji prefix:
- `:memo:` - Documentation
- `:robot:` - AI/automation
- `:elephant:` - Database
- `:rocket:` - Feature
- `:test_tube:` - Testing
---
## 5. Reproducing the pattern
### 5.1 Quickstart guide
See `~/.vibe/scripts/QUICKSTART-DISPATCH-BATCH.md` for complete how-to guide.
### 5.2 Resources
| Resource | Path | Description |
|----------|------|-------------|
| Brief template | `~/.vibe/skills/prompt-builder/examples/dispatch-batch-task.md` | Standardized brief format |
| Mistral quirks | `~/.vibe/memory/reference/mistral-quirks.md` | Accumulated lessons (Q-001 through Q-063 as of 2026-05-05) |
| Architecture doc | `~/.vibe/memory/reference/architecture-mapreduce-orchestration.md` | Design rationale |
| Budget history | `~/.vibe/memory/reference/budget-history.jsonl` | Empirical cost data |
---
## 6. Numbers (2026-05-05 reference)
### 6.1 Throughput
| Metric | Value | Notes |
|--------|-------|-------|
| PRs merged (one day) | 20 | Mistral autonomous |
| Wall-clock parallel (2 PRs) | ~2 minutes | vs ~3-4 minutes serial |
| Wall-clock parallel (4 PRs) | ~2-3 minutes | Batch efficiency |
### 6.2 Cost
| PR Type | Cost Range | Notes |
|---------|------------|-------|
| Simple PR | $0.5-1.5 | Documentation, minor changes |
| Code-heavy PR | $2-3 | Complex logic, multiple files |
| Complex PR | $3-5 | Architecture changes, deep refactoring |
### 6.3 Autonomy rate
| Metric | Value |
|--------|-------|
| Autonomy rate per batch | 95-100% |
| Trainer takeover rate | 5% |
| Takeover reasons | Price-cap (2%), broken tests (2%), ambiguity (1%) |
---
## 7. Future evolution
### 7.1 Phase 1bis (current)
- Multi-process workers operating in parallel
- Claude trainer reduces observations
- Improves harness reliability
- Current state as of 2026-05-05
### 7.2 Phase 2 (target)
- Mistral meta-agent performs reduce phase
- Full autonomy loop without Claude
- Self-improving pattern
- Target: Q3 2026
### 7.3 Long-term vision
- Fully autonomous feature development
- Self-healing test failures
- Cost-optimized batch dispatch
- Multi-repository orchestration
---
## 8. Cross-references
### 8.1 Related ADRs
| ADR | Description |
|-----|-------------|
| [ADR-0001](../adr/0001-go-1.26.1-standard.md) | Go 1.26.1 standard |
| [ADR-0008](../adr/0008-bdd-testing.md) | BDD with Godog |
| [ADR-0028](../adr/0028-passwordless-auth-migration.md) | Passwordless auth (Phase A complete) |
### 8.2 Related documentation
| Document | Description |
|----------|-------------|
| [CONTRIBUTING.md](../CONTRIBUTING.md) | Contribution guidelines |
| [AGENTS.md](../AGENTS.md) | Agent documentation |
| [PHASE_B_ROADMAP.md](PHASE_B_ROADMAP.md) | Phase B OIDC roadmap |
---
*Developer onboarding doc — see QUICKSTART-DISPATCH-BATCH.md for implementation details.*

120
documentation/MKCERT.md Normal file
View File

@@ -0,0 +1,120 @@
# mkcert: Local HTTPS for Development
## Overview
This document describes how to set up local HTTPS development certificates using `mkcert`.
OIDC providers **reject `http://localhost` as a redirect URI** by default for security reasons. To test OAuth 2.0 / OpenID Connect flows locally, the development server must be accessible via HTTPS. `mkcert` provides a zero-configuration local Certificate Authority that generates trusted certificates for localhost and custom domains.
This setup is a prerequisite for **ADR-0028 Phase B** (OpenID Connect Authorization Code flow).
## Why mkcert
- **Trusted locally**: Certificates are automatically trusted by the system root store (macOS, Linux, Windows)
- **No configuration**: Single commands to create and install the CA
- **Local-only**: Certificates are valid only for localhost development, never exposed to production
- **Industry standard**: Widely adopted tool for local HTTPS development
## Installation
### macOS (Homebrew)
```bash
brew install mkcert
mkcert -install
```
The `mkcert -install` command creates and installs a local Certificate Authority in your system trust store.
### Linux
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for distribution-specific instructions.
### Windows
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for Windows installation.
## Generate Certificates
Use the provided Make target to generate certificates for localhost development:
```bash
make cert
```
This runs the following command:
```bash
mkcert -cert-file ./certs/dev-cert.pem -key-file ./certs/dev-key.pem localhost 127.0.0.1 ::1
```
### Output Files
| File | Description |
|------|-------------|
| `./certs/dev-cert.pem` | TLS certificate for localhost, 127.0.0.1, and ::1 |
| `./certs/dev-key.pem` | Private key for the certificate |
Both files are created in the `./certs/` directory at the project root.
## Use in Development
Once certificates are generated, start the server with TLS enabled:
```bash
./bin/server --tls-cert ./certs/dev-cert.pem --tls-key ./certs/dev-key.pem
```
> **Note**: The `--tls-cert` and `--tls-key` flags are **not yet implemented** — this is planned for ADR-0028 Phase B.4. The Makefile and certificate generation are prepared in advance so that when the server TLS support is added, the certificates are ready.
The server will then be accessible at:
- `https://localhost:8080` (or the configured port)
- `https://127.0.0.1:8080`
- `https://[::1]:8080`
All OIDC callback URLs must use HTTPS with one of these hostnames.
## Clean Up
To remove generated certificates:
```bash
make clean-cert
```
This deletes the entire `./certs/` directory.
## .gitignore
The `certs/` directory contains locally-generated certificates and **must not be committed** to version control.
Ensure `certs/` is in your `.gitignore`. If it is not already present, add it:
```bash
echo "certs/" >> .gitignore
```
## Cross-References
- [ADR-0028: Passwordless authentication: magic link → OpenID Connect](../adr/0028-passwordless-auth-migration.md) — Phase B describes the OIDC implementation that requires HTTPS
- [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert) — Official documentation
## Troubleshooting
### "mkcert not found" when running `make cert`
Ensure `mkcert` is installed and available in your `PATH`. The Makefile checks for this and will display an error message if `mkcert` is not found.
### Certificate not trusted by browser
Run `mkcert -install` again. On macOS, you may need to restart your browser completely (close all windows, not just tabs).
### Port already in use
If another process is using the port (e.g., a non-TLS server on port 8080), stop that process first or configure the server to use a different port.
## See Also
- `make help` — List all available Make targets
- [documentation/API.md](API.md) — API endpoints reference
- [documentation/BDD_GUIDE.md](BDD_GUIDE.md) — BDD testing guide

View File

@@ -0,0 +1,145 @@
# ADR-0028 Phase B Roadmap
**Document ID:** PHASE_B_ROADMAP
**Date:** 2026-05-05 evening
**Status:** In Progress
**Author:** AI Agent (vibe/batch4-task-b-phase-b-roadmap)
---
## Status as of 2026-05-05 evening
- [x] ADR-0028 Phase A complete (PRs #59-#63, #65)
- [x] Phase B.1 OIDC config (PR #64)
- [x] Phase B prep : pkg/auth skeleton (PR #69) + mkcert doc (PR #68)
- [x] Phase B.3 OIDC client implementation : ✅ (PR #74)
- [x] Phase B.4 OIDC HTTP handlers + tests : ✅ (PR #75 + PR #76 follow-up tests)
## Status as of 2026-05-05 evening (after autonomous Mistral session)
Phase B is essentially complete except B.5. The OIDC client (B.3, PR #74), HTTP handlers and tests (B.4, PR #75 + PR #76) have been delivered and merged.
---
## Remaining work
Phase B delivers OpenID Connect Authorization Code flow with PKCE. Work is organized into **3 shippable phases**, each deliverable as an independent PR. At the time of this update, only Phase B.5 (BDD scenarios) remains to be completed.
### Phase B.3 — OIDC client implementation
- **Goal:** Implement the core OIDC client methods in `pkg/auth/oidc.go`
- **Tasks:**
- `Discover()`: HTTP GET to `/.well-known/openid-configuration`, parse + cache discovery document
- `RefreshJWKS()`: HTTP GET to JWKS URI, parse RSA public keys, cache with TTL
- `ExchangeCode()`: POST to token endpoint with code + PKCE verifier, return TokenResponse
- `ValidateIDToken()`: Verify signature against JWKS, validate standard claims (iss, aud, exp, iat)
- **LOE:** ~200 lines of Go + unit tests
- **Dependencies:** None (uses standard library `crypto/rsa`, `encoding/jwt`)
- **Deliverable:** 1 PR
### Phase B.4 — OIDC HTTP handlers
- **Goal:** Add OIDC flow endpoints and wire them into the server
- **Tasks:**
- Create `pkg/user/api/oidc_handler.go`
- `GET /api/v1/auth/oidc/start`:
- Generate state (CSRF protection) + PKCE verifier + challenge
- Store state + verifier (cookie or short-lived in-memory store)
- Redirect to provider's authorization endpoint
- `GET /api/v1/auth/oidc/callback`:
- Validate state parameter matches stored state
- Exchange code for tokens (calls B.3 client)
- Validate id_token (calls B.3 client)
- Issue internal JWT (reuse existing JWT manager from ADR-0021)
- Return JWT in Set-Cookie + JSON body
- Wire routes in `pkg/server/server.go`
- **LOE:** ~150 lines of Go + unit tests + integration tests
- **Dependencies:** B.3 (client methods must be implemented)
- **Prerequisite:** Run `make cert` (mkcert, from PR #68) before starting dev
- **Deliverable:** 1 PR
### Phase B.5 — BDD coverage
- **Goal:** End-to-end OIDC testing
- **Tasks:**
- Create `features/auth/oidc.feature` with scenarios:
- Happy path: start → provider auth → callback → JWT issued
- Error: state mismatch
- Error: invalid code
- Error: expired id_token
- Use mock OIDC provider (local in-process) OR deterministic test against Authelia/Keycloak in docker-compose
- Follow ADR-0030 parallel BDD strategy for email assertions
- **LOE:** ~150 lines of Gherkin + step definitions
- **Dependencies:** B.3 + B.4 (endpoints must be operational)
- **Deliverable:** 1 PR
---
## Dependencies and order
```
B.3 (OIDC client)
B.4 (HTTP handlers) —— requires B.3
B.5 (BDD coverage) —— requires B.3 + B.4
```
**Note:** mkcert (PR #68) is ready. When starting B.4 development, run `make cert` once to generate local HTTPS certificates.
---
## Out of scope for Phase B (deferred)
| Item | Target Phase | Rationale |
|------|--------------|-----------|
| Decommission password auth | Phase C | Separate ADR after B is in production |
| Multi-provider (Authelia + Google) | Phase B.6 (if needed) | Single provider sufficient for MVP |
| JWKS rotation mid-flight retry | B.3 enhancement | Handle in initial implementation |
| Token refresh flow | Future | Not required for auth code flow MVP |
---
## Risk register
| Risk | Mitigation | Owner |
|------|------------|-------|
| JWKS rotation handling | Implement refresh + retry logic; key rotation must not break mid-flight validation | B.3 implementer |
| PKCE storage | Use signed cookie or short-lived in-memory store; document trade-offs in implementation PR | B.4 implementer |
| Testing without real provider | Use mock OIDC server for CI; local dev uses Authelia in docker-compose | B.5 implementer |
| State CSRF protection | Use cryptographically random state; store server-side with short TTL | B.4 implementer |
---
## Cross-references
- [ADR-0028: Passwordless authentication: magic link → OpenID Connect](../adr/0028-passwordless-auth-migration.md)
- [ADR-0029: Email infrastructure (Mailpit)](../adr/0029-email-infrastructure-mailpit.md)
- [ADR-0030: BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md)
- [PR #59: Email infrastructure](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/59)
- [PR #60: BDD Mailpit helper](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/60)
- [PR #61: magic_link_tokens table](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/61)
- [PR #62: Magic link handlers](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/62)
- [PR #63: Magic link BDD](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/63)
- [PR #64: OIDC config skeleton](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/64)
- [PR #65: Magic link cleanup loop](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/65)
- [PR #68: mkcert documentation](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/68)
- [PR #69: pkg/auth skeleton](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/69)
- [PR #74: Phase B.3 OIDC client implementation](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/74)
- [PR #75: Phase B.4 OIDC HTTP handlers](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/75)
- [PR #76: Phase B.4 follow-up tests](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/76)
---
## Appendix: File inventory
Existing (merged):
- `pkg/auth/oidc.go` — skeleton with TODO methods (PR #69)
- `pkg/auth/oidc_test.go` — placeholder tests (PR #69)
- `documentation/MKCERT.md` — mkcert setup guide (PR #68)
- `Makefile` — includes `make cert` target (PR #68)
To be created:
- `pkg/auth/oidc.go` — complete implementation (B.3)
- `pkg/user/api/oidc_handler.go` — HTTP handlers (B.4)
- `pkg/server/server.go` — route wiring (B.4)
- `features/auth/oidc.feature` — BDD scenarios (B.5)
- `pkg/auth/oidc_test.go` — expanded unit tests (B.3)
- `pkg/user/api/oidc_handler_test.go` — handler tests (B.4)

345
pkg/auth/oidc.go Normal file
View File

@@ -0,0 +1,345 @@
// Package auth provides OpenID Connect client primitives for the
// dance-lessons-coach passwordless-auth migration (ADR-0028 Phase B).
//
// This file defines the client surface only. HTTP handlers wire-up
// happens in pkg/user/api/oidc_handler.go (separate phase B.3).
package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// OIDCClient is a per-provider OIDC client.
// Holds the discovery document + JWKS cache + OAuth code-exchange config.
type OIDCClient struct {
issuerURL string
clientID string
clientSecret string
httpClient *http.Client
// discovery document, lazy-fetched on first use
discoveryMu sync.RWMutex
discovery *Discovery
// JWKS cache (id_token signature verification keys), refreshed periodically
jwksMu sync.RWMutex
jwks map[string]*rsa.PublicKey
jwksFetched time.Time
}
// Discovery is the subset of the .well-known/openid-configuration document we use.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
}
// TokenResponse is the response from the token endpoint after code exchange.
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token"`
Scope string `json:"scope,omitempty"`
}
// IDTokenClaims represents the parsed claims from an ID token.
type IDTokenClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
}
// jwks represents the JWKS (JSON Web Key Set) response.
type jwks struct {
Keys []jwk `json:"keys"`
}
// jwk represents a single JSON Web Key.
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
}
// NewOIDCClient constructs a client. Discovery + JWKS are NOT fetched eagerly;
// they are lazy-loaded on first use to avoid blocking server startup if the
// provider is temporarily down.
func NewOIDCClient(issuerURL, clientID, clientSecret string) *OIDCClient {
return &OIDCClient{
issuerURL: issuerURL,
clientID: clientID,
clientSecret: clientSecret,
httpClient: &http.Client{Timeout: 10 * time.Second},
jwks: make(map[string]*rsa.PublicKey),
}
}
// ClientID returns the OIDC client ID.
func (c *OIDCClient) ClientID() string {
return c.clientID
}
// IssuerURL returns the OIDC issuer URL.
func (c *OIDCClient) IssuerURL() string {
return c.issuerURL
}
// SetHTTPClient sets a custom HTTP client for testing.
func (c *OIDCClient) SetHTTPClient(client *http.Client) {
c.httpClient = client
}
// decodeRSAPublicKey reconstructs an *rsa.PublicKey from JWK n and e values.
func decodeRSAPublicKey(j jwk) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(j.N)
if err != nil {
return nil, fmt.Errorf("decode n: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(j.E)
if err != nil {
return nil, fmt.Errorf("decode e: %w", err)
}
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
}
// Discover fetches and caches the .well-known document. Idempotent.
// First call: HTTP fetch + cache. Subsequent calls: cached value.
func (c *OIDCClient) Discover(ctx context.Context) (*Discovery, error) {
c.discoveryMu.RLock()
if c.discovery != nil {
c.discoveryMu.RUnlock()
return c.discovery, nil
}
c.discoveryMu.RUnlock()
c.discoveryMu.Lock()
defer c.discoveryMu.Unlock()
// Double-check after acquiring write lock
if c.discovery != nil {
return c.discovery, nil
}
wellKnownURL := fmt.Sprintf("%s/.well-known/openid-configuration", c.issuerURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
if err != nil {
return nil, fmt.Errorf("create discovery request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("discovery HTTP %d", resp.StatusCode)
}
var disc Discovery
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
return nil, fmt.Errorf("decode discovery: %w", err)
}
c.discovery = &disc
return &disc, nil
}
// RefreshJWKS fetches JWKS URI, parse keys, populate jwks map.
func (c *OIDCClient) RefreshJWKS(ctx context.Context) error {
// Ensure discovery is loaded
if c.discovery == nil {
if _, err := c.Discover(ctx); err != nil {
return fmt.Errorf("discover: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.discovery.JWKSUri, nil)
if err != nil {
return fmt.Errorf("create JWKS request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("JWKS HTTP %d", resp.StatusCode)
}
var keySet jwks
if err := json.NewDecoder(resp.Body).Decode(&keySet); err != nil {
return fmt.Errorf("decode JWKS: %w", err)
}
c.jwksMu.Lock()
defer c.jwksMu.Unlock()
c.jwks = make(map[string]*rsa.PublicKey)
for _, key := range keySet.Keys {
if key.Kty == "RSA" {
pubKey, err := decodeRSAPublicKey(key)
if err != nil {
return fmt.Errorf("decode RSA key %s: %w", key.Kid, err)
}
c.jwks[key.Kid] = pubKey
}
}
c.jwksFetched = time.Now()
return nil
}
// ExchangeCode exchanges an authorization code for an access token and ID token.
func (c *OIDCClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI string) (*TokenResponse, error) {
// Ensure discovery is loaded
if c.discovery == nil {
if _, err := c.Discover(ctx); err != nil {
return nil, fmt.Errorf("discover: %w", err)
}
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("code_verifier", codeVerifier)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", c.clientID)
form.Set("client_secret", c.clientSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.discovery.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("exchange code: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token HTTP %d", resp.StatusCode)
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("decode token response: %w", err)
}
return &tokenResp, nil
}
// ValidateIDToken verifies the signature and claims of an ID token.
func (c *OIDCClient) ValidateIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) {
// First, parse without verification to get the kid
parser := jwt.NewParser()
unverifiedToken, _, err := parser.ParseUnverified(idToken, &IDTokenClaims{})
if err != nil {
return nil, fmt.Errorf("parse unverified token: %w", err)
}
claims, ok := unverifiedToken.Claims.(*IDTokenClaims)
if !ok {
return nil, fmt.Errorf("invalid claims type")
}
// Get kid from header
kid, ok := unverifiedToken.Header["kid"].(string)
if !ok || kid == "" {
return nil, fmt.Errorf("missing kid in token header")
}
// Get the key, refreshing JWKS if needed
c.jwksMu.RLock()
_, keyExists := c.jwks[kid]
c.jwksMu.RUnlock()
if !keyExists {
if err := c.RefreshJWKS(ctx); err != nil {
return nil, fmt.Errorf("refresh JWKS: %w", err)
}
c.jwksMu.RLock()
_, keyExists = c.jwks[kid]
c.jwksMu.RUnlock()
if !keyExists {
return nil, fmt.Errorf("key %s not found in JWKS", kid)
}
}
// Parse with verification
keyFunc := func(token *jwt.Token) (interface{}, error) {
if kid, ok := token.Header["kid"].(string); ok {
c.jwksMu.RLock()
defer c.jwksMu.RUnlock()
if key, exists := c.jwks[kid]; exists {
return key, nil
}
}
return nil, fmt.Errorf("key not found")
}
parsedToken, err := jwt.ParseWithClaims(idToken, &IDTokenClaims{}, keyFunc)
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
claims, ok = parsedToken.Claims.(*IDTokenClaims)
if !ok {
return nil, fmt.Errorf("invalid claims type after parse")
}
// Validate claims
if claims.Issuer != c.issuerURL {
return nil, fmt.Errorf("issuer mismatch: expected %s, got %s", c.issuerURL, claims.Issuer)
}
// Check audience contains clientID
audValid := false
if claims.Audience != nil {
for _, aud := range claims.Audience {
if aud == c.clientID {
audValid = true
break
}
}
}
if !audValid {
return nil, fmt.Errorf("audience does not contain client ID %s", c.clientID)
}
// Check expiration
if claims.ExpiresAt != nil && time.Now().UTC().After(claims.ExpiresAt.Time) {
return nil, fmt.Errorf("token expired")
}
return claims, nil
}

431
pkg/auth/oidc_test.go Normal file
View File

@@ -0,0 +1,431 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestNewOIDCClient(t *testing.T) {
c := NewOIDCClient("https://example.com", "client_id", "client_secret")
if c == nil {
t.Fatal("NewOIDCClient returned nil")
}
if c.issuerURL != "https://example.com" {
t.Errorf("issuerURL not set: got %q", c.issuerURL)
}
}
func TestDiscover_HappyPath(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
disc, err := client.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
if disc.Issuer != server.URL {
t.Errorf("issuer mismatch: got %s, want %s", disc.Issuer, server.URL)
}
if disc.TokenEndpoint != server.URL+"/token" {
t.Errorf("token endpoint mismatch: got %s", disc.TokenEndpoint)
}
if disc.JWKSUri != server.URL+"/jwks" {
t.Errorf("jwks_uri mismatch: got %s", disc.JWKSUri)
}
}
func TestDiscover_Idempotent(t *testing.T) {
var requestCount int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// First call
_, err := client.Discover(context.Background())
if err != nil {
t.Fatalf("First Discover failed: %v", err)
}
// Second call
_, err = client.Discover(context.Background())
if err != nil {
t.Fatalf("Second Discover failed: %v", err)
}
if atomic.LoadInt32(&requestCount) != 1 {
t.Errorf("Expected 1 HTTP request, got %d", requestCount)
}
}
func generateTestRSAKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
return privKey
}
func encodeRSAPublicKey(privKey *rsa.PrivateKey) (n, e string) {
n = base64.RawURLEncoding.EncodeToString(privKey.PublicKey.N.Bytes())
e = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privKey.PublicKey.E)).Bytes())
return n, e
}
func TestRefreshJWKS_HappyPath(t *testing.T) {
privKey := generateTestRSAKey(t)
n, e := encodeRSAPublicKey(privKey)
var discoveryCalled, jwksCalled bool
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
discoveryCalled = true
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
return
}
if r.URL.Path == "/jwks" {
jwksCalled = true
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"keys":[{"kid":"test-key-id","kty":"RSA","use":"sig","alg":"RS256","n":"%s","e":"%s"}]}`, n, e)))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// First discover to populate discovery
_, err := client.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
// Now refresh JWKS
err = client.RefreshJWKS(context.Background())
if err != nil {
t.Fatalf("RefreshJWKS failed: %v", err)
}
if !discoveryCalled {
t.Error("discovery endpoint was not called")
}
if !jwksCalled {
t.Error("jwks endpoint was not called")
}
// Check that jwks was populated
client.jwksMu.RLock()
defer client.jwksMu.RUnlock()
if len(client.jwks) != 1 {
t.Errorf("expected 1 key in jwks, got %d", len(client.jwks))
}
if _, exists := client.jwks["test-key-id"]; !exists {
t.Error("test-key-id not found in jwks")
}
}
func TestExchangeCode_HappyPath(t *testing.T) {
tokenResponseJSON := `{"access_token":"access-token-123","id_token":"id-token-456","token_type":"Bearer","expires_in":3600}`
var receivedForm url.Values
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
return
}
if r.URL.Path == "/token" {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("expected Content-Type application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type"))
w.WriteHeader(http.StatusBadRequest)
return
}
err := r.ParseForm()
if err != nil {
t.Errorf("failed to parse form: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
receivedForm = r.Form
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(tokenResponseJSON))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// Discover first to populate discovery
_, err := client.Discover(context.Background())
if err != nil {
t.Fatalf("Discover failed: %v", err)
}
resp, err := client.ExchangeCode(context.Background(), "auth-code-789", "code-verifier-123", "https://app.example.com/callback")
if err != nil {
t.Fatalf("ExchangeCode failed: %v", err)
}
if resp.AccessToken != "access-token-123" {
t.Errorf("access token mismatch: got %s", resp.AccessToken)
}
if resp.IDToken != "id-token-456" {
t.Errorf("id token mismatch: got %s", resp.IDToken)
}
if resp.TokenType != "Bearer" {
t.Errorf("token type mismatch: got %s", resp.TokenType)
}
// Check form values
if receivedForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type mismatch: got %s", receivedForm.Get("grant_type"))
}
if receivedForm.Get("code") != "auth-code-789" {
t.Errorf("code mismatch: got %s", receivedForm.Get("code"))
}
if receivedForm.Get("code_verifier") != "code-verifier-123" {
t.Errorf("code_verifier mismatch: got %s", receivedForm.Get("code_verifier"))
}
if receivedForm.Get("redirect_uri") != "https://app.example.com/callback" {
t.Errorf("redirect_uri mismatch: got %s", receivedForm.Get("redirect_uri"))
}
if receivedForm.Get("client_id") != "client_id" {
t.Errorf("client_id mismatch: got %s", receivedForm.Get("client_id"))
}
if receivedForm.Get("client_secret") != "client_secret" {
t.Errorf("client_secret mismatch: got %s", receivedForm.Get("client_secret"))
}
}
func TestValidateIDToken_HappyPath(t *testing.T) {
privKey := generateTestRSAKey(t)
n, e := encodeRSAPublicKey(privKey)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
return
}
if r.URL.Path == "/jwks" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"keys":[{"kid":"test-key-id","kty":"RSA","use":"sig","alg":"RS256","n":"%s","e":"%s"}]}`, n, e)))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// Create and sign a JWT
token := jwt.NewWithClaims(jwt.SigningMethodRS256, &IDTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: server.URL,
Audience: jwt.ClaimStrings{"client_id"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: "user-123",
},
Email: "user@example.com",
EmailVerified: true,
Name: "Test User",
})
token.Header["kid"] = "test-key-id"
signedToken, err := token.SignedString(privKey)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
// Validate the token
claims, err := client.ValidateIDToken(context.Background(), signedToken)
if err != nil {
t.Fatalf("ValidateIDToken failed: %v", err)
}
if claims.Issuer != server.URL {
t.Errorf("issuer mismatch: got %s, want %s", claims.Issuer, server.URL)
}
if claims.Subject != "user-123" {
t.Errorf("subject mismatch: got %s", claims.Subject)
}
if claims.Email != "user@example.com" {
t.Errorf("email mismatch: got %s", claims.Email)
}
if !claims.EmailVerified {
t.Error("email_verified should be true")
}
if claims.Name != "Test User" {
t.Errorf("name mismatch: got %s", claims.Name)
}
}
func TestValidateIDToken_RejectsExpired(t *testing.T) {
privKey := generateTestRSAKey(t)
n, e := encodeRSAPublicKey(privKey)
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
return
}
if r.URL.Path == "/jwks" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"keys":[{"kid":"test-key-id","kty":"RSA","use":"sig","alg":"RS256","n":"%s","e":"%s"}]}`, n, e)))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// Create an expired JWT
token := jwt.NewWithClaims(jwt.SigningMethodRS256, &IDTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: server.URL,
Audience: jwt.ClaimStrings{"client_id"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // Expired 1 hour ago
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
Subject: "user-123",
},
})
token.Header["kid"] = "test-key-id"
signedToken, err := token.SignedString(privKey)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
// Should fail due to expired token
_, err = client.ValidateIDToken(context.Background(), signedToken)
if err == nil {
t.Error("expected error for expired token, got nil")
}
}
func TestValidateIDToken_RejectsWrongIssuer(t *testing.T) {
privKey := generateTestRSAKey(t)
n, e := encodeRSAPublicKey(privKey)
wrongIssuer := "https://wrong-provider.example.com"
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)))
return
}
if r.URL.Path == "/jwks" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"keys":[{"kid":"test-key-id","kty":"RSA","use":"sig","alg":"RS256","n":"%s","e":"%s"}]}`, n, e)))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewOIDCClient(server.URL, "client_id", "client_secret")
client.httpClient = server.Client()
// Create a JWT with wrong issuer
token := jwt.NewWithClaims(jwt.SigningMethodRS256, &IDTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: wrongIssuer,
Audience: jwt.ClaimStrings{"client_id"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: "user-123",
},
})
token.Header["kid"] = "test-key-id"
signedToken, err := token.SignedString(privKey)
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
// Should fail due to issuer mismatch
_, err = client.ValidateIDToken(context.Background(), signedToken)
if err == nil {
t.Error("expected error for wrong issuer, got nil")
}
}

View File

@@ -109,12 +109,27 @@ type AuthConfig struct {
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
type MagicLinkConfig struct {
TTL time.Duration `mapstructure:"ttl"`
BaseURL string `mapstructure:"base_url"`
TTL time.Duration `mapstructure:"ttl"`
BaseURL string `mapstructure:"base_url"`
CleanupInterval time.Duration `mapstructure:"cleanup_interval"`
}
// OIDCConfig holds OpenID Connect provider configuration (ADR-0028 Phase B).
// Multiple providers are supported via a map keyed by provider name (e.g. "arcodange-sso", "google").
type OIDCConfig struct {
Providers map[string]OIDCProvider `mapstructure:"providers"`
}
// OIDCProvider describes a single OIDC provider's discovery + client config.
type OIDCProvider struct {
IssuerURL string `mapstructure:"issuer_url"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
}
// EmailConfig holds outgoing email transport configuration.
@@ -286,6 +301,11 @@ func LoadConfig() (*Config, error) {
// Magic-link defaults (ADR-0028 Phase A).
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
v.SetDefault("auth.magic_link.cleanup_interval", 1*time.Hour)
// OIDC defaults (ADR-0028 Phase B). Providers map is empty by default;
// configured per environment via config file or env vars.
v.SetDefault("auth.oidc.providers", map[string]interface{}{})
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
@@ -343,6 +363,14 @@ func LoadConfig() (*Config, error) {
// Magic-link environment variables (ADR-0028 Phase A).
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
v.BindEnv("auth.magic_link.base_url", "DLC_AUTH_MAGIC_LINK_BASE_URL")
v.BindEnv("auth.magic_link.cleanup_interval", "DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL")
// OIDC environment variables (ADR-0028 Phase B). One canonical "default"
// provider is bindable via env; additional providers must be defined in config.yaml.
v.BindEnv("auth.oidc.providers.default.issuer_url", "DLC_AUTH_OIDC_ISSUER_URL")
v.BindEnv("auth.oidc.providers.default.client_id", "DLC_AUTH_OIDC_CLIENT_ID")
v.BindEnv("auth.oidc.providers.default.client_secret", "DLC_AUTH_OIDC_CLIENT_SECRET")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
@@ -494,6 +522,23 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
return out
}
// GetOIDCProviders returns the configured OIDC providers, keyed by provider name.
// Empty map (not nil) is returned when no providers are configured.
func (c *Config) GetOIDCProviders() map[string]OIDCProvider {
if c.Auth.OIDC.Providers == nil {
return map[string]OIDCProvider{}
}
return c.Auth.OIDC.Providers
}
// GetMagicLinkCleanupInterval returns the magic-link cleanup interval (ADR-0028 Phase A consequence).
func (c *Config) GetMagicLinkCleanupInterval() time.Duration {
if c.Auth.MagicLink.CleanupInterval <= 0 {
return 1 * time.Hour
}
return c.Auth.MagicLink.CleanupInterval
}
// GetJWTTTL returns the JWT TTL
func (c *Config) GetJWTTTL() time.Duration {
if c.Auth.JWT.TTL == 0 {

View File

@@ -18,6 +18,7 @@ import (
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger"
"dance-lessons-coach/pkg/auth"
"dance-lessons-coach/pkg/cache"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
@@ -246,6 +247,9 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
r.Get("/{name}", s.handleGreetPath)
})
// Uptime endpoint
r.Get("/uptime", s.handleUptime)
// Register user authentication routes
if s.userService != nil && s.userRepo != nil {
// Use unified user service - much simpler!
@@ -276,6 +280,18 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
)
mlHandler.RegisterRoutes(r)
}
// OIDC handlers (ADR-0028 Phase B.4)
oidcProviders := s.config.GetOIDCProviders()
if len(oidcProviders) > 0 {
oidcClients := make(map[string]*auth.OIDCClient, len(oidcProviders))
for name, p := range oidcProviders {
oidcClients[name] = auth.NewOIDCClient(p.IssuerURL, p.ClientID, p.ClientSecret)
}
redirectBase := s.config.GetMagicLinkConfig().BaseURL
oidcHandler := userapi.NewOIDCHandler(oidcClients, s.userService, s.userRepo, redirectBase)
oidcHandler.RegisterRoutes(r)
}
})
// Register admin routes
@@ -583,6 +599,30 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
// UptimeResponse represents the JSON response for /api/v1/uptime
type UptimeResponse struct {
StartTime string `json:"start_time"`
UptimeSeconds int `json:"uptime_seconds"`
}
// handleUptime godoc
//
// @Summary Get server uptime
// @Description Returns server start time and uptime duration
// @Tags System/Info
// @Produce json
// @Success 200 {object} UptimeResponse
// @Router /v1/uptime [get]
func (s *Server) handleUptime(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Uptime check requested")
resp := UptimeResponse{
StartTime: s.startedAt.Format(time.RFC3339),
UptimeSeconds: int(time.Since(s.startedAt).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// handleGreetQuery godoc
//
// @Summary Get greeting with cache
@@ -764,6 +804,12 @@ func (s *Server) Run() error {
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
}
// Start the magic-link expired-token cleanup loop (ADR-0028 Phase A consequence).
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
runner := user.NewMagicLinkCleanupRunner(mlRepo)
runner.StartCleanupLoop(rootCtx, s.config.GetMagicLinkCleanupInterval())
}
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
// telemetrySetup is non-nil only when telemetry was successfully initialized
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).

81
pkg/server/uptime_test.go Normal file
View File

@@ -0,0 +1,81 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestHandleUptime(t *testing.T) {
// Setup with a known start time
cfg := &config.Config{}
// We need to create a server and then set its startedAt to a known time
// Since NewServer sets startedAt to time.Now(), we'll create the server
// and then use reflection or we can use NewServerWithUserRepo which also sets startedAt
s := NewServer(cfg, context.Background())
// Set a fixed start time for deterministic testing
// We can't directly set s.startedAt since it's unexported, but we can test
// that the handler uses the server's startedAt
// The test will verify the structure and that uptime_seconds is >= 0
// Create request
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
// Call handler
s.handleUptime(w, req)
// Check status code
assert.Equal(t, http.StatusOK, w.Code)
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Decode response
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Assert fields
assert.NotEmpty(t, resp.StartTime)
// Verify start_time is in RFC3339 format
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
}
func TestHandleUptime_Deterministic(t *testing.T) {
// For a more deterministic test, we would need to be able to set startedAt
// Since startedAt is unexported, we test the behavior with a known server
// that was just created (uptime should be very small)
cfg := &config.Config{}
s := NewServer(cfg, context.Background())
// Small delay to ensure uptime is at least 0 seconds
time.Sleep(10 * time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
s.handleUptime(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Uptime should be at least 0 (it's int() of seconds, so minimum is 0)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
// Start time should be parseable
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
}

View File

@@ -0,0 +1,329 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"dance-lessons-coach/pkg/auth"
"dance-lessons-coach/pkg/user"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// OIDCHandler exposes the OIDC authorization-code endpoints.
type OIDCHandler struct {
clients map[string]*auth.OIDCClient // keyed by provider name
users user.UserService
repo user.UserRepository
redirectBase string
pkceMu sync.Mutex
pkceStore map[string]pkceEntry
}
type pkceEntry struct {
codeVerifier string
providerName string
expiresAt time.Time
}
// NewOIDCHandler creates a new OIDCHandler.
func NewOIDCHandler(clients map[string]*auth.OIDCClient, users user.UserService, repo user.UserRepository, redirectBase string) *OIDCHandler {
return &OIDCHandler{
clients: clients,
users: users,
repo: repo,
redirectBase: redirectBase,
pkceStore: make(map[string]pkceEntry),
}
}
// RegisterRoutes mounts the OIDC endpoints on the provided router.
func (h *OIDCHandler) RegisterRoutes(router chi.Router) {
router.Get("/oidc/{provider}/start", h.handleStart)
router.Get("/oidc/{provider}/callback", h.handleCallback)
}
// handleStart initiates the OIDC authorization-code flow.
//
// @Summary Start OIDC authorization
// @Description Generates PKCE state and verifier, redirects to the OIDC provider authorization endpoint.
// @Tags API/v1/User
// @Produce json
// @Param provider path string true "OIDC provider name"
// @Success 302 {string}string "Redirect to OIDC provider"
// @Failure 404 {object}map[string]string "Unknown provider"
// @Failure 502 {object}map[string]string "Discovery failed"
// @Router /v1/auth/oidc/{provider}/start [get]
func (h *OIDCHandler) handleStart(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provider := chi.URLParam(r, "provider")
client, exists := h.clients[provider]
if !exists {
log.Warn().Ctx(ctx).Str("provider", provider).Msg("OIDC start: unknown provider")
writeJSONError(w, http.StatusNotFound, "unknown_provider", "unknown OIDC provider")
return
}
// Ensure discovery is loaded
disc, err := client.Discover(ctx)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("provider", provider).Msg("OIDC start: discovery failed")
writeJSONError(w, http.StatusBadGateway, "discovery_failed", fmt.Sprintf("OIDC discovery failed: %v", err))
return
}
// Generate state: 32 bytes random, base64-url-no-padding
state := generateRandomBase64URL(32)
// Generate code verifier: 32 bytes random, base64-url-no-padding
codeVerifier := generateRandomBase64URL(32)
// Compute code challenge: SHA256 hash of code verifier, base64-url-no-padding
hash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
// Store PKCE entry
h.pkceMu.Lock()
// Lazy-clean expired entries
now := time.Now()
for k, entry := range h.pkceStore {
if entry.expiresAt.Before(now) {
delete(h.pkceStore, k)
}
}
h.pkceStore[state] = pkceEntry{
codeVerifier: codeVerifier,
providerName: provider,
expiresAt: now.Add(10 * time.Minute),
}
h.pkceMu.Unlock()
// Build redirect URL
redirectURI := fmt.Sprintf("%s/api/v1/auth/oidc/%s/callback", h.redirectBase, provider)
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", client.ClientID())
v.Set("redirect_uri", redirectURI)
v.Set("state", state)
v.Set("code_challenge", codeChallenge)
v.Set("code_challenge_method", "S256")
v.Set("scope", "openid email profile")
target := disc.AuthorizationEndpoint + "?" + v.Encode()
log.Debug().Ctx(ctx).Str("provider", provider).Str("target", target).Msg("OIDC start: redirecting to provider")
http.Redirect(w, r, target, http.StatusFound)
}
// handleCallback handles the OIDC callback after authorization.
//
// @Summary OIDC callback handler
// @Description Validates state, exchanges code for tokens, validates id_token, signs up on first use, issues JWT.
// @Tags API/v1/User
// @Produce json
// @Param provider path string true "OIDC provider name"
// @Param state query string true "State parameter"
// @Param code query string false "Authorization code"
// @Param error query string false "OIDC error"
// @Success 200 {object} OIDCCallbackResponse "Successfully signed in via OIDC"
// @Failure 401 {object} map[string]string "Invalid state, missing code, or OIDC error"
// @Failure 502 {object} map[string]string "Token exchange or validation failed"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /v1/auth/oidc/{provider}/callback [get]
func (h *OIDCHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provider := chi.URLParam(r, "provider")
client, exists := h.clients[provider]
if !exists {
log.Warn().Ctx(ctx).Str("provider", provider).Msg("OIDC callback: unknown provider")
writeJSONError(w, http.StatusNotFound, "unknown_provider", "unknown OIDC provider")
return
}
// Read query parameters
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
oidcError := r.URL.Query().Get("error")
// If OIDC provider returned an error
if oidcError != "" {
log.Warn().Ctx(ctx).Str("provider", provider).Str("error", oidcError).Msg("OIDC callback: provider error")
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "oidc_error",
"provider_error": oidcError,
})
return
}
// Validate state
if state == "" {
log.Warn().Ctx(ctx).Msg("OIDC callback: missing state")
writeJSONError(w, http.StatusUnauthorized, "invalid_state", "missing state parameter")
return
}
h.pkceMu.Lock()
entry, exists := h.pkceStore[state]
if !exists {
h.pkceMu.Unlock()
log.Warn().Ctx(ctx).Str("state", state).Msg("OIDC callback: state not found")
writeJSONError(w, http.StatusUnauthorized, "invalid_state", "state not found or already used")
return
}
// Check expiration and provider match
now := time.Now()
if entry.expiresAt.Before(now) {
delete(h.pkceStore, state)
h.pkceMu.Unlock()
log.Warn().Ctx(ctx).Str("state", state).Msg("OIDC callback: state expired")
writeJSONError(w, http.StatusUnauthorized, "invalid_state", "state expired")
return
}
if entry.providerName != provider {
delete(h.pkceStore, state)
h.pkceMu.Unlock()
log.Warn().Ctx(ctx).Str("state", state).Str("expected_provider", entry.providerName).Str("actual_provider", provider).Msg("OIDC callback: provider mismatch")
writeJSONError(w, http.StatusUnauthorized, "invalid_state", "provider mismatch")
return
}
// Delete the entry (single-use)
codeVerifier := entry.codeVerifier
delete(h.pkceStore, state)
h.pkceMu.Unlock()
// Validate code parameter
if code == "" {
log.Warn().Ctx(ctx).Msg("OIDC callback: missing code")
writeJSONError(w, http.StatusUnauthorized, "invalid_request", "missing authorization code")
return
}
// Build redirect URI
redirectURI := fmt.Sprintf("%s/api/v1/auth/oidc/%s/callback", h.redirectBase, provider)
// Exchange code for tokens
tokenResp, err := client.ExchangeCode(ctx, code, codeVerifier, redirectURI)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("provider", provider).Msg("OIDC callback: code exchange failed")
writeJSONError(w, http.StatusBadGateway, "token_exchange_failed", fmt.Sprintf("code exchange failed: %v", err))
return
}
// Validate ID token
claims, err := client.ValidateIDToken(ctx, tokenResp.IDToken)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("provider", provider).Msg("OIDC callback: ID token validation failed")
writeJSONError(w, http.StatusUnauthorized, "invalid_id_token", fmt.Sprintf("ID token validation failed: %v", err))
return
}
// Check email in claims
if claims.Email == "" {
log.Warn().Ctx(ctx).Str("provider", provider).Msg("OIDC callback: no email in ID token")
writeJSONError(w, http.StatusUnauthorized, "no_email_in_id_token", "ID token does not contain an email claim")
return
}
// Ensure user exists (sign-up on first use)
u, err := h.ensureUser(ctx, claims.Email)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", claims.Email).Msg("OIDC callback: user upsert failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", fmt.Sprintf("user upsert failed: %v", err))
return
}
// Generate JWT
jwtToken, err := h.users.GenerateJWT(ctx, u)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", claims.Email).Msg("OIDC callback: JWT generation failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", fmt.Sprintf("JWT generation failed: %v", err))
return
}
log.Info().Ctx(ctx).Str("provider", provider).Str("email", claims.Email).Msg("OIDC callback: user signed in successfully")
writeJSON(w, http.StatusOK, map[string]string{
"message": "signed in via oidc",
"token": jwtToken,
"user": claims.Email,
})
}
// ensureUser returns the user keyed on email (stored as Username),
// creating them if absent. Newly-created users get a random unguessable
// bcrypt-hashed password so the password endpoints stay locked out.
func (h *OIDCHandler) ensureUser(ctx context.Context, email string) (*user.User, error) {
if h.repo != nil {
existing, err := h.repo.GetUserByUsername(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by username: %w", err)
}
if existing != nil {
return existing, nil
}
}
// Generate random password
rawPass := generateRandomHex(32)
hash, err := h.users.HashPassword(ctx, rawPass)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
u := &user.User{
Username: email,
PasswordHash: hash,
IsAdmin: false,
}
if err := h.users.CreateUser(ctx, u); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
if h.repo != nil {
return h.repo.GetUserByUsername(ctx, email)
}
return u, nil
}
// generateRandomBase64URL generates a random string suitable for use in OIDC PKCE flows.
func generateRandomBase64URL(length int) string {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("failed to read random bytes: %v", err))
}
return base64.RawURLEncoding.EncodeToString(b)
}
// generateRandomHex generates a random hex string.
func generateRandomHex(length int) string {
b := make([]byte, length/2)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("failed to read random bytes: %v", err))
}
return hex.EncodeToString(b)
}
// OIDCCallbackResponse represents the JSON response from the OIDC callback.
type OIDCCallbackResponse struct {
Message string `json:"message"`
Token string `json:"token"`
User string `json:"user"`
}

View File

@@ -0,0 +1,134 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"dance-lessons-coach/pkg/auth"
"dance-lessons-coach/pkg/user"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
)
// fakeUserSvc is reused from magic_link_handler_test.go
// It's in the same package (api) so we can use it directly.
// fakeUserRepo is reused from magic_link_handler_test.go
// It's in the same package (api) so we can use it directly.
// setupMockOIDCProvider creates a mock OIDC provider server for testing.
// Uses the Q-062 mitigation pattern with var server *httptest.Server.
func setupMockOIDCProvider(t *testing.T) *httptest.Server {
t.Helper()
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"issuer":"%s","authorization_endpoint":"%s/auth","token_endpoint":"%s/token","jwks_uri":"%s/jwks"}`,
server.URL, server.URL, server.URL, server.URL)
return
}
w.WriteHeader(http.StatusNotFound)
}))
return server
}
// mountOIDCHandler mounts the OIDCHandler on a new router and returns it.
func mountOIDCHandler(t *testing.T, handler *OIDCHandler) *chi.Mux {
t.Helper()
r := chi.NewRouter()
handler.RegisterRoutes(r)
return r
}
// newTestOIDCHandler creates an OIDCHandler with the given clients.
func newTestOIDCHandler(clients map[string]*auth.OIDCClient) *OIDCHandler {
return NewOIDCHandler(
clients,
newFakeUserSvc(),
&fakeUserRepo{svc: newFakeUserSvc()},
"http://localhost:8080",
)
}
// TestOIDCHandler_Start_RejectsUnknownProvider tests that starting with an unknown provider returns 404.
func TestOIDCHandler_Start_RejectsUnknownProvider(t *testing.T) {
handler := newTestOIDCHandler(map[string]*auth.OIDCClient{})
router := mountOIDCHandler(t, handler)
req := httptest.NewRequest(http.MethodGet, "/oidc/unknown/start", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
// TestOIDCHandler_Callback_RejectsMissingState tests that callback without state returns 401.
func TestOIDCHandler_Callback_RejectsMissingState(t *testing.T) {
client := auth.NewOIDCClient("http://mock-provider", "test-id", "test-secret")
handler := newTestOIDCHandler(map[string]*auth.OIDCClient{"test": client})
router := mountOIDCHandler(t, handler)
req := httptest.NewRequest(http.MethodGet, "/oidc/test/callback", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
// TestOIDCHandler_Callback_RejectsUnknownState tests that callback with unknown state returns 401.
func TestOIDCHandler_Callback_RejectsUnknownState(t *testing.T) {
client := auth.NewOIDCClient("http://mock-provider", "test-id", "test-secret")
handler := newTestOIDCHandler(map[string]*auth.OIDCClient{"test": client})
router := mountOIDCHandler(t, handler)
req := httptest.NewRequest(http.MethodGet, "/oidc/test/callback?state=unknown&code=any", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
// TestOIDCHandler_Start_RedirectsWithPKCE tests that starting with a valid provider redirects with PKCE.
func TestOIDCHandler_Start_RedirectsWithPKCE(t *testing.T) {
// Setup mock OIDC provider
mockServer := setupMockOIDCProvider(t)
defer mockServer.Close()
// Create OIDC client pointing to mock server
client := auth.NewOIDCClient(mockServer.URL, "test-id", "test-secret")
// Set a custom HTTP client that can reach the mock server
client.SetHTTPClient(mockServer.Client())
handler := newTestOIDCHandler(map[string]*auth.OIDCClient{"test": client})
router := mountOIDCHandler(t, handler)
req := httptest.NewRequest(http.MethodGet, "/oidc/test/start", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Assert 302 redirect
assert.Equal(t, http.StatusFound, rr.Code)
// Get Location header
location := rr.Header().Get("Location")
assert.NotEmpty(t, location)
// Location should start with the mock auth endpoint
expectedAuthEndpoint := mockServer.URL + "/auth"
assert.Contains(t, location, expectedAuthEndpoint)
// Location should contain code_challenge and state
assert.Contains(t, location, "code_challenge=")
assert.Contains(t, location, "state=")
assert.Contains(t, location, "response_type=code")
assert.Contains(t, location, "client_id=test-id")
assert.Contains(t, location, "code_challenge_method=S256")
}
// Ensure the interfaces are satisfied at compile time
var _ user.UserService = (*fakeUserSvc)(nil)
var _ user.UserRepository = (*fakeUserRepo)(nil)

View File

@@ -0,0 +1,56 @@
package user
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
// MagicLinkCleanupRunner periodically deletes expired magic-link tokens
// (ADR-0028 Phase A consequence — the rows accumulate without cleanup
// otherwise, and stale rows are pure overhead since the token plaintext
// is never stored).
type MagicLinkCleanupRunner struct {
repo MagicLinkRepository
}
// NewMagicLinkCleanupRunner creates a new cleanup runner.
func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner {
return &MagicLinkCleanupRunner{repo: repo}
}
// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx
// is cancelled. interval <= 0 disables the loop.
func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = r.runOnce(ctx)
}
}
}()
}
// runOnce performs a single cleanup pass. Returns the count of deleted rows.
// Exposed for testing — tests drive runOnce directly instead of waiting on
// the ticker.
func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) {
n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now())
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed")
return 0, err
}
if n > 0 {
log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens")
}
return n, nil
}

View File

@@ -0,0 +1,64 @@
package user
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeMLRepo struct {
deleteN int64
deleteErr error
cutoffSeen time.Time
}
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil }
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) {
return nil, nil
}
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error {
return nil
}
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) {
r.cutoffSeen = before
return r.deleteN, r.deleteErr
}
func TestRunOnce_ReturnsCount(t *testing.T) {
repo := &fakeMLRepo{deleteN: 7}
r := NewMagicLinkCleanupRunner(repo)
n, err := r.runOnce(context.Background())
require.NoError(t, err)
assert.EqualValues(t, 7, n)
assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second)
}
func TestRunOnce_PropagatesError(t *testing.T) {
repo := &fakeMLRepo{deleteErr: errors.New("simulated")}
r := NewMagicLinkCleanupRunner(repo)
_, err := r.runOnce(context.Background())
require.Error(t, err)
}
func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
ctx, cancel := context.WithCancel(context.Background())
r.StartCleanupLoop(ctx, 10*time.Millisecond)
time.Sleep(25 * time.Millisecond) // 2 ticks
cancel()
time.Sleep(15 * time.Millisecond) // give the goroutine time to exit
// Implicit assertion: no goroutine leak (test would hang in -race mode otherwise).
}
func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
r.StartCleanupLoop(context.Background(), 0)
// Just make sure no goroutine is started ; nothing observable to assert
// beyond "no panic, returns immediately".
}