mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(auth): move the SSO callback's account resolution and sign-in endings out of its route
This commit is contained in:
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.
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user