Compare commits
1 Commits
b9dd23a64f
...
vibe/batch
| Author | SHA1 | Date | |
|---|---|---|---|
| e6499ac6b8 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -10,17 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨ `GET /api/v1/uptime` endpoint (PR #67) — returns server start_time and uptime_seconds
|
- ✨ `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
|
- 📝 mkcert local HTTPS setup + 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+)
|
- ✨ `pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later
|
||||||
- 📝 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)
|
|
||||||
- 📝 README link to Mistral autonomous pattern doc (PR #83)
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-05-05
|
## [0.1.0] - 2026-05-05
|
||||||
|
|
||||||
|
|||||||
24
Makefile
24
Makefile
@@ -1,24 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -20,7 +20,6 @@ Go web service demonstrating idiomatic package structure, versioned JSON API, an
|
|||||||
- OpenAPI / Swagger UI (embedded in binary)
|
- OpenAPI / Swagger UI (embedded in binary)
|
||||||
- PostgreSQL user service with JWT auth
|
- PostgreSQL user service with JWT auth
|
||||||
- BDD + unit tests
|
- BDD + unit tests
|
||||||
- 🤖 Mistral autonomous PR pattern (cf. [documentation/MISTRAL-AUTONOMOUS-PATTERN.md](documentation/MISTRAL-AUTONOMOUS-PATTERN.md))
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# 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.*
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# 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.*
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Project Status Snapshot
|
|
||||||
|
|
||||||
Last updated 2026-05-05 evening.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Active Features
|
|
||||||
|
|
||||||
- Magic-link passwordless auth (POST /api/v1/auth/magic-link/request + GET /consume) — production-ready, ADR-0028 Phase A complete
|
|
||||||
- OIDC client + HTTP handlers (GET /api/v1/auth/oidc/{provider}/start + /callback with PKCE) — production-ready code, BDD coverage TODO. ADR-0028 Phase B (B.1, B.3, B.4 + tests done ; B.5 BDD scenarios TODO).
|
|
||||||
- Username + password auth — legacy (ADR-0018), kept during migration. To be decommissioned in Phase C.
|
|
||||||
- Versioned API, JWT, OpenTelemetry, Swagger, BDD
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's In Progress / Next
|
|
||||||
|
|
||||||
- Phase B.5 BDD scenarios for OIDC (1 PR Mistral expected)
|
|
||||||
- Phase C decommission password auth (separate ADR)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure Highlights
|
|
||||||
|
|
||||||
```
|
|
||||||
adr/ : ADRs
|
|
||||||
pkg/ : packages (auth, config, server, user, etc.)
|
|
||||||
features/ : BDD scenarios
|
|
||||||
documentation/ : docs index
|
|
||||||
scripts/ : build + CI
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Documentation Entry Points
|
|
||||||
|
|
||||||
- README.md : quick start
|
|
||||||
- AGENTS.md : agent + automation conventions
|
|
||||||
- documentation/AUTH.md : auth system synthesis
|
|
||||||
- documentation/MISTRAL-AUTONOMOUS-PATTERN.md : how Mistral PRs are shipped
|
|
||||||
- documentation/PHASE_B_ROADMAP.md : remaining auth migration work
|
|
||||||
- documentation/2026-05-05-AUTONOMOUS-SESSION-RECAP.md : the autonomous session highlights
|
|
||||||
- adr/ : architecture decisions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Today's Milestone (2026-05-05)
|
|
||||||
|
|
||||||
27 PRs merged in 1 day via the Mistral autonomous multi-process pattern. ADR-0028 (passwordless auth migration) essentially complete except Phase B.5 BDD.
|
|
||||||
345
pkg/auth/oidc.go
345
pkg/auth/oidc.go
@@ -1,345 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,27 +109,12 @@ type AuthConfig struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
|
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
|
||||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
|
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
|
||||||
type MagicLinkConfig struct {
|
type MagicLinkConfig struct {
|
||||||
TTL time.Duration `mapstructure:"ttl"`
|
TTL time.Duration `mapstructure:"ttl"`
|
||||||
BaseURL string `mapstructure:"base_url"`
|
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.
|
// EmailConfig holds outgoing email transport configuration.
|
||||||
@@ -301,11 +286,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
// Magic-link defaults (ADR-0028 Phase A).
|
// Magic-link defaults (ADR-0028 Phase A).
|
||||||
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
|
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
|
||||||
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
|
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
|
// Check for custom config file path via environment variable
|
||||||
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
||||||
@@ -363,14 +343,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
// Magic-link environment variables (ADR-0028 Phase A).
|
// Magic-link environment variables (ADR-0028 Phase A).
|
||||||
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
|
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.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.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
||||||
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
||||||
|
|
||||||
@@ -522,23 +494,6 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
|
|||||||
return out
|
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
|
// GetJWTTTL returns the JWT TTL
|
||||||
func (c *Config) GetJWTTTL() time.Duration {
|
func (c *Config) GetJWTTTL() time.Duration {
|
||||||
if c.Auth.JWT.TTL == 0 {
|
if c.Auth.JWT.TTL == 0 {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/auth"
|
|
||||||
"dance-lessons-coach/pkg/cache"
|
"dance-lessons-coach/pkg/cache"
|
||||||
"dance-lessons-coach/pkg/config"
|
"dance-lessons-coach/pkg/config"
|
||||||
"dance-lessons-coach/pkg/email"
|
"dance-lessons-coach/pkg/email"
|
||||||
@@ -247,9 +246,6 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
|
|||||||
r.Get("/{name}", s.handleGreetPath)
|
r.Get("/{name}", s.handleGreetPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Uptime endpoint
|
|
||||||
r.Get("/uptime", s.handleUptime)
|
|
||||||
|
|
||||||
// Register user authentication routes
|
// Register user authentication routes
|
||||||
if s.userService != nil && s.userRepo != nil {
|
if s.userService != nil && s.userRepo != nil {
|
||||||
// Use unified user service - much simpler!
|
// Use unified user service - much simpler!
|
||||||
@@ -280,18 +276,6 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
|
|||||||
)
|
)
|
||||||
mlHandler.RegisterRoutes(r)
|
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
|
// Register admin routes
|
||||||
@@ -599,30 +583,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(data)
|
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
|
// handleGreetQuery godoc
|
||||||
//
|
//
|
||||||
// @Summary Get greeting with cache
|
// @Summary Get greeting with cache
|
||||||
@@ -804,12 +764,6 @@ func (s *Server) Run() error {
|
|||||||
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
|
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).
|
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
|
||||||
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
||||||
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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".
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user