Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
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 :
- 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.
- OpenID Connect Authorization Code flow — delegate authentication to an external Identity Provider (e.g. Authelia, Keycloak, Auth0, Google) ; our app receives an
id_tokenafter 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
- Add
-
Phase B — OpenID Connect Authorization Code with PKCE
- Add
GET /api/v1/auth/oidc/start— generates state + PKCE verifier, redirects to provider'sauthorization_endpoint - Add
GET /api/v1/auth/oidc/callback— exchanges code for tokens, validatesid_tokensignature 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 —
mkcertsetup documented indocumentation/DEV_SETUP.md
- Add
-
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)
- A.1 — Storage : add a
magic_link_tokenstable (id, email, token_hash, expires_at, consumed_at). Repository pattern alongsidepkg/user/postgres_repository.go. - A.2 — Token endpoint :
POST /api/v1/auth/magic-link/requestgenerates a token, stores it (hashed), enqueues an email send. Rate-limited (cf. ADR-0022) by email address. - A.3 — Consume endpoint :
GET /api/v1/auth/magic-link/consume?token=...validates + marks consumed + issues JWT. ReturnsSet-Cookieand{token: jwt}body. - 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.)
- 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)
- B.1 — Local IdP : choose Authelia or Keycloak for local development. Add to
docker-compose.ymlwith default test configuration. - B.2 — mkcert : document local HTTPS setup in
documentation/DEV_SETUP.md, addmake certtarget. - B.3 — OIDC client :
pkg/auth/oidc.go— discovery, JWKS cache, code exchange with PKCE. - B.4 — Endpoints :
/oidc/startand/oidc/callback. - B.5 — Provider config :
auth.oidc.providersmap in config (cf. ADR-0006 Viper) ; multi-provider supported. - 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 inpkg/user/) — separation is now justified by the multi-mechanism scopepkg/user/api/auth_handler.gocontinues to serve username/password during transition (Phase A and B), removed in Phase Cdocumentation/DEV_SETUP.mdbecomes 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
- BDD email testing strategy : ADR-0030
- Existing user auth (to be partially superseded by Phase C) : ADR-0018
- JWT secret retention reused : ADR-0021
- Rate limiting reused : ADR-0022
- OAuth 2.0 Authorization Code with PKCE : RFC 7636
- OpenID Connect Core : OpenID Foundation