Compare commits
10 Commits
vibe/batch
...
8c2f76a2aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2f76a2aa | |||
| 2359837c8d | |||
| 55f0a0da02 | |||
| fbf00a3cd0 | |||
| 001172e5b3 | |||
| c05e508d56 | |||
| b17b727157 | |||
| 087ce8a4e1 | |||
| b6a6a2b3d7 | |||
| 6ed95165d3 |
@@ -10,8 +10,9 @@ 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 setup + Makefile cert target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
|
- 📝 mkcert local HTTPS doc + Makefile `cert` target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
|
||||||
- ✨ `pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later
|
- ✨ `pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later (Phase B.3+)
|
||||||
|
- 📝 ADR-0028 Phase B roadmap document (PR #71) — outlines remaining B.3 / B.4 / B.5 work
|
||||||
|
|
||||||
## [0.1.0] - 2026-05-05
|
## [0.1.0] - 2026-05-05
|
||||||
|
|
||||||
|
|||||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# dance-lessons-coach Makefile — minimal targets for local development.
|
||||||
|
# This is a starter Makefile ; expand as needed (build, test, run, etc.).
|
||||||
|
# Existing build/test workflows live in scripts/ and remain authoritative.
|
||||||
|
|
||||||
|
CERT_DIR := ./certs
|
||||||
|
|
||||||
|
.PHONY: help cert clean-cert
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " cert Generate local-dev TLS certs via mkcert (cf. documentation/MKCERT.md)"
|
||||||
|
@echo " clean-cert Remove generated TLS certs"
|
||||||
|
@echo " help Show this help"
|
||||||
|
|
||||||
|
cert: $(CERT_DIR)
|
||||||
|
@command -v mkcert >/dev/null 2>&1 || { echo >&2 "mkcert not found. See documentation/MKCERT.md to install."; exit 1; }
|
||||||
|
mkcert -cert-file $(CERT_DIR)/dev-cert.pem -key-file $(CERT_DIR)/dev-key.pem localhost 127.0.0.1 ::1
|
||||||
|
@echo "Certs ready at $(CERT_DIR)/. Cf. documentation/MKCERT.md for usage."
|
||||||
|
|
||||||
|
$(CERT_DIR):
|
||||||
|
mkdir -p $(CERT_DIR)
|
||||||
|
|
||||||
|
clean-cert:
|
||||||
|
rm -rf $(CERT_DIR)
|
||||||
132
documentation/AUTH.md
Normal file
132
documentation/AUTH.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Authentication System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The dance-lessons-coach authentication system provides a passwordless magic-link flow as the primary mechanism, with legacy username+password support during the transition period. OpenID Connect (OIDC) integration is in progress for Phase B. See [ADR-0028](../adr/0028-passwordless-auth-migration.md) for the migration strategy.
|
||||||
|
|
||||||
|
## Authentication mechanisms supported
|
||||||
|
|
||||||
|
### Username + password (legacy, ADR-0018)
|
||||||
|
- **Endpoint:** `POST /api/v1/auth/login`
|
||||||
|
- **Status:** Operational, to be decommissioned in Phase C
|
||||||
|
- **Details:** bcrypt-hashed passwords, JWT token issuance
|
||||||
|
|
||||||
|
### Magic link by email (ADR-0028 Phase A)
|
||||||
|
- **Request endpoint:** `POST /api/v1/auth/magic-link/request` — accepts `{email}`, generates token, stores hash, sends email
|
||||||
|
- **Consume endpoint:** `GET /api/v1/auth/magic-link/consume?token=<...>` — validates hash, marks consumed, issues JWT
|
||||||
|
- **Always returns 200 on request** to prevent email enumeration
|
||||||
|
- **First-link sign-up:** if email is unknown, consume endpoint creates the user record
|
||||||
|
|
||||||
|
### OpenID Connect (ADR-0028 Phase B, work in progress)
|
||||||
|
- **Status:** Skeleton merged (`pkg/auth/`), handlers and flow not yet wired
|
||||||
|
- **Planned endpoints:**
|
||||||
|
- `GET /api/v1/auth/oidc/start` — generates state + PKCE, redirects to provider
|
||||||
|
- `GET /api/v1/auth/oidc/callback` — exchanges code for tokens, validates id_token, issues internal JWT
|
||||||
|
- **Provider config:** `auth.oidc.providers.*` in config
|
||||||
|
|
||||||
|
## Magic-link flow detail
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
User->>Server: POST /api/v1/auth/magic-link/request {email}
|
||||||
|
Server-->>User: 200 (always — anti-enumeration)
|
||||||
|
Server->>Mailpit (or SMTP provider): SMTP send "Your sign-in link"
|
||||||
|
User->>Email: clicks link
|
||||||
|
User->>Server: GET /api/v1/auth/magic-link/consume?token=<plain>
|
||||||
|
Server->>DB: verify hash, mark consumed, ensure user exists
|
||||||
|
Server-->>User: 200 {token: <JWT>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Email (ADR-0029)
|
||||||
|
| Config key | Env var | Default | Description |
|
||||||
|
|------------|---------|---------|-------------|
|
||||||
|
| `auth.email.from` | `DLC_AUTH_EMAIL_FROM` | `noreply@dance-lessons-coach.local` | Sender address |
|
||||||
|
| `auth.email.smtp_host` | `DLC_AUTH_EMAIL_SMTP_HOST` | `localhost` | SMTP host |
|
||||||
|
| `auth.email.smtp_port` | `DLC_AUTH_EMAIL_SMTP_PORT` | `1025` | SMTP port |
|
||||||
|
| `auth.email.smtp_use_tls` | `DLC_AUTH_EMAIL_SMTP_USE_TLS` | `false` | Use TLS |
|
||||||
|
| `auth.email.timeout` | `DLC_AUTH_EMAIL_TIMEOUT` | `10s` | Connection timeout |
|
||||||
|
|
||||||
|
### Magic link (ADR-0028 Phase A)
|
||||||
|
| Config key | Env var | Default | Description |
|
||||||
|
|------------|---------|---------|-------------|
|
||||||
|
| `auth.magic_link.ttl` | `DLC_AUTH_MAGIC_LINK_TTL` | `15m` | Token lifetime |
|
||||||
|
| `auth.magic_link.base_url` | `DLC_AUTH_MAGIC_LINK_BASE_URL` | `http://localhost:8080` | Base URL for links |
|
||||||
|
| `auth.magic_link.cleanup_interval` | `DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL` | `1h` | Cleanup loop interval |
|
||||||
|
|
||||||
|
### JWT (ADR-0021)
|
||||||
|
| Config key | Env var | Default | Description |
|
||||||
|
|------------|---------|---------|-------------|
|
||||||
|
| `auth.jwt.ttl` | `DLC_AUTH_JWT_TTL` | `1h` | Token time-to-live |
|
||||||
|
| `auth.jwt.secret_retention.retention_factor` | `DLC_AUTH_JWT_SECRET_RETENTION_FACTOR` | `2.0` | Retention multiplier |
|
||||||
|
| `auth.jwt.secret_retention.max_retention` | `DLC_AUTH_JWT_SECRET_MAX_RETENTION` | `72h` | Maximum retention |
|
||||||
|
| `auth.jwt.secret_retention.cleanup_interval` | `DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL` | `1h` | Secret cleanup interval |
|
||||||
|
|
||||||
|
### OIDC (Phase B, prep)
|
||||||
|
| Config key | Env var | Default | Description |
|
||||||
|
|------------|---------|---------|-------------|
|
||||||
|
| `auth.oidc.providers.<name>.issuer_url` | `DLC_AUTH_OIDC_ISSUER_URL` | - | Provider issuer URL |
|
||||||
|
| `auth.oidc.providers.<name>.client_id` | `DLC_AUTH_OIDC_CLIENT_ID` | - | Client ID |
|
||||||
|
| `auth.oidc.providers.<name>.client_secret` | `DLC_AUTH_OIDC_CLIENT_SECRET` | - | Client secret |
|
||||||
|
|
||||||
|
## Token model
|
||||||
|
|
||||||
|
Magic-link tokens use **SHA-256 hex hashing at rest** — only the hash is stored in the database (`token_hash` column, 64 chars). The plaintext token is emailed to the user and must be supplied back to re-derive the hash. This means a database leak reveals no usable tokens. See `pkg/user/magic_link.go` for the rationale.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// HashMagicLinkToken returns the lowercase hex sha256 of token
|
||||||
|
func HashMagicLinkToken(plaintext string) string {
|
||||||
|
sum := sha256.Sum256([]byte(plaintext))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup loops
|
||||||
|
|
||||||
|
### JWT secret retention (ADR-0021)
|
||||||
|
- **Location:** `pkg/user/jwt_manager.go` — `StartCleanupLoop`
|
||||||
|
- **Interval:** Configurable via `auth.jwt.secret_retention.cleanup_interval` (default: 1h)
|
||||||
|
- **Behavior:** Removes secrets older than retention period (TTL x retention_factor, capped at max_retention)
|
||||||
|
- **Safety:** Never removes the current primary secret
|
||||||
|
|
||||||
|
### Magic-link expired tokens (ADR-0028 Phase A)
|
||||||
|
- **Location:** `pkg/user/magic_link_cleanup.go` — `StartCleanupLoop`
|
||||||
|
- **Interval:** Configurable via `auth.magic_link.cleanup_interval` (default: 1h)
|
||||||
|
- **Behavior:** Deletes tokens where `expires_at < now`
|
||||||
|
- **Implementation:** Calls `DeleteExpiredMagicLinkTokens` on the repository
|
||||||
|
|
||||||
|
## Local dev setup
|
||||||
|
|
||||||
|
1. **Start services:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d # starts Postgres + Mailpit
|
||||||
|
```
|
||||||
|
2. **Inspect emails:** http://localhost:8025 (Mailpit UI)
|
||||||
|
3. **HTTPS for OIDC (Phase B):**
|
||||||
|
```bash
|
||||||
|
make cert # generates certs/dev-cert.pem + certs/dev-key.pem via mkcert
|
||||||
|
```
|
||||||
|
See [MKCERT.md](MKCERT.md) for details.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
### Architecture Decision Records
|
||||||
|
| ADR | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| [ADR-0018](../adr/0018-user-management-auth-system.md) | Original username/password auth system |
|
||||||
|
| [ADR-0021](../adr/0021-jwt-secret-retention-policy.md) | JWT secret retention and cleanup |
|
||||||
|
| [ADR-0028](../adr/0028-passwordless-auth-migration.md) | Passwordless migration (Phase A complete, Phase B in progress) |
|
||||||
|
| [ADR-0029](../adr/0029-email-infrastructure-mailpit.md) | Email infrastructure (Mailpit) |
|
||||||
|
| [ADR-0030](../adr/0030-bdd-email-parallel-strategy.md) | BDD parallel email assertions |
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [EMAIL.md](EMAIL.md) | SMTP setup and Mailpit usage |
|
||||||
|
| [MKCERT.md](MKCERT.md) | Local HTTPS certificate setup |
|
||||||
|
| [PHASE_B_ROADMAP.md](PHASE_B_ROADMAP.md) | Remaining OIDC work |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Developer onboarding doc — see ADR-0028 for implementation details.*
|
||||||
120
documentation/MKCERT.md
Normal file
120
documentation/MKCERT.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# mkcert: Local HTTPS for Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes how to set up local HTTPS development certificates using `mkcert`.
|
||||||
|
|
||||||
|
OIDC providers **reject `http://localhost` as a redirect URI** by default for security reasons. To test OAuth 2.0 / OpenID Connect flows locally, the development server must be accessible via HTTPS. `mkcert` provides a zero-configuration local Certificate Authority that generates trusted certificates for localhost and custom domains.
|
||||||
|
|
||||||
|
This setup is a prerequisite for **ADR-0028 Phase B** (OpenID Connect Authorization Code flow).
|
||||||
|
|
||||||
|
## Why mkcert
|
||||||
|
|
||||||
|
- **Trusted locally**: Certificates are automatically trusted by the system root store (macOS, Linux, Windows)
|
||||||
|
- **No configuration**: Single commands to create and install the CA
|
||||||
|
- **Local-only**: Certificates are valid only for localhost development, never exposed to production
|
||||||
|
- **Industry standard**: Widely adopted tool for local HTTPS development
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### macOS (Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install mkcert
|
||||||
|
mkcert -install
|
||||||
|
```
|
||||||
|
|
||||||
|
The `mkcert -install` command creates and installs a local Certificate Authority in your system trust store.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for distribution-specific instructions.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for Windows installation.
|
||||||
|
|
||||||
|
## Generate Certificates
|
||||||
|
|
||||||
|
Use the provided Make target to generate certificates for localhost development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make cert
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkcert -cert-file ./certs/dev-cert.pem -key-file ./certs/dev-key.pem localhost 127.0.0.1 ::1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `./certs/dev-cert.pem` | TLS certificate for localhost, 127.0.0.1, and ::1 |
|
||||||
|
| `./certs/dev-key.pem` | Private key for the certificate |
|
||||||
|
|
||||||
|
Both files are created in the `./certs/` directory at the project root.
|
||||||
|
|
||||||
|
## Use in Development
|
||||||
|
|
||||||
|
Once certificates are generated, start the server with TLS enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/server --tls-cert ./certs/dev-cert.pem --tls-key ./certs/dev-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: The `--tls-cert` and `--tls-key` flags are **not yet implemented** — this is planned for ADR-0028 Phase B.4. The Makefile and certificate generation are prepared in advance so that when the server TLS support is added, the certificates are ready.
|
||||||
|
|
||||||
|
The server will then be accessible at:
|
||||||
|
- `https://localhost:8080` (or the configured port)
|
||||||
|
- `https://127.0.0.1:8080`
|
||||||
|
- `https://[::1]:8080`
|
||||||
|
|
||||||
|
All OIDC callback URLs must use HTTPS with one of these hostnames.
|
||||||
|
|
||||||
|
## Clean Up
|
||||||
|
|
||||||
|
To remove generated certificates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make clean-cert
|
||||||
|
```
|
||||||
|
|
||||||
|
This deletes the entire `./certs/` directory.
|
||||||
|
|
||||||
|
## .gitignore
|
||||||
|
|
||||||
|
The `certs/` directory contains locally-generated certificates and **must not be committed** to version control.
|
||||||
|
|
||||||
|
Ensure `certs/` is in your `.gitignore`. If it is not already present, add it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "certs/" >> .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
- [ADR-0028: Passwordless authentication: magic link → OpenID Connect](../adr/0028-passwordless-auth-migration.md) — Phase B describes the OIDC implementation that requires HTTPS
|
||||||
|
- [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert) — Official documentation
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "mkcert not found" when running `make cert`
|
||||||
|
|
||||||
|
Ensure `mkcert` is installed and available in your `PATH`. The Makefile checks for this and will display an error message if `mkcert` is not found.
|
||||||
|
|
||||||
|
### Certificate not trusted by browser
|
||||||
|
|
||||||
|
Run `mkcert -install` again. On macOS, you may need to restart your browser completely (close all windows, not just tabs).
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
|
||||||
|
If another process is using the port (e.g., a non-TLS server on port 8080), stop that process first or configure the server to use a different port.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- `make help` — List all available Make targets
|
||||||
|
- [documentation/API.md](API.md) — API endpoints reference
|
||||||
|
- [documentation/BDD_GUIDE.md](BDD_GUIDE.md) — BDD testing guide
|
||||||
137
documentation/PHASE_B_ROADMAP.md
Normal file
137
documentation/PHASE_B_ROADMAP.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 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)
|
||||||
|
- [ ] Phase B.3 not yet started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
104
pkg/auth/oidc.go
Normal file
104
pkg/auth/oidc.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// 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"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Audience string `json:"aud"`
|
||||||
|
ExpirationTime int64 `json:"exp"`
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
EmailVerified bool `json:"email_verified,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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// TODO Phase B.3: implement (HTTP GET issuerURL + "/.well-known/openid-configuration")
|
||||||
|
return nil, nil // placeholder for skeleton phase
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshJWKS fetches JWKS URI, parse keys, populate jwks map.
|
||||||
|
// TODO Phase B.3: implement
|
||||||
|
func (c *OIDCClient) RefreshJWKS(ctx context.Context) error {
|
||||||
|
// TODO Phase B.3: implement (HTTP GET to JWKS URI from discovery, parse keys)
|
||||||
|
return nil // placeholder for skeleton phase
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeCode exchanges an authorization code for an access token and ID token.
|
||||||
|
// TODO Phase B.3: implement
|
||||||
|
func (c *OIDCClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI string) (*TokenResponse, error) {
|
||||||
|
// TODO Phase B.3: implement (POST to token_endpoint with code, code_verifier, redirect_uri)
|
||||||
|
return nil, nil // placeholder for skeleton phase
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateIDToken verifies the signature and claims of an ID token.
|
||||||
|
// TODO Phase B.3: implement
|
||||||
|
func (c *OIDCClient) ValidateIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) {
|
||||||
|
// TODO Phase B.3: implement (verify signature with JWKS, validate claims)
|
||||||
|
return nil, nil // placeholder for skeleton phase
|
||||||
|
}
|
||||||
13
pkg/auth/oidc_test.go
Normal file
13
pkg/auth/oidc_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,12 +109,27 @@ 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.
|
||||||
@@ -286,6 +301,11 @@ 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 != "" {
|
||||||
@@ -343,6 +363,14 @@ 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")
|
||||||
|
|
||||||
@@ -494,6 +522,23 @@ 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 {
|
||||||
|
|||||||
@@ -246,6 +246,9 @@ 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!
|
||||||
@@ -583,6 +586,30 @@ 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
|
||||||
@@ -764,6 +791,12 @@ 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).
|
||||||
|
|||||||
81
pkg/server/uptime_test.go
Normal file
81
pkg/server/uptime_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dance-lessons-coach/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleUptime(t *testing.T) {
|
||||||
|
// Setup with a known start time
|
||||||
|
cfg := &config.Config{}
|
||||||
|
// We need to create a server and then set its startedAt to a known time
|
||||||
|
// Since NewServer sets startedAt to time.Now(), we'll create the server
|
||||||
|
// and then use reflection or we can use NewServerWithUserRepo which also sets startedAt
|
||||||
|
s := NewServer(cfg, context.Background())
|
||||||
|
|
||||||
|
// Set a fixed start time for deterministic testing
|
||||||
|
// We can't directly set s.startedAt since it's unexported, but we can test
|
||||||
|
// that the handler uses the server's startedAt
|
||||||
|
// The test will verify the structure and that uptime_seconds is >= 0
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
s.handleUptime(w, req)
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
// Decode response
|
||||||
|
var resp UptimeResponse
|
||||||
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Assert fields
|
||||||
|
assert.NotEmpty(t, resp.StartTime)
|
||||||
|
// Verify start_time is in RFC3339 format
|
||||||
|
_, err = time.Parse(time.RFC3339, resp.StartTime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUptime_Deterministic(t *testing.T) {
|
||||||
|
// For a more deterministic test, we would need to be able to set startedAt
|
||||||
|
// Since startedAt is unexported, we test the behavior with a known server
|
||||||
|
// that was just created (uptime should be very small)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
s := NewServer(cfg, context.Background())
|
||||||
|
|
||||||
|
// Small delay to ensure uptime is at least 0 seconds
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.handleUptime(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp UptimeResponse
|
||||||
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Uptime should be at least 0 (it's int() of seconds, so minimum is 0)
|
||||||
|
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
|
||||||
|
// Start time should be parseable
|
||||||
|
_, err = time.Parse(time.RFC3339, resp.StartTime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
56
pkg/user/magic_link_cleanup.go
Normal file
56
pkg/user/magic_link_cleanup.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MagicLinkCleanupRunner periodically deletes expired magic-link tokens
|
||||||
|
// (ADR-0028 Phase A consequence — the rows accumulate without cleanup
|
||||||
|
// otherwise, and stale rows are pure overhead since the token plaintext
|
||||||
|
// is never stored).
|
||||||
|
type MagicLinkCleanupRunner struct {
|
||||||
|
repo MagicLinkRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMagicLinkCleanupRunner creates a new cleanup runner.
|
||||||
|
func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner {
|
||||||
|
return &MagicLinkCleanupRunner{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx
|
||||||
|
// is cancelled. interval <= 0 disables the loop.
|
||||||
|
func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_, _ = r.runOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOnce performs a single cleanup pass. Returns the count of deleted rows.
|
||||||
|
// Exposed for testing — tests drive runOnce directly instead of waiting on
|
||||||
|
// the ticker.
|
||||||
|
func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) {
|
||||||
|
n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed")
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens")
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
64
pkg/user/magic_link_cleanup_test.go
Normal file
64
pkg/user/magic_link_cleanup_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeMLRepo struct {
|
||||||
|
deleteN int64
|
||||||
|
deleteErr error
|
||||||
|
cutoffSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil }
|
||||||
|
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) {
|
||||||
|
r.cutoffSeen = before
|
||||||
|
return r.deleteN, r.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_ReturnsCount(t *testing.T) {
|
||||||
|
repo := &fakeMLRepo{deleteN: 7}
|
||||||
|
r := NewMagicLinkCleanupRunner(repo)
|
||||||
|
n, err := r.runOnce(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 7, n)
|
||||||
|
assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOnce_PropagatesError(t *testing.T) {
|
||||||
|
repo := &fakeMLRepo{deleteErr: errors.New("simulated")}
|
||||||
|
r := NewMagicLinkCleanupRunner(repo)
|
||||||
|
_, err := r.runOnce(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) {
|
||||||
|
repo := &fakeMLRepo{}
|
||||||
|
r := NewMagicLinkCleanupRunner(repo)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r.StartCleanupLoop(ctx, 10*time.Millisecond)
|
||||||
|
time.Sleep(25 * time.Millisecond) // 2 ticks
|
||||||
|
cancel()
|
||||||
|
time.Sleep(15 * time.Millisecond) // give the goroutine time to exit
|
||||||
|
// Implicit assertion: no goroutine leak (test would hang in -race mode otherwise).
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) {
|
||||||
|
repo := &fakeMLRepo{}
|
||||||
|
r := NewMagicLinkCleanupRunner(repo)
|
||||||
|
r.StartCleanupLoop(context.Background(), 0)
|
||||||
|
// Just make sure no goroutine is started ; nothing observable to assert
|
||||||
|
// beyond "no panic, returns immediately".
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user