Files
dance-lessons-coach/adr/0028-passwordless-auth-migration.md
Gabriel Radureau 873f449d17 📝 docs(adr): ADR-0028/0029/0030 — passwordless auth + Mailpit + BDD email strategy
Three coordinated ADRs Proposed for the auth-completion sprint, requested
by user: signup → magic link by email → OpenID Connect Authorization
Code with PKCE, all integrated with Mailpit (already locally available
as docker image) and BDD parallel testing strategy.

ADR-0028 — Passwordless auth migration (sequenced):
- Phase A: magic link by email (no password storage, JWT issued on consume)
- Phase B: OpenID Connect Code flow with PKCE (cross-product SSO, mkcert
  for local HTTPS callbacks)
- Phase C (separate ADR later): decommission password auth

ADR-0029 — Email infra:
- Mailpit (axllent/mailpit:latest) for local dev + BDD (image already
  pulled, 51 MB), defaults SMTP :1025 / HTTP API :8025
- pkg/email.Sender interface for provider portability
- Production sender choice DEFERRED (separate ADR when volume / SLA
  / compliance requirements known — likely AWS SES or Postmark)

ADR-0030 — BDD email parallel strategy:
- Per-test recipient scoping: each scenario generates a unique address
  <scenario-key>-<8hex>@bdd.local
- Mailpit HTTP API filters by recipient → no cross-scenario interference
- pkg/bdd/mailpit/ helper package + pkg/bdd/steps/email_steps.go
- Preserves the 2.85x parallel BDD speedup from PR #35

Implementation lands in subsequent PRs ; today only the design is shipped.
README index updated with 3 new entries (0028/0029/0030 all Proposed).
2026-05-05 10:42:19 +02:00

148 lines
8.9 KiB
Markdown

