mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(auth): native OIDC SSO session handoff for the iOS client
The web OIDC flow ends in a session cookie the Bearer-transport native client cannot consume, so on an OIDC_ONLY instance the iOS app had no sign-in. Add a native leg: the app opens the login URL with client=native and its own PKCE S256 challenge inside an in-app browser; the callback resolves identity exactly as before, then mints a one-time handoff code and 302s to the fixed compiled-in scheme healthlog://oidc-callback (or hands back the existing MFA ticket when the account has a second factor). The app exchanges the code plus its PKCE verifier at POST /api/auth/oidc/native/token for the standard native token bundle. The return address is a compile-time constant — no redirect_uri parameter and no env allowlist, so the open-redirect / scheme-hijack class is removed by construction. The native flag and app challenge travel only inside the AES-256-GCM state blob, so a network attacker or the IdP cannot flip a web login into a native one. The handoff code is 256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a guarded consume; a failed PKCE check burns it. A replay of a consumed code revokes exactly the pair the first exchange issued. The token pair never rides a URL; only the opaque code or the MFA ticket do. The SSO login can never satisfy requireFreshMfa step-up — the transport is Bearer and step-up is cookie-only. Adds the OidcNativeHandoff model (migration 0252), the token-exchange route, a daily cleanup sweep, and the OpenAPI entry.
This commit is contained in:
1 parent
74891e95f4
commit
e6d9cc229a
16 files changed
+900
-70
No files matched your search
@@ -29,6 +29,10 @@ 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";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -82,17 +86,38 @@ interface StoredOidcState {
|
||||
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;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,34 +131,12 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return errorRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
// 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 rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
@@ -144,6 +147,54 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
const isNative = stored?.native === true;
|
||||
|
||||
// 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).
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ error: reason }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
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: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
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) return errorRedirect("oidc_state");
|
||||
|
||||
if (!stored) return errorRedirect("oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
@@ -166,7 +217,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect("oidc_failed");
|
||||
if (!tokens.id_token) return failRedirect("oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
@@ -229,11 +280,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return errorRedirect("oidc_no_email");
|
||||
return failRedirect("oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return errorRedirect("oidc_email_unverified");
|
||||
return failRedirect("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
@@ -251,7 +302,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return errorRedirect("oidc_identity_conflict");
|
||||
return failRedirect("oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
@@ -277,7 +328,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect("oidc_registration_disabled");
|
||||
return failRedirect("oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
@@ -337,11 +388,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// 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, which renders the same
|
||||
// challenge UI and completes 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.)
|
||||
// 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 },
|
||||
@@ -356,12 +407,31 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.oidc" },
|
||||
details: { source: isNative ? "login.oidc.native" : "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
|
||||
// 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: challenge.ticket,
|
||||
methods: methods.join(","),
|
||||
}),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
|
||||
if (stored.next !== "/") {
|
||||
loginUrl.searchParams.set("next", stored.next);
|
||||
@@ -385,6 +455,33 @@ 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({
|
||||
userId: user.id,
|
||||
// Guaranteed present by `isStoredOidcState` when `native === true`.
|
||||
appCodeChallenge: stored.appCodeChallenge as string,
|
||||
ipAddress: 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 }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
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, {
|
||||
@@ -414,6 +511,6 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect("oidc_failed");
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
@@ -29,9 +30,28 @@ import {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.login" } });
|
||||
|
||||
// v1.30.x — native SSO. The iOS app opens this URL inside an
|
||||
// `ASWebAuthenticationSession` with `client=native` and its own PKCE
|
||||
// `code_challenge`. Both are query params here only; they are validated and
|
||||
// folded into the AES-256-GCM state blob so nothing native-specific rides the
|
||||
// IdP round-trip as a URL parameter. The `client` param is attacker-flippable
|
||||
// at this route, but routing THIS request's error to the custom scheme leaks
|
||||
// nothing — an error redirect carries no code, ticket, or session (spec §1).
|
||||
const isNative = req.nextUrl.searchParams.get("client") === "native";
|
||||
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
|
||||
|
||||
// Native-aware error redirect: the scheme for a native start, the web login
|
||||
// page otherwise. Machine-readable `error=<reason>` in both.
|
||||
const loginError = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
|
||||
}
|
||||
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled"));
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
@@ -41,9 +61,14 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.redirect(
|
||||
oidcAppUrl("/auth/login?error=oidc_rate_limited"),
|
||||
);
|
||||
return loginError("oidc_rate_limited");
|
||||
}
|
||||
|
||||
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
|
||||
// structurally unsupported — the exchange only ever verifies S256.
|
||||
if (isNative && !isValidChallenge(appCodeChallenge)) {
|
||||
annotate({ meta: { reason: "invalid_native_request" } });
|
||||
return loginError("oidc_invalid_request");
|
||||
}
|
||||
|
||||
let metadata;
|
||||
@@ -51,7 +76,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed"));
|
||||
return loginError("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
@@ -59,10 +84,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
const safeNext = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
|
||||
// the native branch (one less input to sanitize).
|
||||
const safeNext = isNative
|
||||
? "/"
|
||||
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
@@ -75,8 +101,20 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
// The server↔IdP PKCE (`codeVerifier`) stays independent of the app↔server
|
||||
// PKCE (`appCodeChallenge`). The native flag + challenge live ONLY inside the
|
||||
// AES-256-GCM blob, so the callback branches on tamper-authenticated state a
|
||||
// network attacker or the IdP cannot flip.
|
||||
const cookiePayload = encrypt(
|
||||
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next: safeNext,
|
||||
...(isNative
|
||||
? { native: true as const, appCodeChallenge: appCodeChallenge! }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* POST /api/auth/oidc/native/token
|
||||
*
|
||||
* The cookie-less native leg of the OIDC SSO flow (design spec §4). The app
|
||||
* presents the one-time handoff code it received on
|
||||
* `healthlog://oidc-callback?code=` plus its PKCE `codeVerifier`; on success it
|
||||
* receives the SAME native token bundle password login issues
|
||||
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - It IS the credential mint (same class as `/api/auth/mfa/verify`): no
|
||||
* session or Bearer is required to reach it.
|
||||
* - The transport gate (`isCookielessNativeCaller`) makes the endpoint
|
||||
* structurally incapable of minting a web session or handing a 60-day refresh
|
||||
* token into a browser context — a Mozilla UA or an inbound session cookie is
|
||||
* rejected before any state is touched.
|
||||
* - `userId` is derived SOLELY from the server-side handoff row — the request
|
||||
* carries no identity field.
|
||||
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
|
||||
* deleted-user so a code-guesser learns nothing; distinct `annotate` names
|
||||
* keep the wide events diagnosable.
|
||||
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
|
||||
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The single generic rejection for every invalid-code class (steps 4–9). */
|
||||
function invalidCode() {
|
||||
return apiError("Invalid or expired code", 401);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.native.token" } });
|
||||
|
||||
// (1) Rate limit — byte-for-byte the MFA-verify surface's posture, including
|
||||
// the trust-violation collapse semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:oidc:native-token",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Transport gate — this endpoint can only ever hand a bundle to a
|
||||
// genuine cookie-less native caller. A browser (Mozilla UA or an inbound
|
||||
// session cookie) is rejected before any state is touched.
|
||||
if (!isCookielessNativeCaller(request.headers)) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// (3) Body — bounded, then the exact wire contract.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = oidcNativeTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
|
||||
const result = await consumeNativeHandoff(code, codeVerifier);
|
||||
if (result.status !== "ok") {
|
||||
switch (result.status) {
|
||||
case "not_found":
|
||||
annotate({ action: { name: "auth.oidc.native.token.not_found" } });
|
||||
break;
|
||||
case "replayed":
|
||||
// Containment (revoke the issued pair) + the audit row already ran
|
||||
// inside `consumeNativeHandoff`.
|
||||
annotate({
|
||||
action: { name: "auth.oidc.native.handoff_replay" },
|
||||
meta: { revoked_issued_pair: true },
|
||||
});
|
||||
break;
|
||||
case "expired":
|
||||
annotate({ action: { name: "auth.oidc.native.token.expired" } });
|
||||
break;
|
||||
case "pkce_mismatch":
|
||||
annotate({ action: { name: "auth.oidc.native.pkce_failed" } });
|
||||
break;
|
||||
case "race_lost":
|
||||
annotate({ action: { name: "auth.oidc.native.token.race_lost" } });
|
||||
break;
|
||||
}
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (9) Load the user (may have been deleted mid-flight).
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: result.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.user_missing" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (10) Mint the native bundle. Step (2) guarantees the cookie-less-native
|
||||
// branch of `finishLogin`, i.e. the 24h access + 60d rotating refresh with
|
||||
// the `auth.token.autoissue.native` audit row. `mfaVerified` is deliberately
|
||||
// unset — the SSO login can never satisfy step-up.
|
||||
const ua = request.headers.get("user-agent");
|
||||
const response = await finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.oidc.native",
|
||||
});
|
||||
|
||||
// (11) Stamp the issued refresh token's hash for replay-containment
|
||||
// reach-back, then audit the completed SSO login. Reading the bundle off a
|
||||
// clone leaves the client's response stream intact. Best-effort: the code is
|
||||
// already single-use-consumed, so a stamping hiccup only forgoes the
|
||||
// replay-revoke for this one row.
|
||||
try {
|
||||
const bundle = (await response.clone().json()) as {
|
||||
data?: { refreshToken?: unknown };
|
||||
};
|
||||
const refreshToken = bundle.data?.refreshToken;
|
||||
if (typeof refreshToken === "string") {
|
||||
await stampIssuedRefreshToken(result.handoffId, refreshToken);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — see comment above.
|
||||
}
|
||||
|
||||
await auditLog("auth.oidc.login", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { transport: "native" },
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
Reference in new issue
Block a user