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

+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);