Files
HealthLog/src/lib/auth/oidc-callback/state.ts
T
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00

125 lines
4.0 KiB
TypeScript

/**
* 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" | "reproof";
claimBinding?: string;
/**
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
* signed-in account and the session row the round trip confirms. The
* callback stamps nothing unless the browser still carries exactly this
* session and the provider names exactly this account's linked identity.
* Web only, like the claim.
*/
reproofUserId?: string;
reproofSessionId?: 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.native !== undefined) return false;
if (v.intent === "claim") {
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
return false;
}
} else if (v.intent === "reproof") {
if (
typeof v.reproofUserId !== "string" ||
v.reproofUserId.length === 0 ||
typeof v.reproofSessionId !== "string" ||
v.reproofSessionId.length === 0 ||
v.claimBinding !== undefined
) {
return false;
}
} else {
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";
}