ADR 0032 — Email OTP sign-in: a passwordless third authenticator behind the get_current_user seam¶
- Status: Accepted
- Date: 2026-07-09
- Deciders: @TheurgicDuke771
- Amends: ADR 0026 — Decision 6 answers its deferred phase-2 question "migration path for
users.aad_object_id→ generic principal" for the email slice; ADR 0028 — the frontendDATAQ_AUTH_MODEenum gainsotpand the SPA gains a cookie credential beside the OIDC bearer flow. - Related: ADR 0026 (PATs — the verifier-secret and seam pattern this copies; Basic auth rejected there stays rejected), 0010/0013 (portability guardrails)
- Issue: umbrella #738 → slices #734 (backend) · #735 (identity) · #736 (frontend) · #737 (SMTP pre-flight); hard prerequisite #725 (rate limiting, auth slice). Ratified 2026-07-09 — slices unblocked.
Amendment (2026-07-09, ADR 0033): the OTP signup contract gains
AUTH_OTP_DEFAULT_ROLE(defaultmember) — the workspace role assigned at self-signup; theWORKSPACE_ADMIN_EMAILSwrite-through wins over the default for bootstrap admins. Decision 6's trust statement also widens: with in-app-promotable stored admins, mailbox compromise of any admin-role holder is admin compromise — not only allowlisted addresses.
Context¶
Human sign-in today has exactly one real path: Azure AD (fastapi-azure-auth on the backend, generic OIDC against Azure on the frontend). PATs (ADR 0026) are headless-only and need an existing user to mint them; dev-bypass is single-user local eval. So a BYOL customer on a non-Azure cloud, and the post-wind-down local-first posture (#591), have no way to log a human in. ADR 0026 rejected HTTP Basic because it would make DataQ a password system (storage/hashing policy, lockout, reset flows). Email OTP is passwordless — proof of mailbox ownership is the credential — so it closes the gap without reopening that rejection. It complements, not replaces, the generic OIDC/JWKS backend validator (ADR 0013 Phase 2, tracked in #732): generic OIDC serves customers with an IdP; OTP serves small teams without one.
The trade this makes explicit: OTP swaps "bring an IdP" for "bring a mailbox" — an org SMTP relay is an install prerequisite of this mode. A deployment with neither uses bypass (solo/eval, unchanged). And unlike Basic auth, OTP still makes DataQ an identity issuer for its users: enumeration, mail-flooding, and code-guessing become our attack surface to own — hence the hard caps and the #725 dependency below.
Decision¶
- Third authenticator behind the existing seam.
get_current_userresolves, in order:dq_live_bearer (PAT) →dq_sess_session cookie → Azure JWT. Same uniform-401 discipline./mcpis a non-goal: sessions are a browser credential; PATs remain the headless/MCP credential. - Auth-mode ladder, fail-closed — realized by two coordinated contracts. The ladder is
bypass(solo/eval, nothing required) ·otp(small team — bring SMTP) ·oidc(org IdP). Mode selection is split across components today and stays split: the frontendDATAQ_AUTH_MODEruntime enum (ADR 0028 — nginx-injected, never read by the backend) gainsotp; the backend, which currently infers its mode fromAZURE_*/AUTH_DEV_BYPASSininit_auth, gains OTP selection via the presence of theAUTH_EMAIL_*+ allowlist block — #734 owns keeping the two selectors coordinated and documented together. Extending theinit_authfail-closed contract: OTP configured incompletely (partialAUTH_EMAIL_*or an empty signup allowlist) refuses to boot, naming the missing vars — never a deployment that looks up but can't log anyone in. - Sessions copy the PAT mechanism, not the PAT table. Opaque
dq_sess_token, SHA-256 at rest in a newsessionstable (verifier secret — never in the SecretStore), fixed expiry (default 24 h), no refresh pair — re-running OTP is the "refresh". Expiry and revocation are enforced at the seam (an expired or logged-out session is a uniform 401 on the next request — verified by tests, not just stored columns). Delivered as an HttpOnly, Secure, SameSite=Lax cookie riding the same-origin nginx proxy (ADR 0028 §5), so the SPA never holds the token (no JS-readable storage). CSRF stance: Lax blocks cross-site POSTs, which only holds if every cookie-authenticated mutation is POST-only — that invariant plus login-CSRF on verify/logout are explicit test obligations (#734/#736). DataQ does not self-issue JWTs — no signing-key lifecycle to own. - OTP mechanics sized to its entropy. A 6-digit code is ~20 bits, so the protection is caps, not KDF: 10-min TTL, single-use, max 5 verify attempts, re-request invalidates prior codes, constant-time compare, SHA-256 at rest. The request endpoint returns a uniform response whether or not the email is eligible (anti-enumeration) and sends nothing for ineligible addresses.
- Signup gating is mandatory — no open registration.
otpmode requiresAUTH_OTP_ALLOWED_EMAILSand/orAUTH_OTP_ALLOWED_DOMAINS. First-admin bootstrap: the operator puts their own address in the signup allowlist andWORKSPACE_ADMIN_EMAILS, then signs in to their own mailbox — no seeded password to rotate. - One user row per normalized email (identity linking).
users.aad_object_idbecomes nullable with a unique index onlower(email)(two-step migration + duplicate-email audit first — #735). An OTP sign-in whose email matches an existing AAD-provisioned row resolves to that row: mailbox proof is the credential, and in a single-tenant AAD the email claim is tenant-controlled, so the join is trustworthy. Grants, shares, and PATs never fragment across authenticators. Consequence to state plainly: email is the root of trust — mailbox compromise is account compromise (and admin compromise if that address is onWORKSPACE_ADMIN_EMAILS). - The OTP mailer is its own config block, on the request path.
AUTH_EMAIL_*(host/port/username/from/password-secret-name — password viaSecretStore, same pattern as alerting) is separate from alerting'sEMAIL_*, reusing the SMTP+STARTTLS code shape but not the publisher: the alert path treats mail as best-effort (the mailer no-ops when unconfigured and the composite publisher isolates its send errors so a flaky mailer can't fail a run); OTP send is the opposite contract — synchronous (~5 s timeout) on the request path, with send errors surfaced to the caller, never isolated away. A misconfigured alert channel can never block sign-in, and vice versa. An admin-gated SMTP pre-flight ("send me a test mail", #737) surfaces misconfiguration at install time. - Hard prerequisite: #725's auth-endpoint slice.
otp/requestis the app's first unauthenticated mail-sending endpoint; per-email and per-IP rate limits land with or before it.
Consequences¶
Positive — a real human sign-in for non-Azure and fully-local deployments (the local-first posture's missing auth mode); no password store, ever; bootstrap without seeded credentials; all four moving parts copy proven in-repo patterns (PAT verifier storage, init_auth fail-closed, SecretStore-by-name, runtime frontend config), so the implementation risk concentrates in the one genuinely new thing — the identity migration (#735).
Negative / accepted — a mailbox becomes an install prerequisite of otp mode (mitigated: bypass remains for the mailbox-less solo case, and the BYOL audience has SMTP by definition); DataQ takes on identity-issuer abuse surface (mitigated by caps + gating + #725, but it is new surface); email-as-root-of-trust is a real trust-model shift that the security docs must state; the users migration touches every authz path and needs the two-step discipline; sessions add a second browser credential model (cookie) beside the OIDC bearer flow.
Alternatives considered¶
- HTTP Basic / passwords — rejected in ADR 0026; unchanged.
- Magic links instead of codes — rejected: corporate mail scanners prefetch URLs (consuming single-use links), links leak into logs/referrers, and codes copy across devices. Same transport, worse failure modes.
- TOTP authenticator apps — rejected for this gap: enrollment needs a retrievable shared secret (password-adjacent storage) plus a recovery story — heavier than the problem. Fine as a future second factor on top.
- Self-issued JWT sessions — rejected: buys statelessness we don't need at this scale, costs a signing-key rotation story; opaque hashed tokens match the PAT precedent and are trivially revocable.
- Bundle the generic OIDC validator instead — rejected as instead (different audience: IdP-owning orgs vs IdP-less teams); it remains the marketplace prerequisite in #732 and arrives on its own track.
- Open signup (no allowlist) — rejected: DataQ holds failing-row samples (PII); self-provisioning by anyone who can receive email is unacceptable as a default.