mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 524,
|
||||
"total": 5488,
|
||||
"count": 523,
|
||||
"total": 5483,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -337,7 +337,6 @@
|
||||
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
|
||||
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
|
||||
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
|
||||
"lib/auth/__tests__/oidc.test.ts": 5,
|
||||
"lib/auth/__tests__/passkey.test.ts": 7,
|
||||
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
|
||||
"lib/auth/__tests__/profile-update.test.ts": 4,
|
||||
|
||||
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
// #1296 — the identity-provider re-proof start. A top-level navigation from
|
||||
// the web's re-proof dialog that ends at the provider's sign-in page; the
|
||||
// native client never re-proves this way (Bearer has its own step-up).
|
||||
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
|
||||
|
||||
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// replayed callback redirects instead of minting a second identity, and
|
||||
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
||||
// as the claim routes refuse it.
|
||||
// And the identity-provider re-proof (#1296), which stamps only
|
||||
// the session the start bound into the sealed state, and only while the
|
||||
// browser still carries it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding on an otherwise anonymous route",
|
||||
// Public liveness probe. The session read only decides whether the body
|
||||
// carries operational detail; the status code is computed without it.
|
||||
"app/api/health/route.ts": "admin-only detail on a public probe",
|
||||
|
||||
Reference in new issue
Block a user