feat(auth): let a single-sign-on account confirm it is you at its provider

An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 346,
"total": 112297,
"total": 112215,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
@@ -9,7 +9,7 @@
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
@@ -217,7 +217,7 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,