# 28. Passwordless authentication: magic link → OpenID Connect
**Date:** 2026-05-05
**Status:** Proposed
**Authors:** Gabriel Radureau, AI Agent
## Context and Problem Statement
ADR-0018 (now Implemented) shipped a username + password authentication system with bcrypt hashing, JWT tokens, admin master password, and admin-assisted password reset. It works, but it carries the cost-of-passwords : we store password hashes, support password reset flows, and maintain a credential-rotation policy. Users hate passwords ; ops and security pay for them.
Two industry-standard alternatives exist :
1. **Magic link by email** — user enters their email, receives a one-time token in a clickable link, link consumes the token and issues a session JWT. No password stored.
2. **OpenID Connect Authorization Code flow** — delegate authentication to an external Identity Provider (e.g. Authelia, Keycloak, Auth0, Google) ; our app receives an `id_token` after the OIDC dance.
We want to **migrate to passwordless** for new sign-ups while keeping the existing username/password code path operational during the transition (no flag-day breakage). The two passwordless mechanisms above complement each other : magic link is simpler for first-party users on day 1 ; OIDC is the right answer for second-party users (other ARCODANGE products, partner integrations) and for admin SSO.
A third constraint : ARCODANGE local development must use HTTPS for OAuth callbacks to be valid (most OIDC providers reject `http://localhost` redirect URIs in their default config). `mkcert` is the canonical local-CA tool for this.
## Decision Drivers
* **Reduce password-related attack surface** — no hash storage, no breach-and-reuse risk, no password reset abuse vectors
* **User experience** — passwordless is faster for the user (1 click in email vs typing/remembering password)
* **Operational simplicity** — no password reset flow to maintain ; the password-reset code can be removed once migration is complete
* **Multi-product readiness** — OIDC is the prerequisite for cross-product SSO across the ARCODANGE portfolio
* **Backwards compatibility** — must not break existing tokens or BDD scenarios mid-migration
* **Local dev parity** — HTTPS in dev so OAuth flows can be tested locally without provider-specific workarounds
## Considered Options
### Option 1 (Chosen): Sequenced — magic link first, OIDC second
Deliver in two phases :
* **Phase A — Magic link**
- Add `POST /api/v1/auth/magic-link/request` (body: `{email}`) — generates token, stores it (TTL ~15 min), sends email via SMTP
- Add `GET /api/v1/auth/magic-link/consume?token=<...>` — single-use consumption, issues a JWT, returns it as cookie + JSON body
- Reuse the existing JWT issuance + secret retention infrastructure (ADR-0021)
- Existing `/api/v1/auth/login` (username/password) stays operational during transition
* **Phase B — OpenID Connect Authorization Code with PKCE**
- Add `GET /api/v1/auth/oidc/start` — generates state + PKCE verifier, redirects to provider's `authorization_endpoint`
- Add `GET /api/v1/auth/oidc/callback` — exchanges code for tokens, validates `id_token` signature against provider's JWKS, issues internal JWT
- Provider URL configurable per environment (`auth.oidc.issuer_url`, `auth.oidc.client_id`, `auth.oidc.client_secret`)
- Allow multiple providers in config (key by provider name, e.g. `arcodange-sso`)
- Local dev requires HTTPS — `mkcert` setup documented in `documentation/DEV_SETUP.md`
* **Phase C (later, separate ADR) — Decommission password auth**
- Once all users have migrated, remove the password endpoints, remove the password_hash column, mark ADR-0018 as Superseded by this ADR
### Option 2: All-at-once OIDC, no magic link
Skip magic link, jump straight to OIDC.
* Good — single migration, no intermediate state
* Bad — requires an OIDC provider operational on day 1, which we don't have configured
* Bad — magic link has zero infra dependencies (just SMTP) ; OIDC requires running an IdP or paying for one
### Option 3: Magic link only, no OIDC
Stop at Phase A.
* Good — simplest implementation
* Bad — doesn't solve cross-product SSO ; we'd re-do this work later for the broader ARCODANGE portfolio
### Option 4: Status quo (do nothing)
Keep username + password.
* Good — zero effort
* Bad — passwords stay forever ; ARCODANGE locks itself out of integration scenarios that expect OIDC
## Decision Outcome
Chosen option : **Option 1, sequenced magic link → OIDC**.
Rationale :
- Magic link is implementable today with zero infra dependencies beyond the email infrastructure (ADR-0029)
- OIDC requires running an IdP locally (Authelia or Keycloak) — that's another container in the dev stack and another ADR's worth of decision work, but the magic-link work is the natural prerequisite (token-by-email plumbing is reused)
- Sequenced delivery means we never have to roll back : Phase A works alone, Phase B layers on top, Phase C cleans up
## Implementation Plan
### Phase A — Magic link (target: 2-3 PRs)
1. **A.1 — Storage** : add a `magic_link_tokens` table (id, email, token_hash, expires_at, consumed_at). Repository pattern alongside `pkg/user/postgres_repository.go`.
2. **A.2 — Token endpoint** : `POST /api/v1/auth/magic-link/request` generates a token, stores it (hashed), enqueues an email send. Rate-limited (cf. ADR-0022) by email address.
3. **A.3 — Consume endpoint** : `GET /api/v1/auth/magic-link/consume?token=...` validates + marks consumed + issues JWT. Returns `Set-Cookie` and `{token: jwt}` body.
4. **A.4 — Sign-up via magic link** : if the email is unknown, the consume endpoint creates the user record. (No separate "sign-up" flow needed — first magic link IS the sign-up.)
5. **A.5 — BDD coverage** : scenarios for happy path, expired token, double-consume, wrong-email, rate-limit. Cf. ADR-0030 for the email assertion strategy.
### Phase B — OIDC Code flow with PKCE (target: 3-4 PRs)
1. **B.1 — Local IdP** : choose Authelia or Keycloak for local development. Add to `docker-compose.yml` with default test configuration.
2. **B.2 — mkcert** : document local HTTPS setup in `documentation/DEV_SETUP.md`, add `make cert` target.
3. **B.3 — OIDC client** : `pkg/auth/oidc.go` — discovery, JWKS cache, code exchange with PKCE.
4. **B.4 — Endpoints** : `/oidc/start` and `/oidc/callback`.
5. **B.5 — Provider config** : `auth.oidc.providers` map in config (cf. ADR-0006 Viper) ; multi-provider supported.
6. **B.6 — BDD coverage** : end-to-end scenarios using a mock OIDC server (or the local Authelia instance with deterministic users).
### Phase C — Decommission password (separate ADR after A+B in production)
Out of scope for this ADR. Will be ADR-NNNN when migration is complete.
## Pros and Cons of the Options
### Option 1 (Chosen — Sequenced)
* Good — incremental, no flag day, each phase shippable on its own
* Good — reuses existing JWT infrastructure (ADR-0021 secret retention)
* Good — magic link work is a prerequisite for OIDC anyway (email plumbing, mkcert)
* Bad — total work spans 2 sprints, longer time-to-OIDC than Option 2
* Mitigation: after Phase A, the team can stop if priorities shift — magic link alone is a complete improvement
### Option 2 (All OIDC)
* Good — single migration
* Bad — requires IdP operational from day 1
* Bad — local dev environment more complex than necessary for the magic link case
### Option 3 (Magic link only)
* Good — minimal scope
* Bad — re-work later for SSO
### Option 4 (Status quo)
* Good — zero effort
* Bad — accumulating tech debt
## Consequences
* `pkg/auth/` package created (currently auth code lives in `pkg/user/`) — separation is now justified by the multi-mechanism scope
* `pkg/user/api/auth_handler.go` continues to serve username/password during transition (Phase A and B), removed in Phase C
* `documentation/DEV_SETUP.md` becomes a load-bearing doc for new contributors (mkcert + docker-compose with mailpit + Authelia)
* The 4 new endpoints (`magic-link/request`, `magic-link/consume`, `oidc/start`, `oidc/callback`) require their own ADR entries in the API doc + Swagger annotations
* Phase A's magic link plumbing depends on **ADR-0029** (email infrastructure decision) — that ADR ships first
* BDD scenarios for Phase A depend on **ADR-0030** (email testing strategy with parallel BDD) — that ADR ships before any Phase A scenario lands
## Links
* Email infrastructure : [ADR-0029](0029-email-infrastructure-mailpit.md)
* BDD email testing strategy : [ADR-0030](0030-bdd-email-parallel-strategy.md)
* Existing user auth (to be partially superseded by Phase C) : [ADR-0018](0018-user-management-auth-system.md)
* JWT secret retention reused : [ADR-0021](0021-jwt-secret-retention-policy.md)
* Rate limiting reused : [ADR-0022](0022-rate-limiting-cache-strategy.md)
* OAuth 2.0 Authorization Code with PKCE : [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
* OpenID Connect Core : [OpenID Foundation](https://openid.net/specs/openid-connect-core-1_0.html)