import { type Handle, type RequestEvent } from '@sveltejs/kit'; import type { AuthLogger } from '../types.js'; import { type PublicRoute } from './public-routes.js'; /** * The identity a verified IdP token proves — and NOTHING more. Deliberately * excludes the token's `role` and `tokenVersion` claims: both are internal to * the IdP application (its own role model, its own revocation counter). * Forwarding them here would invite the identity/authorization confusion this * boundary exists to prevent — a consumer app must decide access and roles * itself, keyed on `subject` (e.g. via a `FederatedAccountRepository` link * table). The object handed to `resolveUser` is constructed field-by-field * from this shape, so the withheld claims cannot leak through at runtime * either. */ export interface FederatedIdentity { /** * The IdP's stable user id (the token's `sub` claim). This is the key to * link accounts by — never the email, which a user can change at the IdP. */ subject: string; /** The email verified/managed by the IdP at token-mint time. */ email: string; /** Unix seconds the token was minted (`iat`). */ issuedAt: number; /** Unix seconds the token expires (`exp`). */ expiresAt: number; } export interface FederatedAuthHandleOptions { /** * Absolute URL of the IdP's JWKS endpoint (the route where the IdP mounted * `createJWKSHandler`, e.g. `https://auth.example.com/.well-known/jwks.json`). * MUST be https — the JWKS is the trust anchor for every federated session, * and fetching it over plain http would let a network attacker substitute * keys. http is tolerated for localhost development only (with a warning); * anything else throws at factory time. */ jwksUrl: string; /** * Name of the IdP session cookie this app receives (via `jwt.cookieDomain` * on the IdP, e.g. `.example.com`). Must match the IdP's `jwt.cookieName`. * @default 'session' */ cookieName?: string; /** * Map a proven identity to this app's own user — the consumer's ENTIRE * authorization decision. Return the object to expose as `locals.user`, or * `null` to deny access (fail-closed: the request is then treated exactly * like an unauthenticated one). Called on every request that carries a * verifiable token; memoize/cache inside if your lookup is expensive. A * throw fails the request (mirroring `transformUser` on the IdP handle). * * Receives ONLY identity claims ({@link FederatedIdentity}) — deliberately * never the IdP token's `role`/`tokenVersion`. */ resolveUser: (identity: FederatedIdentity, event: RequestEvent) => Promise | TUser | null; /** * How long a fetched JWKS document is trusted before it is re-fetched. * Mirrors the `max-age=300` the IdP's `createJWKSHandler` serves: five * minutes keeps the rotation-propagation window tight without hammering the * endpoint. Must be at least 1000 ms — a shorter cache would collapse the * anti-fetch-storm cooldown (rejected at factory time). @default 300_000 */ cacheTtlMs?: number; /** * Optional freshness cap on top of `exp`, as a duration string (`'15m'`, * `'2h'`, …): a token whose `iat` is older is rejected even when not yet * expired. This is the consumer-side mitigation for revocation blindness — * the consumer cannot see the IdP's `tokenVersion` bumps ("log out * everywhere"), so an IdP-revoked session stays verifiable here until `exp`. * A tight `maxTokenAge` bounds that window (pair it with short-lived IdP * access tokens via `refreshToken` rotation). Off by default. */ maxTokenAge?: string; /** * Routes exempt from the guard, read exactly as the IdP handle's * `publicRoutes`: a string is a pathname prefix, `{ path, exact: true }` the * pathname alone (see {@link PublicRoute}); a bare `'/'` prefix exempts the * whole app and is warned about at construction. A list held in a variable * first needs `as const` or the annotation `PublicRoute[]`, or TypeScript * widens `exact: true` to `boolean`; an inline list needs nothing. Defaults * to `[]` — the whole app requires a resolved user — because unlike the IdP * this app serves no login/register pages of its own; list your genuinely * public pages explicitly. */ publicRoutes?: readonly PublicRoute[]; /** * Where to send an unauthenticated browser request (302) — typically the * IdP's login page, absolute URL. Deliberately used verbatim: no * `redirectTo` is appended, because the IdP's `sanitizeRedirect` admits * IdP-local paths only, so a consumer-app path would be dropped (or worse, * misresolved against the IdP origin). Encode your own return-URL scheme * into `loginUrl` if the IdP deployment supports one. When omitted, guarded * page requests get the same JSON 401 as API routes (fail-closed, no * invented default). */ loginUrl?: string; /** * Same switch as on the IdP handle: allow unauthenticated SvelteKit Remote * Functions past the guard. Remote calls are default-denied on the * unspoofable `event.isRemoteRequest` (plus the no-JS `?/remote=` form * fallback) because their pathname is caller-controlled — see * `AuthHandleOptions.allowUnauthenticatedRemote` for the full rationale. * @default false */ allowUnauthenticatedRemote?: boolean; /** Log sink for the JWKS fail-closed warnings. @default console */ logger?: AuthLogger; } /** * Consumer-side SvelteKit handle for apps that trust a federated identity * provider running this package with `jwt.algorithm: 'ES256'` — the * counterpart of the IdP's `createJWKSHandler`. Per request it reads the IdP * session cookie, verifies the ES256 JWT against the IdP's JWKS (fetched * lazily, cached per `cacheTtlMs`), hands the proven identity to * `resolveUser`, exposes the result as `event.locals.user` (same locals * contract as `createAuthHandle`), and guards routes: unauthenticated remote * requests are default-denied, `/api/` routes get a JSON 401, pages redirect * to `loginUrl` (when set) — `publicRoutes` are exempt. * * **Identity ≠ authorization.** The token proves who the caller is; this app * decides — in `resolveUser` — whether that identity gets in and as what. * `resolveUser` receives identity claims only ({@link FederatedIdentity}); * the IdP token's `role`/`tokenVersion` are IdP-internal and never forwarded. * `resolveUser` returning `null` denies access (fail-closed). * * **ES256 only.** A federated consumer verifies asymmetrically; the token * header's `alg` must be `'ES256'` or the token is rejected. There is no * legitimate HS256 federation setup — verifying HS256 requires the signing * secret, and a shared signing secret means every "consumer" can MINT tokens, * i.e. there is no trust boundary left to federate across. * * **Fail-closed JWKS handling.** The JWKS fetch is lazy (first verification) * and cached; an unknown `kid` triggers at most one refresh per cooldown * window (no fetch storms from invented kids); a failed/timed-out/malformed/ * oversized fetch logs one loud error and treats affected sessions as signed * out — never a 500. Keys carrying private material are discarded. * * **This handle never writes cookies.** The session cookie belongs to the * IdP (which sets it for the shared parent domain via `jwt.cookieDomain`); * login and logout happen there. Consequently there is no refresh-token * rotation here either — rotation is IdP-internal. This handle also adds no * CSRF gate of its own (keep SvelteKit's kernel CSRF gate on — the default; * don't set `trustedOrigins: ['*']` on a federated consumer: there is no * `validateCsrf` backstop behind this handle. If you must expose a * header-less cross-origin endpoint, gate cookie-authenticated mutations * yourself via the exported `validateCsrf` first — docs/AUTH.md → Federated) * and no security headers (they are this app's own policy, not the * IdP's) — it does exactly one thing: turn the IdP cookie into * `locals.user`, or into a guarded 401/redirect. * * Revocation caveat: the consumer cannot see the IdP's `tokenVersion` ("log * out everywhere"), so an IdP-revoked session stays verifiable here until * `exp`. Keep IdP access tokens short-lived and/or set `maxTokenAge`. */ export declare function createFederatedAuthHandle(options: FederatedAuthHandleOptions): Handle;