mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
The web leg of the OIDC callback already names the purge date when the account is pending deletion; the native leg redirected with the bare reason. It now adds the same purgeAt parameter to the custom-scheme redirect, and only the date. Refs #1341
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
import { timingSafeEqual } from "node:crypto";
|
|
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 { validateSessionFromCookieValue } from "@/lib/auth/session";
|
|
import { readSessionCookie } from "@/lib/auth/session-cookie";
|
|
import {
|
|
discoverOidcMetadata,
|
|
exchangeCodeForTokens,
|
|
fetchUserinfoEmail,
|
|
getOidcConfig,
|
|
getOidcRedirectUri,
|
|
oidcAppUrl,
|
|
verifyIdToken,
|
|
} from "@/lib/auth/oidc";
|
|
import {
|
|
OIDC_MFA_COOKIE,
|
|
OIDC_MFA_COOKIE_PATH,
|
|
OIDC_MFA_TTL_MS,
|
|
OIDC_STATE_COOKIE,
|
|
OIDC_STATE_COOKIE_PATH,
|
|
} from "@/lib/auth/oidc-cookie";
|
|
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
|
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,
|
|
claimMatchesBinding,
|
|
clearClaimCookie,
|
|
readClaimCookie,
|
|
} from "@/lib/managed-profiles/claim-cookie";
|
|
import {
|
|
completeSsoClaim,
|
|
SSO_CLAIM_LANDING,
|
|
} from "@/lib/managed-profiles/claim-sso";
|
|
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
|
|
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
|
import {
|
|
isReproofSession,
|
|
recordOidcReproof,
|
|
reproofReturnUrl,
|
|
} from "@/lib/auth/oidc-reproof";
|
|
|
|
const LOGIN_ERROR_URL = "/auth/login";
|
|
|
|
function errorRedirect(reason: string): NextResponse {
|
|
const response = NextResponse.redirect(
|
|
oidcAppUrl(`${LOGIN_ERROR_URL}?error=${reason}`),
|
|
);
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* The web sign-in page, with the single-use second-factor ticket parked in a
|
|
* short-lived cookie its script reads (`OIDC_MFA_COOKIE`).
|
|
*/
|
|
function secondFactorRedirect(
|
|
next: string,
|
|
secondFactor: { ticket: string; methods: readonly string[] },
|
|
): NextResponse {
|
|
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
|
|
if (next !== "/") {
|
|
loginUrl.searchParams.set("next", next);
|
|
}
|
|
const response = NextResponse.redirect(loginUrl);
|
|
deleteStateCookie(response);
|
|
response.cookies.set(
|
|
OIDC_MFA_COOKIE,
|
|
JSON.stringify({
|
|
ticket: secondFactor.ticket,
|
|
methods: secondFactor.methods,
|
|
}),
|
|
{
|
|
// Not httpOnly by design — see the constant's doc comment.
|
|
httpOnly: false,
|
|
secure: shouldEmitSecureCookie(),
|
|
sameSite: "lax",
|
|
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
|
|
path: OIDC_MFA_COOKIE_PATH,
|
|
},
|
|
);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* A failure on the native leg: the custom scheme with `error` and, at most,
|
|
* a non-secret detail beside it. An error redirect carries no code, ticket,
|
|
* or session.
|
|
*/
|
|
function nativeErrorRedirect(params: Record<string, string>): NextResponse {
|
|
const response = NextResponse.redirect(buildNativeCallbackUrl(params));
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* The purge date as the `purgeAt` parameter the native leg carries beside
|
|
* `account_deletion_pending`, mirroring `deletionPendingRedirect` (#1341).
|
|
* Only the date: nothing else about the account rides the URL.
|
|
*/
|
|
function purgeAtParam(purgeAt: Date | null): Record<string, string> {
|
|
return purgeAt ? { purgeAt: purgeAt.toISOString() } : {};
|
|
}
|
|
|
|
/**
|
|
* The web sign-in page for an account pending deletion, with the date it is
|
|
* erased, so the page can say until when an admin can undo it.
|
|
*/
|
|
function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
|
|
const url = oidcAppUrl(LOGIN_ERROR_URL);
|
|
url.searchParams.set("error", "account_deletion_pending");
|
|
if (purgeAt) url.searchParams.set("purgeAt", purgeAt.toISOString());
|
|
const response = NextResponse.redirect(url);
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
|
|
* the person is still signed in, and the session they came from is the one
|
|
* thing that leg may touch. `reason` null is the success.
|
|
*/
|
|
function reproofRedirect(next: string, reason: string | null): NextResponse {
|
|
if (reason) annotate({ meta: { reason } });
|
|
const response = NextResponse.redirect(
|
|
reproofReturnUrl(next, reason ? "failed" : "done"),
|
|
);
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
|
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
|
* delete would target `/` and never match, leaving the single-use blob
|
|
* alive for the rest of its TTL).
|
|
*/
|
|
function deleteStateCookie(response: NextResponse): void {
|
|
response.cookies.delete({
|
|
name: OIDC_STATE_COOKIE,
|
|
path: OIDC_STATE_COOKIE_PATH,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* OIDC callback. Must work with NO existing session — this route
|
|
* authenticates the user in the first place, unlike the per-account
|
|
* integration callbacks (Withings/Polar/etc.) which all require
|
|
* `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" } });
|
|
|
|
// Decrypt the single-use state blob FIRST. The `native` flag lives inside the
|
|
// AES-256-GCM payload (tamper-authenticated), so decrypting it up front lets
|
|
// every post-decrypt error branch redirect to the custom scheme instead of
|
|
// 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 stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
|
|
const isNative = stored?.native === true;
|
|
const isClaim = stored?.intent === "claim";
|
|
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
|
|
const isReproof = stored?.intent === "reproof";
|
|
const reproofBack = (reason: string | null) =>
|
|
reproofRedirect(stored?.next ?? "/", reason);
|
|
|
|
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
|
// the web login page otherwise. Deletes the single-use state cookie either
|
|
// way. An error redirect carries no code, ticket, or session, so routing it
|
|
// to the scheme leaks nothing (spec §1).
|
|
// v1.43 (#959) — a refused claim returns to the claim page, never the
|
|
// sign-in page, and takes the claim cookie with it: the person starts again
|
|
// from the link, which is still theirs to use if the refusal left it open.
|
|
// Browser-only, like `errorRedirect`: a claim is never native.
|
|
const claimRedirect = (reason: string): NextResponse => {
|
|
const response = NextResponse.redirect(
|
|
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
|
|
);
|
|
deleteStateCookie(response);
|
|
clearClaimCookie(response);
|
|
return response;
|
|
};
|
|
|
|
const failRedirect = (
|
|
reason: string,
|
|
nativeExtra: Record<string, string> = {},
|
|
): NextResponse => {
|
|
if (isReproof) return reproofBack(reason);
|
|
if (isClaim) return claimRedirect(reason);
|
|
if (isNative) return nativeErrorRedirect({ error: reason, ...nativeExtra });
|
|
return errorRedirect(reason);
|
|
};
|
|
|
|
const config = getOidcConfig();
|
|
if (!config) return failRedirect("oidc_disabled");
|
|
|
|
const rl = await checkAuthSurfaceRateLimit(
|
|
req,
|
|
"auth:oidc:callback",
|
|
20,
|
|
15 * 60 * 1000,
|
|
);
|
|
const ip = rl.ip ?? "unknown";
|
|
if (!rl.allowed) return failRedirect("oidc_rate_limited");
|
|
|
|
const { searchParams } = req.nextUrl;
|
|
const idpError = searchParams.get("error");
|
|
if (idpError) {
|
|
annotate({
|
|
meta: {
|
|
reason: "idp_denied",
|
|
idpError: idpErrorCode(idpError),
|
|
},
|
|
});
|
|
return failRedirect("oidc_denied");
|
|
}
|
|
|
|
const code = searchParams.get("code");
|
|
const state = searchParams.get("state");
|
|
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
|
|
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
|
|
// and there is nothing safe to hand the app (spec §1 web-fallback).
|
|
if (!code || !state) {
|
|
if (isReproof) return reproofBack("oidc_state");
|
|
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
|
}
|
|
|
|
if (!stored) return stateMissing(req);
|
|
|
|
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
|
if (
|
|
state.length !== stored.state.length ||
|
|
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
|
) {
|
|
annotate({ meta: { reason: "csrf" } });
|
|
if (isReproof) return reproofBack("oidc_state");
|
|
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
|
}
|
|
|
|
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
|
|
|
|
// v1.43 (#959) — a claim is never finished over a live session (the claim
|
|
// routes' rule: a Guardian finishing their own link would otherwise be
|
|
// signed out of their account and into the person's), and is admitted only
|
|
// with its own sealed cookie, still bound to this sign-in, for a link that
|
|
// is still usable. All of it before the code is redeemed, so a refused claim
|
|
// spends nothing at the provider.
|
|
const pendingClaim = isClaim
|
|
? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value)
|
|
: null;
|
|
if (isClaim) {
|
|
const liveSession = await validateSessionFromCookieValue(
|
|
readSessionCookie(req.cookies)?.value,
|
|
);
|
|
if (liveSession) {
|
|
annotate({ meta: { reason: "claim_signed_in" } });
|
|
return failRedirect("claim_signed_in");
|
|
}
|
|
if (
|
|
!pendingClaim ||
|
|
!claimMatchesBinding(pendingClaim, stored.claimBinding as string) ||
|
|
!(await isHandoverUsable(pendingClaim.handoverId))
|
|
) {
|
|
annotate({ meta: { reason: "claim_expired" } });
|
|
return failRedirect("claim_expired");
|
|
}
|
|
}
|
|
|
|
try {
|
|
const metadata = await discoverOidcMetadata(config);
|
|
const redirectUri = getOidcRedirectUri();
|
|
|
|
const tokens = await exchangeCodeForTokens({
|
|
metadata,
|
|
config,
|
|
code,
|
|
codeVerifier: stored.codeVerifier,
|
|
redirectUri,
|
|
});
|
|
if (!tokens.id_token) return failRedirect("oidc_failed");
|
|
|
|
const identity = await verifyIdToken({
|
|
metadata,
|
|
config,
|
|
idToken: tokens.id_token,
|
|
nonce: stored.nonce,
|
|
});
|
|
|
|
let email = identity.email;
|
|
let emailVerified = identity.emailVerified;
|
|
if (!email && tokens.access_token) {
|
|
const userinfo = await fetchUserinfoEmail({
|
|
metadata,
|
|
accessToken: tokens.access_token,
|
|
});
|
|
email = userinfo.email;
|
|
emailVerified = userinfo.emailVerified;
|
|
}
|
|
|
|
// v1.43 (#959) — the claim. The provider has authenticated somebody; bind
|
|
// that subject to the profile the cookie names, or refuse. Never falls
|
|
// through to the sign-in below: a refused claim signs nobody in, and
|
|
// provisions and links nothing.
|
|
if (pendingClaim) {
|
|
const outcome = await completeSsoClaim({
|
|
handoverId: pendingClaim.handoverId,
|
|
username: pendingClaim.username,
|
|
issuer: metadata.issuer,
|
|
sub: identity.sub,
|
|
email,
|
|
emailVerified,
|
|
ipAddress: ip,
|
|
userAgent: req.headers.get("user-agent"),
|
|
});
|
|
if (!outcome.ok) return failRedirect(outcome.reason);
|
|
const response = NextResponse.redirect(oidcAppUrl(SSO_CLAIM_LANDING));
|
|
deleteStateCookie(response);
|
|
clearClaimCookie(response);
|
|
return response;
|
|
}
|
|
|
|
const account = await resolveOidcAccount({
|
|
issuer: metadata.issuer,
|
|
sub: identity.sub,
|
|
email,
|
|
emailVerified,
|
|
ip,
|
|
});
|
|
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");
|
|
}
|
|
const response = errorRedirect("oidc_link_required");
|
|
setPendingLinkCookie(response, {
|
|
userId: account.userId,
|
|
issuer: metadata.issuer,
|
|
sub: identity.sub,
|
|
});
|
|
return response;
|
|
}
|
|
const user = account.user;
|
|
|
|
// An account pending deletion is locked. The identity provider proved
|
|
// the caller is this account, so the sign-in page may name the date.
|
|
const pendingDeletion = await pendingDeletionOf(user.id);
|
|
if (pendingDeletion) {
|
|
if (isNative || isClaim) {
|
|
const extra = purgeAtParam(pendingDeletion.purgeAt);
|
|
return failRedirect("account_deletion_pending", extra);
|
|
}
|
|
return deletionPendingRedirect(pendingDeletion.purgeAt);
|
|
}
|
|
|
|
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
|
|
// existing native-capable `/api/auth/mfa/verify{,/webauthn}` endpoints,
|
|
// which end in `finishLogin` and return the native bundle directly. The
|
|
// ticket alone is inert: single-use, hashed at rest, 5-minute TTL,
|
|
// 5-attempt cap, factor-gated.
|
|
if (isNative) {
|
|
const response = NextResponse.redirect(
|
|
buildNativeCallbackUrl({
|
|
mfa_ticket: secondFactor.ticket,
|
|
methods: secondFactor.methods.join(","),
|
|
}),
|
|
);
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
return secondFactorRedirect(stored.next, secondFactor);
|
|
}
|
|
|
|
const ua = req.headers.get("user-agent");
|
|
|
|
if (isNative) {
|
|
const handoffCode = await mintOidcNativeHandoff({
|
|
userId: user.id,
|
|
// Guaranteed present by the state reader when `native === true`.
|
|
appCodeChallenge: stored.appCodeChallenge as string,
|
|
ip,
|
|
userAgent: ua,
|
|
});
|
|
const response = NextResponse.redirect(
|
|
buildNativeCallbackUrl({ code: handoffCode }),
|
|
);
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
|
|
await openOidcSession(user, ip, ua);
|
|
|
|
const response = NextResponse.redirect(oidcAppUrl(stored.next));
|
|
deleteStateCookie(response);
|
|
return response;
|
|
} catch (err) {
|
|
getEvent()?.setError(err);
|
|
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
|
return failRedirect("oidc_failed");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* #1296 — the re-proof leg. The browser must still carry the session the
|
|
* start bound into the state, checked before the code is redeemed; then the
|
|
* provider's answer must name this account's linked identity and an
|
|
* authentication inside the gate's window. Only then is `reproofAt` stamped.
|
|
* No session is created, and every refusal lands back on the page it left.
|
|
*/
|
|
async function finishReproof(
|
|
req: NextRequest,
|
|
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
|
|
config: NonNullable<ReturnType<typeof getOidcConfig>>,
|
|
ip: string,
|
|
back: (reason: string | null) => NextResponse,
|
|
): Promise<NextResponse> {
|
|
const live = await validateSessionFromCookieValue(
|
|
readSessionCookie(req.cookies)?.value,
|
|
);
|
|
if (!isReproofSession(stored, live)) return back("session_mismatch");
|
|
|
|
try {
|
|
const metadata = await discoverOidcMetadata(config);
|
|
const tokens = await exchangeCodeForTokens({
|
|
metadata,
|
|
config,
|
|
code: req.nextUrl.searchParams.get("code") as string,
|
|
codeVerifier: stored.codeVerifier,
|
|
redirectUri: getOidcRedirectUri(),
|
|
});
|
|
if (!tokens.id_token) return back("oidc_failed");
|
|
const identity = await verifyIdToken({
|
|
metadata,
|
|
config,
|
|
idToken: tokens.id_token,
|
|
nonce: stored.nonce,
|
|
});
|
|
const outcome = await recordOidcReproof({
|
|
userId: stored.reproofUserId as string,
|
|
sessionId: stored.reproofSessionId as string,
|
|
issuer: metadata.issuer,
|
|
identity,
|
|
ipAddress: ip,
|
|
});
|
|
return back(outcome.ok ? null : outcome.reason);
|
|
} catch (err) {
|
|
getEvent()?.setError(err);
|
|
return back("exchange_or_verify_failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A callback that arrives without its state blob: the duplicate of a sign-in
|
|
* that already completed, or a forged, replayed or expired one.
|
|
*/
|
|
async function stateMissing(req: NextRequest): Promise<NextResponse> {
|
|
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
|
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
|
// first callback the moment it succeeds. A second callback for the same
|
|
// sign-in (a client/proxy retry, a double navigation, or the
|
|
// service-worker double-request this release also fixes) therefore arrives
|
|
// with the state cookie already gone — `stored` is null — and, because the
|
|
// first callback set the session, it carries a live session cookie.
|
|
// Redeeming the now-consumed authorization code again fails at the IdP and
|
|
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
|
// when a VALIDATED session is present, treat this as a duplicate of a
|
|
// completed login and send the user into the app instead of erroring.
|
|
//
|
|
// Fail-closed is preserved for the real attack case: the code is NEVER
|
|
// exchanged on this branch, and the session is resolved against the
|
|
// database + its expiry (not merely "a cookie is present"). A forged,
|
|
// replayed, or expired state from a caller with no valid session gets the
|
|
// unchanged `oidc_state` error; a caller who already holds their own valid
|
|
// session gains nothing here but a redirect to a page they could already
|
|
// reach — no code redemption, no session mint, no identity change.
|
|
const existingSession = await validateSessionFromCookieValue(
|
|
readSessionCookie(req.cookies)?.value,
|
|
);
|
|
if (existingSession) {
|
|
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
|
const response = NextResponse.redirect(oidcAppUrl("/"));
|
|
// The state cookie is already consumed; delete defensively so a stray
|
|
// blob can never outlive this response.
|
|
deleteStateCookie(response);
|
|
return response;
|
|
}
|
|
return errorRedirect("oidc_state");
|
|
}
|