refactor(auth): move the SSO callback's account resolution and sign-in endings out of its route

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 15:17:27 +02:00
1 parent d76497ec13
commit af5cf1ace2
7 files changed
+475 -334

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": 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,
@@ -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,
@@ -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.
+47 -329
View File
@@ -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<string, unknown>;
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=<reason>` 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);
@@ -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<string> {
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<void> {
// 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 });
}
@@ -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<User> {
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<boolean> {
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<User> {
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<OidcAccountResolution> {
// (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 }) };
}
+102
View File
@@ -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<string, unknown>;
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";
}