mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
125 lines
4.0 KiB
TypeScript
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";
|
|
}
|