From af5cf1ace273b7b3eec6eea3abf32d9c1013e082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 15:17:27 +0200 Subject: [PATCH] refactor(auth): move the SSO callback's account resolution and sign-in endings out of its route --- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- ...dc-native-error-reasons-documented.test.ts | 5 +- src/app/api/auth/oidc/callback/route.ts | 376 +++--------------- .../auth/oidc-callback/complete-sign-in.ts | 116 ++++++ src/lib/auth/oidc-callback/resolve-account.ts | 202 ++++++++++ src/lib/auth/oidc-callback/state.ts | 102 +++++ 7 files changed, 475 insertions(+), 334 deletions(-) create mode 100644 src/lib/auth/oidc-callback/complete-sign-in.ts create mode 100644 src/lib/auth/oidc-callback/resolve-account.ts create mode 100644 src/lib/auth/oidc-callback/state.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index feba73d69..04bda5dac 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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": 370, - "total": 129150, + "total": 128945, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -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)": 499, + "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 294, "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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 238eff99a..97ad4bf34 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2911, - "total": 9688, + "total": 9684, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -163,7 +163,7 @@ "app/api/auth/mfa/verify/route.ts": 1, "app/api/auth/mfa/webauthn/verify/route.ts": 1, "app/api/auth/oidc/callback/__tests__/route.test.ts": 1, - "app/api/auth/oidc/callback/route.ts": 7, + "app/api/auth/oidc/callback/route.ts": 3, "app/api/auth/oidc/login/route.ts": 2, "app/api/auth/passkey/login-options/route.ts": 1, "app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 2, diff --git a/src/__tests__/oidc-native-error-reasons-documented.test.ts b/src/__tests__/oidc-native-error-reasons-documented.test.ts index b5b7ec7c9..92a5e1dbd 100644 --- a/src/__tests__/oidc-native-error-reasons-documented.test.ts +++ b/src/__tests__/oidc-native-error-reasons-documented.test.ts @@ -37,10 +37,13 @@ describe("native OIDC error reasons are documented", () => { // The login route answers both legs through `loginError`; the callback's // native-aware helper is `failRedirect`. `errorRedirect` in the callback is // the browser-only branch taken before the state (and so the native flag) - // could be read, and never reaches the app. + // could be read, and never reaches the app. The account resolution the + // callback calls names its refusals through `refuse`, and the callback + // hands each one to `failRedirect`. const sent = new Set([ ...reasons(read("app/api/auth/oidc/login/route.ts"), ["loginError"]), ...reasons(read("app/api/auth/oidc/callback/route.ts"), ["failRedirect"]), + ...reasons(read("lib/auth/oidc-callback/resolve-account.ts"), ["refuse"]), ]); // The login route's 302 description is one string literal on one line. diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index af57ea28a..fc7a1f6d3 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -3,17 +3,11 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; -import { decrypt } from "@/lib/crypto"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; import { - createSession, validateSessionFromCookieValue, SESSION_COOKIE_NAME, } from "@/lib/auth/session"; -import { recordSignInDevice } from "@/lib/auth/login-alert"; import { - deriveUniqueUsername, discoverOidcMetadata, exchangeCodeForTokens, fetchUserinfoEmail, @@ -29,15 +23,19 @@ import { OIDC_STATE_COOKIE, OIDC_STATE_COOKIE_PATH, } from "@/lib/auth/oidc-cookie"; -import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; -import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { resolveServerDefaultTimezone } from "@/lib/tz/resolver"; -import { - buildNativeCallbackUrl, - mintNativeHandoff, -} from "@/lib/auth/oidc-native-handoff"; +import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff"; import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link"; +import { + idpErrorCode, + readStoredOidcState, +} from "@/lib/auth/oidc-callback/state"; +import { resolveOidcAccount } from "@/lib/auth/oidc-callback/resolve-account"; +import { + mintOidcNativeHandoff, + openOidcSession, + startOidcSecondFactor, +} from "@/lib/auth/oidc-callback/complete-sign-in"; import { CLAIM_COOKIE, CLAIM_ERROR_PATH, @@ -74,84 +72,6 @@ function deleteStateCookie(response: NextResponse): void { }); } -/** - * Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error - * codes. The wide-event meta only ever carries a member of this set (or - * "other") — never the raw, attacker-influenceable query value. - */ -const KNOWN_IDP_ERROR_CODES = new Set([ - "access_denied", - "invalid_request", - "unauthorized_client", - "unsupported_response_type", - "invalid_scope", - "server_error", - "temporarily_unavailable", - "interaction_required", - "login_required", - "account_selection_required", - "consent_required", - "invalid_request_uri", - "invalid_request_object", - "request_not_supported", - "request_uri_not_supported", - "registration_not_supported", -]); - -interface StoredOidcState { - state: string; - nonce: string; - codeVerifier: string; - next: string; - /** - * v1.30.x — set only when the login GET carried `client=native`. When true - * the callback mints a handoff code (or MFA ticket) and redirects to the - * custom scheme instead of minting a session. Both fields live ONLY inside - * the AES-256-GCM blob, so they are tamper-authenticated: a network attacker - * or the IdP cannot flip a web login into a native one or swap the app - * challenge. - */ - native?: boolean; - /** The app's S256 PKCE challenge, bound at login; verified at the exchange. */ - appCodeChallenge?: string; - /** - * v1.43 (#959) — set only when the login carried `intent=claim` and a valid - * `hl_claim` cookie. The callback then claims the managed profile the - * cookie names instead of signing in, and only when the cookie still - * carries this exact binding. Web only: never together with `native`. - */ - intent?: "claim"; - claimBinding?: string; -} - -function isStoredOidcState(value: unknown): value is StoredOidcState { - const v = value as Record; - if ( - typeof value !== "object" || - value === null || - typeof v.state !== "string" || - typeof v.nonce !== "string" || - typeof v.codeVerifier !== "string" || - typeof v.next !== "string" - ) { - return false; - } - // Optional native fields: when present they must be the right shape, and a - // native start MUST carry the challenge the exchange verifies against. - if (v.native !== undefined) { - if (v.native !== true) return false; - if (typeof v.appCodeChallenge !== "string") return false; - } - if (v.intent !== undefined) { - if (v.intent !== "claim") return false; - if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) { - return false; - } - if (v.native !== undefined) return false; - } - return true; -} - /** * OIDC callback. Must work with NO existing session — this route * authenticates the user in the first place, unlike the per-account @@ -159,6 +79,9 @@ function isStoredOidcState(value: unknown): value is StoredOidcState { * `requireAuth()`. Every failure branch redirects to `/auth/login?error=...` * (never JSON — this is a top-level browser navigation) and deletes the * single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`. + * + * The state blob, the account resolution and the three endings (second + * factor, native handoff, session) live in `src/lib/auth/oidc-callback/`. */ export const GET = apiHandler(async (req: NextRequest) => { annotate({ action: { name: "auth.oidc.callback" } }); @@ -169,16 +92,7 @@ export const GET = apiHandler(async (req: NextRequest) => { // the web login page — the app parses `?error=` and never lands on a // dead web page. A missing/undecryptable blob leaves `stored` null (isNative // false) and those branches fall back to the web redirect by construction. - const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value; - let stored: StoredOidcState | null = null; - if (rawCookie) { - try { - const parsed: unknown = JSON.parse(decrypt(rawCookie)); - if (isStoredOidcState(parsed)) stored = parsed; - } catch { - stored = null; - } - } + const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value); const isNative = stored?.native === true; const isClaim = stored?.intent === "claim"; @@ -229,7 +143,7 @@ export const GET = apiHandler(async (req: NextRequest) => { annotate({ meta: { reason: "idp_denied", - idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other", + idpError: idpErrorCode(idpError), }, }); return failRedirect("oidc_denied"); @@ -367,198 +281,32 @@ export const GET = apiHandler(async (req: NextRequest) => { return response; } - // (1) Durable identity match — the (issuer, sub) pair stamped on a - // previous login, link, or provision. Email is deliberately NOT - // consulted here: after the stamp it is a display field, so an - // IdP-side email change can refresh it below but can never re-point - // the login at a different account. - let user = await prisma.user.findFirst({ - where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub }, + const account = await resolveOidcAccount({ + issuer: metadata.issuer, + sub: identity.sub, + email, + emailVerified, + ip, }); - - if (user) { - // Refresh the display email only — and only from a claim the IdP - // explicitly marks verified. Skipped when another account already - // holds the address (`email` is unique); the login itself proceeds - // either way, keyed by (issuer, sub). - const displayEmail = email ? email.toLowerCase() : null; - if ( - displayEmail && - emailVerified === true && - user.email?.toLowerCase() !== displayEmail - ) { - const emailTaken = await prisma.user.findFirst({ - where: { - email: { equals: displayEmail, mode: "insensitive" }, - NOT: { id: user.id }, - }, - select: { id: true }, - }); - if (!emailTaken) { - user = await prisma.user.update({ - where: { id: user.id }, - data: { email: displayEmail }, - }); - } + if (account.kind === "refused") return failRedirect(account.reason); + if (account.kind === "linkPending") { + if (isNative) { + // The native sign-in sheet cannot run the confirmation; the person + // signs in on the web once, after which the app's SSO works. + return failRedirect("oidc_link_required"); } - } else { - // No stamped identity yet — both remaining paths (link-once, - // provision) bind this (issuer, sub) to an account keyed by email, - // so the claim must be explicitly `email_verified: true`. An ABSENT - // claim is a reject, not a benefit of the doubt: an IdP that does - // not assert verification cannot anchor a link that later gates a - // health record. - if (!email) { - annotate({ meta: { reason: "no_email" } }); - return failRedirect("oidc_no_email"); - } - if (emailVerified !== true) { - annotate({ meta: { reason: "email_unverified" } }); - return failRedirect("oidc_email_unverified"); - } - - const byEmail = await prisma.user.findFirst({ - where: { email: { equals: email, mode: "insensitive" } }, + const response = errorRedirect("oidc_link_required"); + setPendingLinkCookie(response, { + userId: account.userId, + issuer: metadata.issuer, + sub: identity.sub, }); - - if (byEmail) { - // (2) Link ONCE. An account already pinned to a different IdP - // identity is never silently re-bound — a rebind would let a - // second IdP identity that acquires the same email capture the - // account. Audited so the operator can see the collision. - if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) { - await auditLog("auth.oidc.link_conflict", { - userId: byEmail.id, - ipAddress: ip, - }); - annotate({ meta: { reason: "identity_conflict" } }); - return failRedirect("oidc_identity_conflict"); - } - // (2b) Never link on the email alone. The local address was never - // verified — anyone can register with it or type it into a profile — - // so matching it proves nothing about who owns the account. Record - // the link this login would make and send the browser to sign in to - // the account with its own credential; the link is made there - // (`completePendingOidcLink`), and only for this account. - await auditLog("auth.oidc.link_pending", { - userId: byEmail.id, - ipAddress: ip, - }); - annotate({ meta: { reason: "link_confirmation_required" } }); - if (isNative) { - // The native sign-in sheet cannot run the confirmation; the person - // signs in on the web once, after which the app's SSO works. - return failRedirect("oidc_link_required"); - } - const response = errorRedirect("oidc_link_required"); - setPendingLinkCookie(response, { - userId: byEmail.id, - issuer: metadata.issuer, - sub: identity.sub, - }); - return response; - } else { - // (3) Provision. - const userCount = await prisma.user.count(); - let registrationEnabled = true; - try { - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - }); - if (settings && !settings.registrationEnabled && userCount > 0) { - registrationEnabled = false; - } - } catch { - // Table may not exist yet; allow provisioning (matches register/route.ts). - } - if (!registrationEnabled) { - annotate({ meta: { reason: "registration_disabled" } }); - return failRedirect("oidc_registration_disabled"); - } - - const username = await deriveUniqueUsername(email, (candidate) => - prisma.user - .findUnique({ where: { username: candidate } }) - .then((u: unknown) => u !== null), - ); - - // Same server-default resolution the register route uses — an - // SSO-provisioned account has no registration form to carry the - // browser-detected timezone. - const timezone = await resolveServerDefaultTimezone(); - - // v1.28.48 — the first provisioned user becomes ADMIN. Deriving that - // role from the early `userCount` (read above, before username - // derivation) and then creating without coordination is the same - // check-then-act race the password-register route closed in v1.28.42: - // two concurrent OIDC first-logins racing the empty-DB window would - // both observe `0` and both be minted ADMIN. Serialise the - // count+insert behind a transaction-scoped advisory lock (released on - // commit/rollback) and re-count *inside* the lock so the second - // provision observes the first's committed row and is minted USER. - // The lock key STRING matches the register route's — an OIDC - // first-login racing a password first-registration is serialised - // against the same lock, so only one ADMIN can ever be minted. - user = await prisma.$transaction(async (tx) => { - // `pg_advisory_xact_lock` returns void, which the client cannot - // deserialize as a column — selecting FROM it yields a plain int row. - await tx.$queryRaw` - SELECT 1 AS locked - FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0)) - `; - const priorUsers = await tx.user.count(); - const role = priorUsers === 0 ? "ADMIN" : "USER"; - return tx.user.create({ - data: { - email: email.toLowerCase(), - username, - passwordHash: null, - role, - timezone, - oidcIssuer: metadata.issuer, - oidcSub: identity.sub, - }, - }); - }); - - await auditLog("auth.oidc.provisioned", { - userId: user.id, - ipAddress: ip, - }); - } + return response; } + const user = account.user; - // Native second factor. OIDC is a DELEGATED factor: it proves the IdP - // authenticated someone, but it never substitutes for this app's own - // second factor. An account with a confirmed TOTP secret or a - // registered security key gets the same MFA challenge step password - // login gets — no session is minted here; the single-use ticket rides - // the handoff cookie to the login page (web) or the custom scheme - // (native), which render the same challenge UI and complete at - // `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this - // path: its cookie is SameSite=Strict, which the browser withholds on an - // IdP-initiated redirect chain.) - const hasTotp = Boolean(user.totpConfirmedAt); - const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ - where: { userId: user.id }, - }); - const hasWebauthn = webauthnKeyCount > 0; - - if (hasTotp || hasWebauthn) { - const challenge = await createMfaChallenge(user.id, "login"); - const methods: ("totp" | "recovery" | "webauthn")[] = []; - if (hasTotp) methods.push("totp", "recovery"); - if (hasWebauthn) methods.push("webauthn"); - await auditLog("auth.mfa.challenge", { - userId: user.id, - ipAddress: ip, - details: { source: isNative ? "login.oidc.native" : "login.oidc" }, - }); - annotate({ - action: { name: "auth.mfa.challenge" }, - meta: { mfa_required: true }, - }); - + const secondFactor = await startOidcSecondFactor(user, ip, isNative); + if (secondFactor) { // Native: hand the app the SAME single-use ticket via the custom scheme. // No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web // login page's script) and no session — the app completes at the @@ -569,8 +317,8 @@ export const GET = apiHandler(async (req: NextRequest) => { if (isNative) { const response = NextResponse.redirect( buildNativeCallbackUrl({ - mfa_ticket: challenge.ticket, - methods: methods.join(","), + mfa_ticket: secondFactor.ticket, + methods: secondFactor.methods.join(","), }), ); deleteStateCookie(response); @@ -585,7 +333,10 @@ export const GET = apiHandler(async (req: NextRequest) => { deleteStateCookie(response); response.cookies.set( OIDC_MFA_COOKIE, - JSON.stringify({ ticket: challenge.ticket, methods }), + JSON.stringify({ + ticket: secondFactor.ticket, + methods: secondFactor.methods, + }), { // Not httpOnly by design — see the constant's doc comment. httpOnly: false, @@ -600,26 +351,14 @@ export const GET = apiHandler(async (req: NextRequest) => { const ua = req.headers.get("user-agent"); - // Native, no MFA: mint a one-time handoff code and hand it to the app via - // the custom scheme. NO session and NO cookie are minted here — the app - // exchanges the code at `POST /api/auth/oidc/native/token` for the native - // bundle. `userId` is the resolved identity; the app's S256 challenge - // (bound at login, tamper-authenticated in the state blob) locks the code - // to the app instance that started the flow. The session/device are - // recorded at the exchange via `finishLogin`, not here. if (isNative) { - const { code: handoffCode } = await mintNativeHandoff({ + const handoffCode = await mintOidcNativeHandoff({ userId: user.id, - // Guaranteed present by `isStoredOidcState` when `native === true`. + // Guaranteed present by the state reader when `native === true`. appCodeChallenge: stored.appCodeChallenge as string, - ipAddress: ip, + ip, userAgent: ua, }); - await auditLog("auth.oidc.native.handoff_minted", { - userId: user.id, - ipAddress: ip, - }); - annotate({ action: { name: "auth.oidc.native.handoff_minted" } }); const response = NextResponse.redirect( buildNativeCallbackUrl({ code: handoffCode }), ); @@ -627,28 +366,7 @@ export const GET = apiHandler(async (req: NextRequest) => { return response; } - // v1.23 parity with password login — the admin-enforced-MFA hint cookie - // sends a single-factor account into forced enrollment after sign-in. - await syncMfaEnrollCookie(user.id, { - totpConfirmedAt: user.totpConfirmedAt, - mfaEnforced: user.mfaEnforced, - }); - - // The 5th argument stays null: OIDC is a delegated factor, so an SSO - // session must never satisfy `requireFreshMfa` step-up (MFA disable, - // encryption-key rotation, encrypted export, account deletion) — the - // same treatment a trusted-device login gets. - await createSession( - user.id, - user.onboardingCompletedAt === null, - ip, - ua, - null, - ); - - void recordSignInDevice({ userId: user.id, ip, userAgent: ua }); - - await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip }); + await openOidcSession(user, ip, ua); const response = NextResponse.redirect(oidcAppUrl(stored.next)); deleteStateCookie(response); diff --git a/src/lib/auth/oidc-callback/complete-sign-in.ts b/src/lib/auth/oidc-callback/complete-sign-in.ts new file mode 100644 index 000000000..8635c414a --- /dev/null +++ b/src/lib/auth/oidc-callback/complete-sign-in.ts @@ -0,0 +1,116 @@ +/** + * The three ways an SSO sign-in ends once its account is known + * (`GET /api/auth/oidc/callback`): this app's own second factor, a native + * handoff code, or a web session. The callback turns each into its + * redirect. + */ +import { prisma } from "@/lib/db"; +import type { User } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff"; + +export type MfaMethod = "totp" | "recovery" | "webauthn"; + +/** + * OIDC is a DELEGATED factor: it proves the IdP authenticated someone, but it + * never substitutes for this app's own second factor. An account with a + * confirmed TOTP secret or a registered security key gets the same MFA + * challenge step password login gets: no session is minted, and the + * single-use ticket goes to the login page (web) or the custom scheme + * (native), which render the same challenge UI and complete at + * `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this + * path: its cookie is SameSite=Strict, which the browser withholds on an + * IdP-initiated redirect chain.) + * + * Answers the challenge, or null when the account has no second factor. + */ +export async function startOidcSecondFactor( + user: User, + ip: string, + native: boolean, +): Promise<{ ticket: string; methods: MfaMethod[] } | null> { + const hasTotp = Boolean(user.totpConfirmedAt); + const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ + where: { userId: user.id }, + }); + const hasWebauthn = webauthnKeyCount > 0; + if (!hasTotp && !hasWebauthn) return null; + + const challenge = await createMfaChallenge(user.id, "login"); + const methods: MfaMethod[] = []; + if (hasTotp) methods.push("totp", "recovery"); + if (hasWebauthn) methods.push("webauthn"); + await auditLog("auth.mfa.challenge", { + userId: user.id, + ipAddress: ip, + details: { source: native ? "login.oidc.native" : "login.oidc" }, + }); + annotate({ + action: { name: "auth.mfa.challenge" }, + meta: { mfa_required: true }, + }); + return { ticket: challenge.ticket, methods }; +} + +/** + * Native, no MFA: a one-time handoff code for the app. NO session and NO + * cookie are minted here: the app exchanges the code at + * `POST /api/auth/oidc/native/token` for the native bundle. The app's S256 + * challenge (bound at login, tamper-authenticated in the state blob) locks + * the code to the app instance that started the flow. The session and + * device are recorded at the exchange via `finishLogin`, not here. + */ +export async function mintOidcNativeHandoff(input: { + userId: string; + appCodeChallenge: string; + ip: string; + userAgent: string | null; +}): Promise { + const { code } = await mintNativeHandoff({ + userId: input.userId, + appCodeChallenge: input.appCodeChallenge, + ipAddress: input.ip, + userAgent: input.userAgent, + }); + await auditLog("auth.oidc.native.handoff_minted", { + userId: input.userId, + ipAddress: input.ip, + }); + annotate({ action: { name: "auth.oidc.native.handoff_minted" } }); + return code; +} + +/** A web sign-in without a second factor: the session and its ledger row. */ +export async function openOidcSession( + user: User, + ip: string, + userAgent: string | null, +): Promise { + // Parity with password login: the admin-enforced-MFA hint cookie sends a + // single-factor account into forced enrollment after sign-in. + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); + + // The 5th argument stays null: OIDC is a delegated factor, so an SSO + // session must never satisfy `requireFreshMfa` step-up (MFA disable, + // encryption-key rotation, encrypted export, account deletion), the same + // treatment a trusted-device login gets. + await createSession( + user.id, + user.onboardingCompletedAt === null, + ip, + userAgent, + null, + ); + + void recordSignInDevice({ userId: user.id, ip, userAgent }); + + await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip }); +} diff --git a/src/lib/auth/oidc-callback/resolve-account.ts b/src/lib/auth/oidc-callback/resolve-account.ts new file mode 100644 index 000000000..cc9b7a39a --- /dev/null +++ b/src/lib/auth/oidc-callback/resolve-account.ts @@ -0,0 +1,202 @@ +/** + * Which account an SSO sign-in belongs to (`GET /api/auth/oidc/callback`), + * once the provider has authenticated a subject. + * + * 1. The durable identity match: the (issuer, sub) pair stamped on a + * previous login, link or provision. + * 2. An account that already holds the verified email: never linked on the + * email alone. Pinned to another identity it is a conflict; otherwise + * the link waits for the account's own credential. + * 3. Nobody: provision, while registration is open. + * + * A refusal names the reason the callback redirects with. + */ +import { prisma } from "@/lib/db"; +import type { User } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { deriveUniqueUsername } from "@/lib/auth/oidc"; +import { resolveServerDefaultTimezone } from "@/lib/tz/resolver"; + +export type OidcAccountResolution = + | { kind: "user"; user: User } + | { kind: "refused"; reason: string } + | { kind: "linkPending"; userId: string }; + +/** A refusal; the reason is the `?error=` the callback redirects with. */ +function refuse(reason: string): OidcAccountResolution { + return { kind: "refused", reason }; +} + +interface OidcSubject { + issuer: string; + sub: string; + email: string | null; + emailVerified: boolean | undefined; + ip: string; +} + +/** + * Refresh the display email only, and only from a claim the IdP explicitly + * marks verified. Skipped when another account already holds the address + * (`email` is unique); the login itself proceeds either way, keyed by + * (issuer, sub). Email is deliberately NOT consulted for the match: after + * the stamp it is a display field, so an IdP-side email change can refresh + * it but can never re-point the login at a different account. + */ +async function refreshDisplayEmail( + user: User, + subject: OidcSubject, +): Promise { + const displayEmail = subject.email ? subject.email.toLowerCase() : null; + if ( + !displayEmail || + subject.emailVerified !== true || + user.email?.toLowerCase() === displayEmail + ) { + return user; + } + const emailTaken = await prisma.user.findFirst({ + where: { + email: { equals: displayEmail, mode: "insensitive" }, + NOT: { id: user.id }, + }, + select: { id: true }, + }); + if (emailTaken) return user; + return prisma.user.update({ + where: { id: user.id }, + data: { email: displayEmail }, + }); +} + +async function registrationOpen(): Promise { + const userCount = await prisma.user.count(); + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + if (settings && !settings.registrationEnabled && userCount > 0) { + return false; + } + } catch { + // Table may not exist yet; allow provisioning (matches register/route.ts). + } + return true; +} + +/** + * The first provisioned user becomes ADMIN. Deriving that role from an early + * count and then creating without coordination is the check-then-act race + * the password-register route closed: two concurrent first logins racing the + * empty-DB window would both observe `0` and both be minted ADMIN. The + * count+insert is serialised behind a transaction-scoped advisory lock + * (released on commit/rollback) and re-counted *inside* the lock, so the + * second provision observes the first's committed row and is minted USER. + * The lock key STRING matches the register route's: an OIDC first login + * racing a password first registration is serialised against the same lock, + * so only one ADMIN can ever be minted. + */ +async function provision( + subject: OidcSubject & { email: string }, +): Promise { + const username = await deriveUniqueUsername(subject.email, (candidate) => + prisma.user + .findUnique({ where: { username: candidate } }) + .then((u: unknown) => u !== null), + ); + // Same server-default resolution the register route uses: an + // SSO-provisioned account has no registration form to carry the + // browser-detected timezone. + const timezone = await resolveServerDefaultTimezone(); + const user = await prisma.$transaction(async (tx) => { + // `pg_advisory_xact_lock` returns void, which the client cannot + // deserialize as a column; selecting FROM it yields a plain int row. + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0)) + `; + const priorUsers = await tx.user.count(); + const role = priorUsers === 0 ? "ADMIN" : "USER"; + return tx.user.create({ + data: { + email: subject.email.toLowerCase(), + username, + passwordHash: null, + role, + timezone, + oidcIssuer: subject.issuer, + oidcSub: subject.sub, + }, + }); + }); + await auditLog("auth.oidc.provisioned", { + userId: user.id, + ipAddress: subject.ip, + }); + return user; +} + +export async function resolveOidcAccount( + subject: OidcSubject, +): Promise { + // (1) Durable identity match. + const stamped = await prisma.user.findFirst({ + where: { oidcIssuer: subject.issuer, oidcSub: subject.sub }, + }); + if (stamped) { + return { kind: "user", user: await refreshDisplayEmail(stamped, subject) }; + } + + // No stamped identity yet: both remaining paths (link-once, provision) + // bind this (issuer, sub) to an account keyed by email, so the claim must + // be explicitly `email_verified: true`. An ABSENT claim is a reject, not a + // benefit of the doubt: an IdP that does not assert verification cannot + // anchor a link that later gates a health record. + const email = subject.email; + if (!email) { + annotate({ meta: { reason: "no_email" } }); + return refuse("oidc_no_email"); + } + if (subject.emailVerified !== true) { + annotate({ meta: { reason: "email_unverified" } }); + return refuse("oidc_email_unverified"); + } + + const byEmail = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + }); + if (byEmail) { + // (2) Link ONCE. An account already pinned to a different IdP identity + // is never silently re-bound: a rebind would let a second IdP identity + // that acquires the same email capture the account. Audited so the + // operator can see the collision. + if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) { + await auditLog("auth.oidc.link_conflict", { + userId: byEmail.id, + ipAddress: subject.ip, + }); + annotate({ meta: { reason: "identity_conflict" } }); + return refuse("oidc_identity_conflict"); + } + // (2b) Never link on the email alone. The local address was never + // verified (anyone can register with it or type it into a profile), so + // matching it proves nothing about who owns the account. Record the link + // this login would make; the callback sends the browser to sign in to + // the account with its own credential, and the link is made there + // (`completePendingOidcLink`), and only for this account. + await auditLog("auth.oidc.link_pending", { + userId: byEmail.id, + ipAddress: subject.ip, + }); + annotate({ meta: { reason: "link_confirmation_required" } }); + return { kind: "linkPending", userId: byEmail.id }; + } + + // (3) Provision. + if (!(await registrationOpen())) { + annotate({ meta: { reason: "registration_disabled" } }); + return refuse("oidc_registration_disabled"); + } + return { kind: "user", user: await provision({ ...subject, email }) }; +} diff --git a/src/lib/auth/oidc-callback/state.ts b/src/lib/auth/oidc-callback/state.ts new file mode 100644 index 000000000..1cc56fd3e --- /dev/null +++ b/src/lib/auth/oidc-callback/state.ts @@ -0,0 +1,102 @@ +/** + * The single-use state blob of an SSO sign-in, as the callback reads it back + * (`GET /api/auth/oidc/callback`), and the provider error codes it may name + * in its wide event. + */ +import { decrypt } from "@/lib/crypto"; + +export interface StoredOidcState { + state: string; + nonce: string; + codeVerifier: string; + next: string; + /** + * Set only when the login GET carried `client=native`. When true the + * callback mints a handoff code (or MFA ticket) and redirects to the custom + * scheme instead of minting a session. Both fields live ONLY inside the + * AES-256-GCM blob, so they are tamper-authenticated: a network attacker or + * the IdP cannot flip a web login into a native one or swap the app + * challenge. + */ + native?: boolean; + /** The app's S256 PKCE challenge, bound at login; verified at the exchange. */ + appCodeChallenge?: string; + /** + * Set only when the login carried `intent=claim` and a valid `hl_claim` + * cookie (#959). The callback then claims the managed profile the cookie + * names instead of signing in, and only when the cookie still carries this + * exact binding. Web only: never together with `native`. + */ + intent?: "claim"; + claimBinding?: string; +} + +function isStoredOidcState(value: unknown): value is StoredOidcState { + const v = value as Record; + if ( + typeof value !== "object" || + value === null || + typeof v.state !== "string" || + typeof v.nonce !== "string" || + typeof v.codeVerifier !== "string" || + typeof v.next !== "string" + ) { + return false; + } + // Optional native fields: when present they must be the right shape, and a + // native start MUST carry the challenge the exchange verifies against. + if (v.native !== undefined) { + if (v.native !== true) return false; + if (typeof v.appCodeChallenge !== "string") return false; + } + if (v.intent !== undefined) { + if (v.intent !== "claim") return false; + if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) { + return false; + } + if (v.native !== undefined) return false; + } + return true; +} + +/** The decrypted blob, or null when it is missing, unreadable or malformed. */ +export function readStoredOidcState( + rawCookie: string | undefined, +): StoredOidcState | null { + if (!rawCookie) return null; + try { + const parsed: unknown = JSON.parse(decrypt(rawCookie)); + return isStoredOidcState(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error + * codes. The wide-event meta only ever carries a member of this set (or + * "other"), never the raw, attacker-influenceable query value. + */ +const KNOWN_IDP_ERROR_CODES = new Set([ + "access_denied", + "invalid_request", + "unauthorized_client", + "unsupported_response_type", + "invalid_scope", + "server_error", + "temporarily_unavailable", + "interaction_required", + "login_required", + "account_selection_required", + "consent_required", + "invalid_request_uri", + "invalid_request_object", + "request_not_supported", + "request_uri_not_supported", + "registration_not_supported", +]); + +/** The provider's error code as the wide event may carry it. */ +export function idpErrorCode(raw: string): string { + return KNOWN_IDP_ERROR_CODES.has(raw) ? raw : "other"; +}