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

8.9 KiB

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

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

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

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

  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
  • 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