Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
148 lines
8.9 KiB
Markdown
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)
|