# 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)