@kanzo-tech/auth 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { jwtVerify, type JWTPayload } from \"jose\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\n/**\n * `organization:*` is always asked for: membership of every organization arrives in one token, and\n * which one a request is *in* is the product's resolver's answer, per request. Plain\n * `organization` would make Keycloak prompt for a choice at sign-in instead.\n */\nconst DEFAULT_SCOPE = \"openid profile email organization:*\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * What `randomState()` mints — 43 characters of base64url — with room for another client's. The\n * callback's `state` names a cookie, so it is checked before it is spelled into one.\n */\nconst STATE = /^[A-Za-z0-9_-]{16,128}$/;\n/** The event a logout token carries, OpenID Connect Back-Channel Logout 1.0 §2.4. */\nconst BACKCHANNEL_EVENT = \"http://schemas.openid.net/event/backchannel-logout\";\n/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nconst DEFAULT_RENEW_WITHIN = 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. A resource server answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`. `jose`, fetching the\n * keys a logout token is checked against, reports its deadline as `ERR_JWKS_TIMEOUT` and a key set\n * that is not one as `ERR_JWKS_INVALID`, or as its generic error for a status that is not 200.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\" || code === \"ERR_JWKS_TIMEOUT\") return \"idp/silent\";\n if (\n code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\" ||\n code === \"ERR_JWKS_INVALID\" ||\n code === \"ERR_JOSE_GENERIC\"\n ) {\n return \"idp/unreachable\";\n }\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * The token endpoint's `invalid_grant` for a refresh token: Keycloak's answer once the SSO session\n * behind it has gone idle, been ended, or the token was already rotated by someone else.\n * `openid-client` carries the OAuth error on the `error` field of its `ResponseBodyError`.\n */\nfunction isInvalidGrant(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { error?: unknown }).error === \"invalid_grant\"\n );\n}\n\n/**\n * The scope a sign-in asks for: the configured one with its organization scope replaced by\n * `organization:<alias>` when one is named, and by `organization:*` otherwise. Exactly one\n * organization scope, because Keycloak gives no promise about which of two would win.\n */\nfunction scopeFor(configured: string | undefined, organization: string | undefined): string {\n const others = (configured ?? DEFAULT_SCOPE)\n .split(/\\s+/)\n .filter((scope) => scope !== \"\" && scope !== \"organization\" && !scope.startsWith(\"organization:\"));\n return [...others, `organization:${organization ?? \"*\"}`].join(\" \");\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. The ticket names the session and no longer changes when the session is\n * renewed, so it is the right key: the proxy, the API forwarder and the refresh route all renew the\n * same session under the same name, and whichever asks second joins the first.\n *\n * **It is per process.** Two Node instances behind a load balancer can still both spend the same\n * refresh token, and the loser is told `invalid_grant`. That is answered in `renew` by reading the\n * record again — the winner has already written the rotated token under the same ticket — rather\n * than by a lock here, which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted | Ended>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const guard =\n <A extends unknown[], R>(call: (...args: A) => Promise<R>) =>\n async (...args: A): Promise<R> => {\n try {\n return await call(...args);\n } catch (error) {\n // A store that cannot revoke answered, and its answer is the point; it is not an outage.\n if (error instanceof AuthError && error.code === \"session/irrevocable\") throw error;\n return refuse(\"session/unavailable\", \"the session store did not answer\", error);\n }\n };\n return {\n put: guard((record) => store.put(record)),\n update: guard((ticket, record) => store.update(ticket, record)),\n get: guard((ticket) => store.get(ticket)),\n drop: guard((ticket) => store.drop(ticket)),\n dropAll: guard((subject) => store.dropAll(subject)),\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/**\n * A live session after `refresh`: renewed, or still good. `cookies` is empty unless the ticket\n * itself changed, which only a stateless store's re-seal does.\n */\nexport interface Renewed {\n readonly ended: false;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * No live session: none was presented, the store no longer knows it, or the IdP refused to renew\n * it. The ticket has been dropped, and `cookies` clears the one the browser holds — empty when it\n * held none.\n *\n * A result rather than an exception, because the cookies are the point: an ended session that\n * forgets to clear its cookie is the zombie this replaced, a page drawn for a session the IdP had\n * already closed.\n */\nexport interface Ended {\n readonly ended: true;\n /** `token/refused` when the IdP refused to renew it; `session/absent` when there was none to renew. */\n readonly code: \"session/absent\" | \"token/refused\";\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: the new session, its cookies, and where the person was going. */\nexport interface SignedIn {\n readonly session: Session;\n readonly cookies: readonly string[];\n readonly returnTo: string;\n}\n\n/** What `token` answers for a live session: the credential a resource server takes, and what to set. */\nexport interface Token extends Renewed {\n readonly accessToken: string;\n}\n\n/**\n * Everything a successful renewal produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /**\n * Default `openid profile email organization:*`. Whatever is given, its organization scope is\n * `organization:*` — or the one alias a sign-in names.\n */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply a `ticketStore` to end a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /**\n * Leg one: the authorization URL, and the cookie that remembers this attempt.\n *\n * `redirectUri` is per call, because it is derived from the request that asked, and one relying\n * party serves every origin a deployment answers on.\n */\n begin(options: SignInOptions & { readonly redirectUri: string }): Promise<Redirect>;\n /**\n * Leg two: the callback URL Keycloak returned to, the `Cookie` header it arrived with, and the\n * `redirectUri` `begin` was given — which the token request must repeat exactly.\n */\n complete(request: {\n readonly url: string | URL;\n readonly cookie: string | null;\n readonly redirectUri: string;\n }): Promise<SignedIn>;\n /** The session a request carries, or `null`. One store read; nothing is renewed. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed in place when it is within `renewWithin` seconds\n * of expiry (default 60) — or {@link Ended} when there is no live session.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. Renewal is single-flight per ticket, so a page\n * that fires eight requests at an expiring token spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | Ended>;\n /** Spend the refresh token now, whatever the access token's expiry. See {@link token}. */\n refresh(cookie: string | null | undefined): Promise<Renewed | Ended>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n /**\n * Back-channel logout, OpenID Connect Back-Channel Logout 1.0 §2.6: verify the logout token\n * Keycloak posted, and drop every session it names.\n *\n * Rejects with `token/refused` for a token that does not verify, and `session/irrevocable` for a\n * store that cannot end a session from here.\n */\n logout(logoutToken: string): Promise<void>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What a transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n /**\n * One cookie per attempt, named by its `state` — Auth0's `__txn_{state}`. A single transaction\n * cookie is overwritten by the second tab that starts signing in, and the first tab's callback\n * then finds someone else's attempt and fails; per-state cookies let both finish. Each lives ten\n * minutes and is cleared by the callback that spends it.\n */\n const transaction = (state: string): SealedCookie<Transaction> =>\n sealedCookie({ name: `kanzo-auth.${state}`, secret: config.secret, maxAge: TRANSACTION_MAX_AGE });\n\n /** The ticket a request's cookie carries and the record behind it, or `null` for no cookie. */\n const opened = async (\n cookie: string | null | undefined,\n ): Promise<{ readonly ticket: string; readonly record: SessionRecord | null } | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return { ticket: sealed.ticket, record: await store.get(sealed.ticket) };\n };\n\n const ended = async (\n ticket: string | undefined,\n code: Ended[\"code\"] = \"session/absent\",\n ): Promise<Ended> => {\n if (ticket !== undefined) await store.drop(ticket);\n return { ended: true, code, cookies: ticket === undefined ? [] : [session.clear()] };\n };\n\n /** What a grant produced, as the record to keep: the identity, the tokens, and the IdP session. */\n const adopt = (tokens: Tokens, previous: SessionRecord | null): SessionRecord => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const identity = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (identity === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n const accessTokenExpiresAt = lifetime === undefined ? undefined : Date.now() + lifetime * 1000;\n const sid = typeof idClaims?.[\"sid\"] === \"string\" ? idClaims[\"sid\"] : previous?.sid;\n\n return {\n session: { ...identity, expiresAt: accessTokenExpiresAt ?? identity.expiresAt },\n sid,\n accessToken: tokens.access_token,\n accessTokenExpiresAt,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n };\n\n /**\n * Spend the refresh token and write what comes back under the same ticket.\n *\n * Duende BFF's shape: the server-side session is updated in place, so the cookie naming it does\n * not change and a renewal in the proxy has nothing to hand the browser. A refusal is the end of\n * the session — unless the refusal is because another process got there first, which the\n * record, re-read once, says.\n */\n const renew = (ticket: string, record: SessionRecord): Promise<Adopted | Ended> =>\n renewals(ticket, async () => {\n const spent = record.refreshToken;\n if (spent === undefined) return ended(ticket);\n\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // An IdP that did not answer has refused nothing, and a token endpoint that refused for any\n // reason but the grant is a deployment fault: neither ends the session.\n if (unanswered(error) !== undefined || !isInvalidGrant(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the token endpoint did not renew the session\");\n }\n const current = await store.get(ticket);\n if (current?.refreshToken !== undefined && current.refreshToken !== spent) {\n return { record: current, renewed: { ended: false, session: current.session, cookies: [] } };\n }\n return ended(ticket, \"token/refused\");\n }\n\n const fresh = adopt(tokens, record);\n const next = await store.update(ticket, fresh);\n // The row went while the grant was in flight: a back-channel logout ended this session, and\n // the tokens just issued are for nobody.\n if (next === null) return ended(ticket);\n return {\n record: fresh,\n renewed: {\n ended: false,\n session: fresh.session,\n cookies: next === ticket ? [] : [await session.seal({ ticket: next })],\n },\n };\n });\n\n /** A logout token's verification failure, as the IdP's outage or as a refused token. */\n const fromLogout = (error: unknown): AuthError =>\n error instanceof AuthError\n ? error\n : fromIdp(error, \"token/refused\", \"the logout token did not verify\");\n\n return {\n async begin(options) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: options.redirectUri,\n scope: scopeFor(config.scope, options.organization),\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction(state).seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const current = new URL(request.url);\n const state = current.searchParams.get(\"state\");\n if (state === null || !STATE.test(state)) {\n refuse(\"callback/state-mismatch\", \"the callback carries no `state` this client could have sent\");\n }\n\n const spent = transaction(state);\n const pending = await spent.read(request.cookie);\n if (pending === null || pending.state !== state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` names no transaction this browser started\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n // The token request repeats the `redirect_uri` the authorization request sent (RFC 6749\n // §4.1.3), and `openid-client` reads it off the URL it is handed — so the callback's own\n // parameters go onto that URI, whatever host the request happened to arrive under.\n const callback = new URL(request.redirectUri);\n callback.search = current.search;\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, callback, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. This is the only place a\n // ticket is issued; every renewal after it keeps this one.\n const record = adopt(tokens, null);\n const ticket = await store.put(record);\n\n return {\n session: record.session,\n cookies: [await session.seal({ ticket }), spent.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await opened(cookie))?.record?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const { ticket, record } = found;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (record.accessToken !== undefined && !stale) {\n return { ended: false, accessToken: record.accessToken, session: record.session, cookies: [] };\n }\n\n const outcome = await renew(ticket, record);\n if (\"ended\" in outcome) return outcome;\n if (outcome.record.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { ...outcome.renewed, accessToken: outcome.record.accessToken };\n },\n\n async refresh(cookie) {\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const outcome = await renew(found.ticket, found.record);\n return \"ended\" in outcome ? outcome : outcome.renewed;\n },\n\n async end(cookie, options = {}) {\n const found = await opened(cookie);\n if (found !== null) await store.drop(found.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (found?.record?.idToken !== undefined) parameters[\"id_token_hint\"] = found.record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n\n async logout(logoutToken) {\n let payload: JWTPayload;\n try {\n // `jose` checks the signature against the realm's published keys, `iss`, `aud` and the\n // presence of `iat`, and `exp` when the token carries one. An unknown `kid` re-fetches the\n // key set once per cooldown, which is how a rotation is survived here.\n ({ payload } = await jwtVerify(logoutToken, await provider.keys(), {\n issuer: config.issuer,\n audience: config.clientId,\n requiredClaims: [\"iat\"],\n }));\n } catch (error) {\n throw fromLogout(error);\n }\n\n // §2.6 steps 4–6: the event is present, there is no `nonce` — which is what keeps an ID token\n // from being replayed as a logout token — and the token names a subject, a session, or both.\n const events = payload[\"events\"];\n const event =\n typeof events === \"object\" && events !== null\n ? (events as Record<string, unknown>)[BACKCHANNEL_EVENT]\n : undefined;\n if (typeof event !== \"object\" || event === null) {\n refuse(\"token/refused\", \"the logout token does not carry the back-channel logout event\");\n }\n if (\"nonce\" in payload) {\n refuse(\"token/refused\", \"a logout token must not carry a `nonce`\");\n }\n const sub = typeof payload.sub === \"string\" ? payload.sub : undefined;\n const sid = typeof payload[\"sid\"] === \"string\" ? payload[\"sid\"] : undefined;\n if (sub === undefined) {\n // §2.4 allows a token with only `sid`. Tickets are keyed by subject first, so a session\n // could only be found by reading every ticket there is; Keycloak always sends `sub`.\n refuse(\n \"token/refused\",\n sid === undefined\n ? \"the logout token names neither a subject nor a session\"\n : \"the logout token names a session but no subject, and sessions are found by subject\",\n );\n }\n\n await store.dropAll({ sub, sid });\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type SessionSubject,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","STATE","BACKCHANNEL_EVENT","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","isInvalidGrant","scopeFor","configured","organization","scope","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","guard","call","args","record","ticket","subject","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","state","opened","cookie","sealed","ended","adopt","tokens","previous","idClaims","identity","claims","lifetime","accessTokenExpiresAt","sid","renew","spent","refreshTokenGrant","current","fresh","next","fromLogout","options","discovered","verifier","randomPKCECodeVerifier","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","checks","callback","grant","authorizationCodeGrant","retried","_b","_a","found","within","stale","outcome","returnTo","buildEndSessionUrl","logoutToken","payload","jwtVerify","events","event","sub"],"mappings":";;;;;;;;;;;;AAmDA,MAAMA,IAAgB,uCAEhBC,IAAkB,MAAS,IAE3BC,IAAsB,KAKtBC,IAAQ,2BAERC,IAAoB,sDASpBC,IAAuB;AAE7B,SAASC,EAAOC,GAAqBC,GAAiBC,GAAwB;AAC5E,QAAM,IAAIC,EAAUH,GAAMC,GAAS,CAAA,GAAIC,MAAU,SAAY,SAAY,EAAE,OAAAA,GAAO;AACpF;AAEA,SAASE,EAAOC,GAAoC;AAClD,MAAI,OAAOA,KAAU,YAAYA,MAAU,QAAQ,EAAE,UAAUA,GAAQ;AACvE,QAAML,IAAQK,EAA4B;AAC1C,SAAO,OAAOL,KAAS,WAAWA,IAAO;AAC3C;AAWA,SAASM,EAAgBD,GAAyB;AAChD,QAAML,IAAOI,EAAOC,CAAK;AAGzB,MAAIL,MAAS,qCAAqC;AAChD,QAAIO,IAAgBF;AACpB,aAASG,IAAQ,GAAGA,IAAQ,KAAK,OAAOD,KAAS,YAAYA,MAAS,MAAMC,KAAS;AACnF,UAAKD,EAA6B,UAAU,QAAS,QAAO;AAC5D,MAAAA,IAAQA,EAA6B;AAAA,IACvC;AACA,WAAO;AAAA,EACT;AAMA,SACEP,MAAS,4BACTK,aAAiB,SACjBA,EAAM,iBAAiB,SACvBA,EAAM,MAAM,QAAQ,SAAS,SAAS;AAE1C;AAeA,SAASI,EAAmBJ,GAAyB;AACnD,SAAOD,EAAOC,CAAK,MAAM;AAC3B;AASA,SAASK,EAAWL,GAA8D;AAChF,QAAML,IAAOI,EAAOC,CAAK;AACzB,MAAIL,MAAS,mBAAmBA,MAAS,mBAAoB,QAAO;AAQpE,MANEA,MAAS,mCACTA,MAAS,sBACTA,MAAS,sBAIPK,aAAiB,aAAaL,MAAS,OAAW,QAAO;AAE/D;AAOA,SAASW,EAAeN,GAAyB;AAC/C,SACE,OAAOA,KAAU,YACjBA,MAAU,QACTA,EAA8B,UAAU;AAE7C;AAOA,SAASO,GAASC,GAAgCC,GAA0C;AAI1F,SAAO,CAAC,IAHQD,KAAcpB,GAC3B,MAAM,KAAK,EACX,OAAO,CAACsB,MAAUA,MAAU,MAAMA,MAAU,kBAAkB,CAACA,EAAM,WAAW,eAAe,CAAC,GAChF,gBAAgBD,KAAgB,GAAG,EAAE,EAAE,KAAK,GAAG;AACpE;AAeA,MAAME,KAAe,0DAefC,KAAWC,EAAA;AAGjB,SAASC,GAAUC,GAAmC;AACpD,QAAMC,IACJ,CAAyBC,MACzB,UAAUC,MAAwB;AAChC,QAAI;AACF,aAAO,MAAMD,EAAK,GAAGC,CAAI;AAAA,IAC3B,SAASlB,GAAO;AAEd,UAAIA,aAAiBF,KAAaE,EAAM,SAAS,sBAAuB,OAAMA;AAC9E,aAAON,EAAO,uBAAuB,oCAAoCM,CAAK;AAAA,IAChF;AAAA,EACF;AACF,SAAO;AAAA,IACL,KAAKgB,EAAM,CAACG,MAAWJ,EAAM,IAAII,CAAM,CAAC;AAAA,IACxC,QAAQH,EAAM,CAACI,GAAQD,MAAWJ,EAAM,OAAOK,GAAQD,CAAM,CAAC;AAAA,IAC9D,KAAKH,EAAM,CAACI,MAAWL,EAAM,IAAIK,CAAM,CAAC;AAAA,IACxC,MAAMJ,EAAM,CAACI,MAAWL,EAAM,KAAKK,CAAM,CAAC;AAAA,IAC1C,SAASJ,EAAM,CAACK,MAAYN,EAAM,QAAQM,CAAO,CAAC;AAAA,EAAA;AAEtD;AAwIO,SAASC,GAAaC,GAA0C;AACrE,QAAMC,IAAWC,EAAOF,CAAM,GACxBR,IAAQD,GAAUS,EAAO,SAASG,GAAgB,GAClDC,IAAQC,GAGRC,IAAU,CAAC7B,GAAgB8B,GAAyBlC,MAA+B;AACvF,UAAMD,IAAOU,EAAWL,CAAK,KAAK8B;AAClC,WAAO,IAAIhC,EAAUH,GAAMC,GAASD,MAAS,eAAe,EAAE,OAAAgC,EAAA,IAAU,CAAA,GAAI,EAAE,OAAO3B,GAAO;AAAA,EAC9F,GAGM+B,IAAgB,YAAoC;AACxD,QAAI;AACF,aAAO,MAAMP,EAAS,cAAA;AAAA,IACxB,SAASxB,GAAO;AACd,YAAM6B,EAAQ7B,GAAO,mBAAmB,gDAAgD;AAAA,IAC1F;AAAA,EACF,GAEMgC,IAAuCC,EAAa;AAAA,IACxD,MAAM;AAAA,IACN,QAAQV,EAAO;AAAA,IACf,QAAQA,EAAO,UAAUlC;AAAA,EAAA,CAC1B,GAQK6C,IAAc,CAACC,MACnBF,EAAa,EAAE,MAAM,cAAcE,CAAK,IAAI,QAAQZ,EAAO,QAAQ,QAAQjC,GAAqB,GAG5F8C,IAAS,OACbC,MACuF;AACvF,UAAMC,IAAS,MAAMN,EAAQ,KAAKK,CAAM;AACxC,WAAIC,MAAW,OAAa,OACrB,EAAE,QAAQA,EAAO,QAAQ,QAAQ,MAAMvB,EAAM,IAAIuB,EAAO,MAAM,EAAA;AAAA,EACvE,GAEMC,IAAQ,OACZnB,GACAzB,IAAsB,sBAElByB,MAAW,UAAW,MAAML,EAAM,KAAKK,CAAM,GAC1C,EAAE,OAAO,IAAM,MAAAzB,GAAM,SAASyB,MAAW,SAAY,KAAK,CAACY,EAAQ,MAAA,CAAO,EAAA,IAI7EQ,IAAQ,CAACC,GAAgBC,MAAkD;AAE/E,UAAMC,IAAWF,EAAO,OAAA,GAClBG,IAAWD,MAAa,SAAYD,KAAA,gBAAAA,EAAU,UAAUG,EAAOF,GAAUpB,CAAM;AACrF,IAAIqB,MAAa,UACflD,EAAO,yBAAyB,4DAA4D;AAO9F,UAAMoD,IAAWL,EAAO,UAAA,GAClBM,IAAuBD,MAAa,SAAY,SAAY,KAAK,IAAA,IAAQA,IAAW,KACpFE,IAAM,QAAOL,KAAA,gBAAAA,EAAW,QAAW,WAAWA,EAAS,MAASD,KAAA,gBAAAA,EAAU;AAEhF,WAAO;AAAA,MACL,SAAS,EAAE,GAAGE,GAAU,WAAWG,KAAwBH,EAAS,UAAA;AAAA,MACpE,KAAAI;AAAA,MACA,aAAaP,EAAO;AAAA,MACpB,sBAAAM;AAAA;AAAA;AAAA,MAGA,cAAcN,EAAO,kBAAiBC,KAAA,gBAAAA,EAAU;AAAA,MAChD,SAASD,EAAO,aAAYC,KAAA,gBAAAA,EAAU;AAAA,IAAA;AAAA,EAE1C,GAUMO,IAAQ,CAAC7B,GAAgBD,MAC7BP,GAASQ,GAAQ,YAAY;AAC3B,UAAM8B,IAAQ/B,EAAO;AACrB,QAAI+B,MAAU,OAAW,QAAOX,EAAMnB,CAAM;AAE5C,QAAIqB;AACJ,QAAI;AACF,MAAAA,IAAS,MAAMU,EAAkB,MAAMpB,EAAA,GAAiBmB,CAAK;AAAA,IAC/D,SAASlD,GAAO;AACd,UAAIA,aAAiBF,EAAW,OAAME;AAGtC,UAAIK,EAAWL,CAAK,MAAM,UAAa,CAACM,EAAeN,CAAK;AAC1D,cAAM6B,EAAQ7B,GAAO,yBAAyB,8CAA8C;AAE9F,YAAMoD,IAAU,MAAMrC,EAAM,IAAIK,CAAM;AACtC,cAAIgC,KAAA,gBAAAA,EAAS,kBAAiB,UAAaA,EAAQ,iBAAiBF,IAC3D,EAAE,QAAQE,GAAS,SAAS,EAAE,OAAO,IAAO,SAASA,EAAQ,SAAS,SAAS,CAAA,IAAG,IAEpFb,EAAMnB,GAAQ,eAAe;AAAA,IACtC;AAEA,UAAMiC,IAAQb,EAAMC,GAAQtB,CAAM,GAC5BmC,IAAO,MAAMvC,EAAM,OAAOK,GAAQiC,CAAK;AAG7C,WAAIC,MAAS,OAAaf,EAAMnB,CAAM,IAC/B;AAAA,MACL,QAAQiC;AAAA,MACR,SAAS;AAAA,QACP,OAAO;AAAA,QACP,SAASA,EAAM;AAAA,QACf,SAASC,MAASlC,IAAS,KAAK,CAAC,MAAMY,EAAQ,KAAK,EAAE,QAAQsB,GAAM,CAAC;AAAA,MAAA;AAAA,IACvE;AAAA,EAEJ,CAAC,GAGGC,IAAa,CAACvD,MAClBA,aAAiBF,IACbE,IACA6B,EAAQ7B,GAAO,iBAAiB,iCAAiC;AAEvE,SAAO;AAAA,IACL,MAAM,MAAMwD,GAAS;AACnB,YAAMC,IAAa,MAAM1B,EAAA,GAEnB2B,IAAWC,EAAA,GACXxB,IAAQyB,EAAA,GACRC,IAAQC,EAAA;AAEd,MAAIN,EAAQ,iBAAiB,UAAa,CAAC7C,GAAa,KAAK6C,EAAQ,YAAY,KAC/E9D;AAAA,QACE;AAAA,QACA,KAAK8D,EAAQ,YAAY;AAAA,MAAA;AAI7B,YAAMO,IAAqC;AAAA,QACzC,cAAcP,EAAQ;AAAA,QACtB,OAAOjD,GAASgB,EAAO,OAAOiC,EAAQ,YAAY;AAAA,QAClD,gBAAgB,MAAMQ,EAA2BN,CAAQ;AAAA,QACzD,uBAAuB;AAAA,QACvB,OAAAvB;AAAA,QACA,OAAA0B;AAAA,MAAA;AAGF,aAAO;AAAA,QACL,KAAKI,EAAsBR,GAAYM,CAAU,EAAE;AAAA,QACnD,SAAS;AAAA,UACP,MAAM7B,EAAYC,CAAK,EAAE,KAAK,EAAE,OAAAA,GAAO,OAAA0B,GAAO,UAAAH,GAAU,UAAUF,EAAQ,YAAY,KAAK;AAAA,QAAA;AAAA,MAC7F;AAAA,IAEJ;AAAA,IAEA,MAAM,SAASU,GAAS;AACtB,YAAMd,IAAU,IAAI,IAAIc,EAAQ,GAAG,GAC7B/B,IAAQiB,EAAQ,aAAa,IAAI,OAAO;AAC9C,OAAIjB,MAAU,QAAQ,CAAC5C,EAAM,KAAK4C,CAAK,MACrCzC,EAAO,2BAA2B,6DAA6D;AAGjG,YAAMwD,IAAQhB,EAAYC,CAAK,GACzBgC,IAAU,MAAMjB,EAAM,KAAKgB,EAAQ,MAAM;AAC/C,OAAIC,MAAY,QAAQA,EAAQ,UAAUhC,MACxCzC;AAAA,QACE;AAAA,QACA;AAAA,MAAA;AAIJ,YAAM0E,IAAS;AAAA,QACb,kBAAkBD,EAAQ;AAAA,QAC1B,eAAeA,EAAQ;AAAA,QACvB,eAAeA,EAAQ;AAAA,MAAA,GAMnBE,IAAW,IAAI,IAAIH,EAAQ,WAAW;AAC5C,MAAAG,EAAS,SAASjB,EAAQ;AAE1B,YAAMkB,IAAQ,CAACvC,MACbwC,EAAuBxC,GAAesC,GAAUD,CAAM;AAExD,UAAI3B;AACJ,UAAI;AACF,QAAAA,IAAS,MAAM6B,EAAM,MAAMvC,GAAe;AAAA,MAC5C,SAAS/B,GAAO;AACd,YAAIA,aAAiBF,EAAW,OAAME;AAQtC,YAPIC,EAAgBD,CAAK,KACvBN;AAAA,UACE;AAAA,UACA;AAAA,UACAM;AAAA,QAAA,GAGA,CAACI,EAAmBJ,CAAK;AAC3B,gBAAM6B,EAAQ7B,GAAO,yBAAyB,+CAA+C;AAI/F,YAAI;AACF,UAAAyC,IAAS,MAAM6B,EAAM,MAAM9C,EAAS,YAAY;AAAA,QAClD,SAASgD,GAAS;AAChB,gBAAIvE,EAAgBuE,CAAO,KACzB9E;AAAA,YACE;AAAA,YACA;AAAA,YACA8E;AAAA,UAAA,GAGE3C;AAAA,YACJ2C;AAAA,YACA;AAAA,YACA;AAAA,UAAA;AAAA,QAEJ;AAAA,MACF;AAKA,YAAMrD,IAASqB,EAAMC,GAAQ,IAAI,GAC3BrB,IAAS,MAAML,EAAM,IAAII,CAAM;AAErC,aAAO;AAAA,QACL,SAASA,EAAO;AAAA,QAChB,SAAS,CAAC,MAAMa,EAAQ,KAAK,EAAE,QAAAZ,GAAQ,GAAG8B,EAAM,OAAO;AAAA,QACvD,UAAUiB,EAAQ;AAAA,MAAA;AAAA,IAEtB;AAAA,IAEA,MAAM,KAAK9B,GAAQ;;AACjB,eAAQoC,KAAAC,IAAA,MAAMtC,EAAOC,CAAM,MAAnB,gBAAAqC,EAAuB,WAAvB,gBAAAD,EAA+B,YAAW;AAAA,IACpD;AAAA,IAEA,MAAM,MAAMpC,GAAQmB,IAAU,IAAI;AAChC,YAAMmB,IAAQ,MAAMvC,EAAOC,CAAM;AACjC,WAAIsC,KAAA,gBAAAA,EAAO,WAAU,KAAM,QAAOpC,EAAMoC,KAAA,gBAAAA,EAAO,MAAM;AACrD,YAAM,EAAE,QAAAvD,GAAQ,QAAAD,EAAA,IAAWwD,GAErBC,KAAUpB,EAAQ,eAAe/D,KAAwB,KAGzDoF,IACJ1D,EAAO,yBAAyB,UAChCA,EAAO,uBAAuB,KAAK,SAASyD;AAE9C,UAAIzD,EAAO,gBAAgB,UAAa,CAAC0D;AACvC,eAAO,EAAE,OAAO,IAAO,aAAa1D,EAAO,aAAa,SAASA,EAAO,SAAS,SAAS,CAAA,EAAC;AAG7F,YAAM2D,IAAU,MAAM7B,EAAM7B,GAAQD,CAAM;AAC1C,aAAI,WAAW2D,IAAgBA,KAC3BA,EAAQ,OAAO,gBAAgB,UACjCpF,EAAO,yBAAyB,4CAA4C,GAEvE,EAAE,GAAGoF,EAAQ,SAAS,aAAaA,EAAQ,OAAO,YAAA;AAAA,IAC3D;AAAA,IAEA,MAAM,QAAQzC,GAAQ;AACpB,YAAMsC,IAAQ,MAAMvC,EAAOC,CAAM;AACjC,WAAIsC,KAAA,gBAAAA,EAAO,WAAU,KAAM,QAAOpC,EAAMoC,KAAA,gBAAAA,EAAO,MAAM;AACrD,YAAMG,IAAU,MAAM7B,EAAM0B,EAAM,QAAQA,EAAM,MAAM;AACtD,aAAO,WAAWG,IAAUA,IAAUA,EAAQ;AAAA,IAChD;AAAA,IAEA,MAAM,IAAIzC,GAAQmB,IAAU,IAAI;;AAC9B,YAAMmB,IAAQ,MAAMvC,EAAOC,CAAM;AACjC,MAAIsC,MAAU,QAAM,MAAM5D,EAAM,KAAK4D,EAAM,MAAM;AAEjD,YAAMZ,IAAqC,CAAA,GACrCgB,IAAWvB,EAAQ,YAAYjC,EAAO;AAC5C,aAAIwD,MAAa,WAAWhB,EAAW,2BAA8BgB,MAGjEL,IAAAC,KAAA,gBAAAA,EAAO,WAAP,gBAAAD,EAAe,aAAY,aAAsB,gBAAmBC,EAAM,OAAO,UAI9E;AAAA,QACL,KAAKK,EAAmB,MAAMjD,EAAA,GAAiBgC,CAAU,EAAE;AAAA,QAC3D,SAAS,CAAC/B,EAAQ,MAAA,CAAO;AAAA,MAAA;AAAA,IAE7B;AAAA,IAEA,MAAM,OAAOiD,GAAa;AACxB,UAAIC;AACJ,UAAI;AAIF,SAAC,EAAE,SAAAA,MAAY,MAAMC,EAAUF,GAAa,MAAMzD,EAAS,QAAQ;AAAA,UACjE,QAAQD,EAAO;AAAA,UACf,UAAUA,EAAO;AAAA,UACjB,gBAAgB,CAAC,KAAK;AAAA,QAAA,CACvB;AAAA,MACH,SAASvB,GAAO;AACd,cAAMuD,EAAWvD,CAAK;AAAA,MACxB;AAIA,YAAMoF,IAASF,EAAQ,QACjBG,IACJ,OAAOD,KAAW,YAAYA,MAAW,OACpCA,EAAmC5F,CAAiB,IACrD;AACN,OAAI,OAAO6F,KAAU,YAAYA,MAAU,SACzC3F,EAAO,iBAAiB,+DAA+D,GAErF,WAAWwF,KACbxF,EAAO,iBAAiB,yCAAyC;AAEnE,YAAM4F,IAAM,OAAOJ,EAAQ,OAAQ,WAAWA,EAAQ,MAAM,QACtDlC,IAAM,OAAOkC,EAAQ,OAAW,WAAWA,EAAQ,MAAS;AAClE,MAAII,MAAQ,UAGV5F;AAAA,QACE;AAAA,QACAsD,MAAQ,SACJ,2DACA;AAAA,MAAA,GAIR,MAAMjC,EAAM,QAAQ,EAAE,KAAAuE,GAAK,KAAAtC,GAAK;AAAA,IAClC;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n genericGrantRequest,\n type Configuration,\n} from \"openid-client\";\nimport { jwtVerify, type JWTPayload } from \"jose\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { DEFAULT_RENEW_WITHIN } from \"./renew-within\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\n/**\n * `organization:*` is always asked for: membership of every organization arrives in one token, and\n * which one a request is *in* is the product's resolver's answer, per request. Plain\n * `organization` would make Keycloak prompt for a choice at sign-in instead.\n */\nconst DEFAULT_SCOPE = \"openid profile email organization:*\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * What `randomState()` mints — 43 characters of base64url — with room for another client's. The\n * callback's `state` names a cookie, so it is checked before it is spelled into one.\n */\nconst STATE = /^[A-Za-z0-9_-]{16,128}$/;\n/** The event a logout token carries, OpenID Connect Back-Channel Logout 1.0 §2.4. */\nconst BACKCHANNEL_EVENT = \"http://schemas.openid.net/event/backchannel-logout\";\n/** OAuth 2.0 Token Exchange, RFC 8693 §2.1 and §3: the grant, and the one token type exchanged. */\nconst TOKEN_EXCHANGE = \"urn:ietf:params:oauth:grant-type:token-exchange\";\nconst ACCESS_TOKEN_TYPE = \"urn:ietf:params:oauth:token-type:access_token\";\n/**\n * A client id as a scope may carry it. The audience is a client scope's name (modules/api names the\n * scope as the client), and a scope is a space-delimited list, so a space would be a second scope.\n */\nconst AUDIENCE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. A resource server answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`. `jose`, fetching the\n * keys a logout token is checked against, reports its deadline as `ERR_JWKS_TIMEOUT` and a key set\n * that is not one as `ERR_JWKS_INVALID`, or as its generic error for a status that is not 200.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\" || code === \"ERR_JWKS_TIMEOUT\") return \"idp/silent\";\n if (\n code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\" ||\n code === \"ERR_JWKS_INVALID\" ||\n code === \"ERR_JOSE_GENERIC\"\n ) {\n return \"idp/unreachable\";\n }\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * The token endpoint's `invalid_grant` for a refresh token: Keycloak's answer once the SSO session\n * behind it has gone idle, been ended, or the token was already rotated by someone else.\n * `openid-client` carries the OAuth error on the `error` field of its `ResponseBodyError`.\n */\nfunction isInvalidGrant(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { error?: unknown }).error === \"invalid_grant\"\n );\n}\n\n/**\n * The scope a sign-in asks for: the configured one with its organization scope replaced by\n * `organization:<alias>` when one is named, and by `organization:*` otherwise. Exactly one\n * organization scope, because Keycloak gives no promise about which of two would win.\n */\nfunction scopeFor(configured: string | undefined, organization: string | undefined): string {\n const others = (configured ?? DEFAULT_SCOPE)\n .split(/\\s+/)\n .filter((scope) => scope !== \"\" && scope !== \"organization\" && !scope.startsWith(\"organization:\"));\n return [...others, `organization:${organization ?? \"*\"}`].join(\" \");\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. The ticket names the session and no longer changes when the session is\n * renewed, so it is the right key: the proxy, the API forwarder and the refresh route all renew the\n * same session under the same name, and whichever asks second joins the first.\n *\n * **It is per process.** Two Node instances behind a load balancer can still both spend the same\n * refresh token, and the loser is told `invalid_grant`. That is answered in `renew` by reading the\n * record again — the winner has already written the rotated token under the same ticket — rather\n * than by a lock here, which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted | Ended>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const guard =\n <A extends unknown[], R>(call: (...args: A) => Promise<R>) =>\n async (...args: A): Promise<R> => {\n try {\n return await call(...args);\n } catch (error) {\n // A store that cannot revoke answered, and its answer is the point; it is not an outage.\n if (error instanceof AuthError && error.code === \"session/irrevocable\") throw error;\n return refuse(\"session/unavailable\", \"the session store did not answer\", error);\n }\n };\n return {\n put: guard((record) => store.put(record)),\n update: guard((ticket, record) => store.update(ticket, record)),\n get: guard((ticket) => store.get(ticket)),\n drop: guard((ticket) => store.drop(ticket)),\n dropAll: guard((subject) => store.dropAll(subject)),\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/**\n * A live session after `refresh`: renewed, or still good. `cookies` is empty unless the ticket\n * itself changed, which only a stateless store's re-seal does.\n */\nexport interface Renewed {\n readonly ended: false;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * No live session: none was presented, the store no longer knows it, or the IdP refused to renew\n * it. The ticket has been dropped, and `cookies` clears the one the browser holds — empty when it\n * held none.\n *\n * A result rather than an exception, because the cookies are the point: an ended session that\n * forgets to clear its cookie is the zombie this replaced, a page drawn for a session the IdP had\n * already closed.\n */\nexport interface Ended {\n readonly ended: true;\n /** `token/refused` when the IdP refused to renew it; `session/absent` when there was none to renew. */\n readonly code: \"session/absent\" | \"token/refused\";\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: the new session, its cookies, and where the person was going. */\nexport interface SignedIn {\n readonly session: Session;\n readonly cookies: readonly string[];\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers for a live session: the credential one resource server takes, issued for\n * it and for one organization, and what to set.\n */\nexport interface Token extends Renewed {\n readonly accessToken: string;\n}\n\n/**\n * Who a token is for: one resource server, and the organization a call is made in. RFC 9700 §2.3\n * restricts an access token to one resource server, and Keycloak's own advice for an exchange is\n * *\"ideally use a single audience\"*.\n */\nexport interface Audience {\n /**\n * The API's client id in the realm: the `aud` it validates, and the client scope that puts it\n * there (`services/auth/modules/api`), which the application lists in its `apis`.\n */\n readonly audience: string;\n /**\n * The organization the call is in: `organization:<alias>`, so the token names that organization\n * alone. Absent for an API no organization owns, and the token then names none.\n */\n readonly organization?: string;\n}\n\n/**\n * Everything a successful renewal produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\n/** An exchanged token and when it expires, epoch milliseconds — unknown when the realm did not say. */\ninterface Exchanged {\n readonly accessToken: string;\n readonly expiresAt: number | undefined;\n}\n\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /**\n * Default `openid profile email organization:*`. Whatever is given, its organization scope is\n * `organization:*` — or the one alias a sign-in names.\n */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply a `ticketStore` to end a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /**\n * Leg one: the authorization URL, and the cookie that remembers this attempt.\n *\n * `redirectUri` is per call, because it is derived from the request that asked, and one relying\n * party serves every origin a deployment answers on.\n */\n begin(options: SignInOptions & { readonly redirectUri: string }): Promise<Redirect>;\n /**\n * Leg two: the callback URL Keycloak returned to, the `Cookie` header it arrived with, and the\n * `redirectUri` `begin` was given — which the token request must repeat exactly.\n */\n complete(request: {\n readonly url: string | URL;\n readonly cookie: string | null;\n readonly redirectUri: string;\n }): Promise<SignedIn>;\n /** The session a request carries, or `null`. One store read; nothing is renewed. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * A token for one resource server and one organization — or {@link Ended} when there is no live\n * session.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. The session's own token never leaves this\n * server: it names every organization the person belongs to and no API, and it is exchanged\n * (OAuth 2.0 Token Exchange, RFC 8693, as Keycloak's standard token exchange implements it) for\n * one whose `aud` is `audience` alone and whose organization is `organization` alone. The\n * session is renewed first when it is within `renewWithin` seconds of expiry (default 60).\n *\n * Exchanged tokens are kept until they are within the same window of expiry, one per session,\n * audience and organization, and an exchange is single-flight under that key: a page that fires\n * eight requests costs the realm one exchange, not eight.\n *\n * Rejects with `organization/denied` when the realm grants the token without the organization —\n * the person is not a member of it — and with `organization/invalid` for one that is not an alias.\n */\n token(\n cookie: string | null | undefined,\n options: Audience & { readonly renewWithin?: number },\n ): Promise<Token | Ended>;\n /**\n * Renew the session: spend the refresh token now or, with `renewWithin`, only when its access\n * token is within that many seconds of expiry. Single-flight per ticket, so a page that fires\n * eight requests at an expiring session spends the refresh token once.\n */\n refresh(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Renewed | Ended>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n /**\n * Back-channel logout, OpenID Connect Back-Channel Logout 1.0 §2.6: verify the logout token\n * Keycloak posted, and drop every session it names.\n *\n * Rejects with `token/refused` for a token that does not verify, and `session/irrevocable` for a\n * store that cannot end a session from here.\n */\n logout(logoutToken: string): Promise<void>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What a transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n /**\n * One cookie per attempt, named by its `state` — Auth0's `__txn_{state}`. A single transaction\n * cookie is overwritten by the second tab that starts signing in, and the first tab's callback\n * then finds someone else's attempt and fails; per-state cookies let both finish. Each lives ten\n * minutes and is cleared by the callback that spends it.\n */\n const transaction = (state: string): SealedCookie<Transaction> =>\n sealedCookie({ name: `kanzo-auth.${state}`, secret: config.secret, maxAge: TRANSACTION_MAX_AGE });\n\n /** The ticket a request's cookie carries and the record behind it, or `null` for no cookie. */\n const opened = async (\n cookie: string | null | undefined,\n ): Promise<{ readonly ticket: string; readonly record: SessionRecord | null } | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return { ticket: sealed.ticket, record: await store.get(sealed.ticket) };\n };\n\n const ended = async (\n ticket: string | undefined,\n code: Ended[\"code\"] = \"session/absent\",\n ): Promise<Ended> => {\n if (ticket !== undefined) await store.drop(ticket);\n return { ended: true, code, cookies: ticket === undefined ? [] : [session.clear()] };\n };\n\n /** What a grant produced, as the record to keep: the identity, the tokens, and the IdP session. */\n const adopt = (tokens: Tokens, previous: SessionRecord | null): SessionRecord => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const identity = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (identity === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n const accessTokenExpiresAt = lifetime === undefined ? undefined : Date.now() + lifetime * 1000;\n const sid = typeof idClaims?.[\"sid\"] === \"string\" ? idClaims[\"sid\"] : previous?.sid;\n\n return {\n session: { ...identity, expiresAt: accessTokenExpiresAt ?? identity.expiresAt },\n sid,\n accessToken: tokens.access_token,\n accessTokenExpiresAt,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n };\n\n /**\n * Spend the refresh token and write what comes back under the same ticket.\n *\n * Duende BFF's shape: the server-side session is updated in place, so the cookie naming it does\n * not change and a renewal in the proxy has nothing to hand the browser. A refusal is the end of\n * the session — unless the refusal is because another process got there first, which the\n * record, re-read once, says.\n */\n const renew = (ticket: string, record: SessionRecord): Promise<Adopted | Ended> =>\n renewals(ticket, async () => {\n const spent = record.refreshToken;\n if (spent === undefined) return ended(ticket);\n\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // An IdP that did not answer has refused nothing, and a token endpoint that refused for any\n // reason but the grant is a deployment fault: neither ends the session.\n if (unanswered(error) !== undefined || !isInvalidGrant(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the token endpoint did not renew the session\");\n }\n const current = await store.get(ticket);\n if (current?.refreshToken !== undefined && current.refreshToken !== spent) {\n return { record: current, renewed: { ended: false, session: current.session, cookies: [] } };\n }\n return ended(ticket, \"token/refused\");\n }\n\n const fresh = adopt(tokens, record);\n const next = await store.update(ticket, fresh);\n // The row went while the grant was in flight: a back-channel logout ended this session, and\n // the tokens just issued are for nobody.\n if (next === null) return ended(ticket);\n return {\n record: fresh,\n renewed: {\n ended: false,\n session: fresh.session,\n cookies: next === ticket ? [] : [await session.seal({ ticket: next })],\n },\n };\n });\n\n /**\n * The session's access token, renewed in place when it is within `within` milliseconds of expiry,\n * or {@link Ended}. It is what an exchange presents, and it is never handed to a caller.\n */\n const live = async (\n cookie: string | null | undefined,\n within: number,\n ): Promise<(Renewed & { readonly accessToken: string }) | Ended> => {\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const { ticket, record } = found;\n\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would otherwise\n // be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined && record.accessTokenExpiresAt - Date.now() <= within;\n\n if (record.accessToken !== undefined && !stale) {\n return { ended: false, accessToken: record.accessToken, session: record.session, cookies: [] };\n }\n\n const outcome = await renew(ticket, record);\n if (\"ended\" in outcome) return outcome;\n if (outcome.record.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { ...outcome.renewed, accessToken: outcome.record.accessToken };\n };\n\n /**\n * Exchanged tokens, keyed by the session token they came from, the audience and the organization.\n * The session token is the key rather than the ticket because it is what the exchange presented:\n * a renewed session is a new key, and the old entries age out with the tokens they hold. Pruned\n * on every write, so the map is bounded by the tokens still alive and not by who ever signed in.\n */\n const exchanged = new Map<string, Exchanged>();\n const exchanges = keyedSingleFlight<Exchanged>();\n\n /**\n * The audience is asked for by its scope and not by RFC 8693's `audience` parameter: Keycloak\n * reads that parameter as a filter, and keeps only the requested audience's client roles — so\n * the application's own roles, the ones `resource_access.<clientId>` carries outside any\n * organization, would not reach the API. The scope's audience mapper names the API without\n * filtering anything (measured on Keycloak 26.8 by `services/auth/scripts/verify.sh`).\n */\n const exchange = async (\n subject: string,\n audience: string,\n organization: string | undefined,\n ): Promise<Exchanged> => {\n const wanted = organization === undefined ? undefined : `organization:${organization}`;\n let tokens: Awaited<ReturnType<typeof genericGrantRequest>>;\n try {\n tokens = await genericGrantRequest(await configuration(), TOKEN_EXCHANGE, {\n subject_token: subject,\n subject_token_type: ACCESS_TOKEN_TYPE,\n requested_token_type: ACCESS_TOKEN_TYPE,\n scope: wanted === undefined ? audience : `${audience} ${wanted}`,\n });\n } catch (error) {\n if (error instanceof AuthError) throw error;\n throw fromIdp(error, \"token/exchange-failed\", `the session's token was not exchanged for \\`${audience}\\``);\n }\n\n // Keycloak drops an organization the person is not a member of rather than refusing the grant,\n // and says so in the granted scope (RFC 6749 §5.1: present when it differs from the request).\n // Reading the response's `scope` keeps the access token opaque here, as it is by contract.\n if (wanted !== undefined && tokens.scope !== undefined && !tokens.scope.split(\" \").includes(wanted)) {\n refuse(\"organization/denied\", `the realm did not grant \\`${wanted}\\`: the person is not a member`);\n }\n\n const lifetime = tokens.expiresIn();\n const fresh: Exchanged = {\n accessToken: tokens.access_token,\n expiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\n };\n const now = Date.now();\n for (const [key, entry] of exchanged) {\n if (entry.expiresAt !== undefined && entry.expiresAt <= now) exchanged.delete(key);\n }\n exchanged.set([subject, audience, organization ?? \"\"].join(\" \"), fresh);\n return fresh;\n };\n\n /** A logout token's verification failure, as the IdP's outage or as a refused token. */\n const fromLogout = (error: unknown): AuthError =>\n error instanceof AuthError\n ? error\n : fromIdp(error, \"token/refused\", \"the logout token did not verify\");\n\n return {\n async begin(options) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: options.redirectUri,\n scope: scopeFor(config.scope, options.organization),\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction(state).seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const current = new URL(request.url);\n const state = current.searchParams.get(\"state\");\n if (state === null || !STATE.test(state)) {\n refuse(\"callback/state-mismatch\", \"the callback carries no `state` this client could have sent\");\n }\n\n const spent = transaction(state);\n const pending = await spent.read(request.cookie);\n if (pending === null || pending.state !== state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` names no transaction this browser started\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n // The token request repeats the `redirect_uri` the authorization request sent (RFC 6749\n // §4.1.3), and `openid-client` reads it off the URL it is handed — so the callback's own\n // parameters go onto that URI, whatever host the request happened to arrive under.\n const callback = new URL(request.redirectUri);\n callback.search = current.search;\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, callback, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. This is the only place a\n // ticket is issued; every renewal after it keeps this one.\n const record = adopt(tokens, null);\n const ticket = await store.put(record);\n\n return {\n session: record.session,\n cookies: [await session.seal({ ticket }), spent.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await opened(cookie))?.record?.session ?? null;\n },\n\n async token(cookie, options) {\n const { audience, organization } = options;\n if (!AUDIENCE.test(audience)) {\n // The deployment's own configuration, not a request's: a fault to fix, not a refusal.\n throw new Error(`\\`${audience}\\` is not a client id, and a scope is a space-delimited list`);\n }\n if (organization !== undefined && (organization === \"*\" || !ORGANIZATION.test(organization))) {\n refuse(\n \"organization/invalid\",\n `\\`${organization}\\` is not one organization alias, and a token is for one organization`,\n );\n }\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = await live(cookie, within);\n if (held.ended) return held;\n\n const key = [held.accessToken, audience, organization ?? \"\"].join(\" \");\n const kept = exchanged.get(key);\n const fresh =\n kept !== undefined && (kept.expiresAt === undefined || kept.expiresAt - Date.now() > within)\n ? kept\n : await exchanges(key, () => exchange(held.accessToken, audience, organization));\n return { ended: false, session: held.session, cookies: held.cookies, accessToken: fresh.accessToken };\n },\n\n async refresh(cookie, options = {}) {\n if (options.renewWithin !== undefined) {\n const held = await live(cookie, options.renewWithin * 1000);\n return held.ended ? held : { ended: false, session: held.session, cookies: held.cookies };\n }\n const found = await opened(cookie);\n if (found?.record == null) return ended(found?.ticket);\n const outcome = await renew(found.ticket, found.record);\n return \"ended\" in outcome ? outcome : outcome.renewed;\n },\n\n async end(cookie, options = {}) {\n const found = await opened(cookie);\n if (found !== null) await store.drop(found.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (found?.record?.idToken !== undefined) parameters[\"id_token_hint\"] = found.record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n\n async logout(logoutToken) {\n let payload: JWTPayload;\n try {\n // `jose` checks the signature against the realm's published keys, `iss`, `aud` and the\n // presence of `iat`, and `exp` when the token carries one. An unknown `kid` re-fetches the\n // key set once per cooldown, which is how a rotation is survived here.\n ({ payload } = await jwtVerify(logoutToken, await provider.keys(), {\n issuer: config.issuer,\n audience: config.clientId,\n requiredClaims: [\"iat\"],\n }));\n } catch (error) {\n throw fromLogout(error);\n }\n\n // §2.6 steps 4–6: the event is present, there is no `nonce` — which is what keeps an ID token\n // from being replayed as a logout token — and the token names a subject, a session, or both.\n const events = payload[\"events\"];\n const event =\n typeof events === \"object\" && events !== null\n ? (events as Record<string, unknown>)[BACKCHANNEL_EVENT]\n : undefined;\n if (typeof event !== \"object\" || event === null) {\n refuse(\"token/refused\", \"the logout token does not carry the back-channel logout event\");\n }\n if (\"nonce\" in payload) {\n refuse(\"token/refused\", \"a logout token must not carry a `nonce`\");\n }\n const sub = typeof payload.sub === \"string\" ? payload.sub : undefined;\n const sid = typeof payload[\"sid\"] === \"string\" ? payload[\"sid\"] : undefined;\n if (sub === undefined) {\n // §2.4 allows a token with only `sid`. Tickets are keyed by subject first, so a session\n // could only be found by reading every ticket there is; Keycloak always sends `sub`.\n refuse(\n \"token/refused\",\n sid === undefined\n ? \"the logout token names neither a subject nor a session\"\n : \"the logout token names a session but no subject, and sessions are found by subject\",\n );\n }\n\n await store.dropAll({ sub, sid });\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type SessionSubject,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","STATE","BACKCHANNEL_EVENT","TOKEN_EXCHANGE","ACCESS_TOKEN_TYPE","AUDIENCE","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","isInvalidGrant","scopeFor","configured","organization","scope","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","guard","call","args","record","ticket","subject","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","state","opened","cookie","sealed","ended","adopt","tokens","previous","idClaims","identity","claims","lifetime","accessTokenExpiresAt","sid","renew","spent","refreshTokenGrant","current","fresh","next","live","within","found","stale","outcome","exchanged","exchanges","exchange","audience","wanted","genericGrantRequest","now","key","entry","fromLogout","options","discovered","verifier","randomPKCECodeVerifier","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","checks","callback","grant","authorizationCodeGrant","retried","_b","_a","DEFAULT_RENEW_WITHIN","held","kept","returnTo","buildEndSessionUrl","logoutToken","payload","jwtVerify","events","event","sub"],"mappings":"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"}
package/dist/store.d.ts CHANGED
@@ -34,9 +34,10 @@ export interface SessionRecord {
34
34
  */
35
35
  readonly sid?: string;
36
36
  /**
37
- * The credential for a resource server, and the reason this field exists.
37
+ * The session's access token: what an exchange presents for a resource server's token, and
38
+ * never forwarded itself — it names every organization the person belongs to and no API.
38
39
  *
39
- * Without it a token-mediating backend has nothing `typ: "Bearer"` to forward, and what it
40
+ * Without it a token-mediating backend has nothing `typ: "Bearer"` to exchange, and what it
40
41
  * reaches for instead is the ID token — which works on a realm that happens to put the same
41
42
  * audience in both and stops working the day the resource server checks the type, as it should.
42
43
  * An ID token says *who signed in*; it was never a key to an API. It is also what
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oGAAoG;IACpG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtE,sFAAsF;IACtF,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACnD,6FAA6F;IAC7F,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC;;;;;;OAMG;IACH,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAyB7C;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AA+BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CAgDhG"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;;OASG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oGAAoG;IACpG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtE,sFAAsF;IACtF,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACnD,6FAA6F;IAC7F,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC;;;;;;OAMG;IACH,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAyB7C;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AA+BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CAgDhG"}
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport { AuthError, type Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * a product that enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The IdP's session id, the ID token's `sid`, kept across refreshes.\n *\n * It is what a back-channel logout names when one browser session ends at Keycloak rather than\n * every session the person holds, and {@link ticketStore} writes it into the ticket for that\n * reason. Absent on a realm that does not emit it, and then a logout can only end them all.\n */\n readonly sid?: string;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in, and {@link Session.expiresAt} is this same\n * number: the moment the next renewal is due.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\n/**\n * Who a back-channel logout names: a person, and optionally one of their IdP sessions.\n *\n * The shape of OpenID Connect Back-Channel Logout 1.0's own claims, `sub` and `sid`.\n */\nexport interface SessionSubject {\n readonly sub: string;\n readonly sid?: string;\n}\n\n/**\n * Where sessions live between requests. A stable ticket, updated in place — Duende BFF's server-side\n * session — rather than a new ticket per renewal.\n *\n * The ticket is issued **once, at sign-in**, which is the session-fixation defence: whatever cookie\n * a browser arrived at the callback with, it leaves with a ticket nobody has seen before. After\n * that the ticket names the session for its whole life, and a renewal changes what the row holds\n * rather than which row it is. That is what lets a renewal that happens in the proxy leave the\n * cookie alone, and what lets two tabs renewing at once agree on the session they share.\n */\nexport interface SessionStore {\n /**\n * Store a record from a sign-in and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /**\n * Replace the record behind `ticket` after a renewal, and return the ticket to carry from now on\n * — or `null` when there is no longer a session to replace.\n *\n * A store with server state answers `ticket` itself and the cookie does not change. A store whose\n * ticket *is* the record — {@link statelessStore} — has no row to update and answers a new\n * ticket, which is the cookie being re-sealed.\n *\n * **The write is conditional.** A back-channel logout may delete the row while a renewal is in\n * flight; an unconditional write would bring the ended session back. `null` is the renewal\n * learning it lost that race, and the session ends instead.\n */\n update(ticket: string, record: SessionRecord): Promise<string | null>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n /**\n * Forget every session of `sub`, or only the one Keycloak calls `sid` — a back-channel logout.\n *\n * A store that cannot find a person's sessions rejects with `AuthError` `session/irrevocable`\n * rather than answering as though it had: a logout that ends nothing must not report success to\n * the identity provider.\n */\n dropAll(subject: SessionSubject): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing**, and\n * **`dropAll` refuses** with `session/irrevocable`. Signing out clears the cookie, which is enough\n * for the person holding the browser and is not enough for anyone else — a copy of that cookie\n * taken beforehand keeps working until it expires. A session lifetime is therefore a real security\n * parameter under this store, and neither \"sign out everywhere\" nor a back-channel logout is\n * implementable on top of it — Auth0's SDK requires a session store for back-channel logout for\n * the same reason. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * `update` re-seals: the record changed, so the ticket that *is* the record changes with it, and\n * the cookie carrying it is reissued.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async update(_ticket, record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n async dropAll() {\n throw new AuthError(\n \"session/irrevocable\",\n \"a stateless session lives in the cookie, so no server can end it: give relyingParty a ticketStore\",\n );\n },\n };\n}\n\n/**\n * What a store needs from a deployment's own database — read, write, a conditional replace and a\n * delete — and a fifth that only a back-channel logout needs.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n /**\n * Overwrite `key` only if it still exists, and say whether it did — Redis `SET … XX`, SQL\n * `UPDATE … WHERE key = ?` and its row count.\n *\n * One atomic step, because the check and the write must not have a gap between them: that gap\n * is where a back-channel logout's delete lands and a renewal's write undoes it.\n */\n replace(key: string, value: string, ttl: number): Promise<boolean>;\n delete(key: string): Promise<void>;\n /**\n * Every key that begins with `prefix` — Redis `SCAN MATCH <prefix>*`, SQL `LIKE '<prefix>%'`.\n *\n * Optional, because only `dropAll` uses it; without it a back-channel logout answers that it\n * cannot end anything. The prefix never contains a glob metacharacter — see\n * {@link ticketStore} — so the Redis pattern is literal as written. A SQL adapter still escapes\n * `%` and `_`, which percent-encoding produces.\n */\n keys?(prefix: string): AsyncIterable<string>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * `encodeURIComponent`, and the five characters it leaves alone that a glob or a pattern reads:\n * `!`, `'`, `(`, `)` and `*`. A subject spelled `*` must not become a prefix that matches everyone.\n */\nfunction keySegment(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\n/** Where a person's tickets begin, or one IdP session's: `<sub>:` and `<sub>:<sid>:`. */\nfunction prefixOf(subject: SessionSubject): string {\n const sub = `${keySegment(subject.sub)}:`;\n return subject.sid === undefined ? sub : `${sub}${keySegment(subject.sid)}:`;\n}\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * kanzoAuth(() => ({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * replace: async (key, value, ttl) => (await redis.set(key, value, { XX: true, EX: ttl })) === \"OK\",\n * delete: (key) => redis.del(key),\n * // node-redis 5 yields a batch of keys per SCAN step\n * async *keys(prefix) {\n * for await (const batch of redis.scanIterator({ MATCH: `${prefix}*` })) yield* batch;\n * },\n * }),\n * }));\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject and the IdP session, and that is deliberate\n *\n * A ticket is `<sub>:<sid>:<random>`, each part percent-encoded. The random part is the whole of\n * the security — the subject and session id are not secrets and are not trusted on the way back\n * in, because the record they name is read from the row and never from the key. What the prefix\n * buys is the one operation a flat random key makes impossible: *every session belonging to this\n * person*, or *the one Keycloak just ended*. That is `dropAll`, which a back-channel logout calls,\n * and it is a prefix scan through the adapter's `keys` — the same `SCAN sub:*` a deployment could\n * write by hand for \"sign out on every device\".\n *\n * ## `update` keeps the ticket\n *\n * A renewal rewrites the row under the same key, so the cookie that names it is untouched. It goes\n * through `replace`, never `write`, so a row a back-channel logout deleted stays deleted. It also\n * restarts the row's `ttl`, which can then outlive the cookie by up to one `ttl`; that is a row the\n * adapter forgets later, never a session, because nothing can present the expired cookie.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // Encoded per part, not as a whole key: a `sub` is a uuid on every realm anyone has seen, and\n // on the one that makes it something with a colon in it the prefix must still be the prefix.\n // The random part needs no encoding — base64url is already key-safe.\n const ticket = `${prefixOf({ sub: record.session.user.id, sid: record.sid ?? \"\" })}${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async update(ticket, record) {\n const replaced = await bounded(() => adapter.replace(ticket, JSON.stringify(record), ttl));\n return replaced ? ticket : null;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n\n async dropAll(subject) {\n const keys = adapter.keys?.bind(adapter);\n if (keys === undefined) {\n throw new AuthError(\n \"session/irrevocable\",\n \"the ticket adapter has no `keys`, so a person's sessions cannot be found to end them\",\n );\n }\n await bounded(async () => {\n for await (const key of keys(prefixOf(subject))) await adapter.delete(key);\n });\n },\n };\n}\n"],"names":["statelessStore","record","_ticket","ticket","AuthError","DEFAULT_TTL","keySegment","value","c","prefixOf","subject","sub","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","keys","_a","key"],"mappings":";;AAgJO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,OAAOC,GAASD,GAAQ;AAC5B,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIE,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,IACA,MAAM,UAAU;AACd,YAAM,IAAIC;AAAA,QACR;AAAA,QACA;AAAA,MAAA;AAAA,IAEJ;AAAA,EAAA;AAEJ;AA0DA,MAAMC,IAAc,MAAS;AAM7B,SAASC,EAAWC,GAAuB;AACzC,SAAO,mBAAmBA,CAAK,EAAE;AAAA,IAC/B;AAAA,IACA,CAACC,MAAM,IAAIA,EAAE,WAAW,CAAC,EAAE,SAAS,EAAE,EAAE,aAAa;AAAA,EAAA;AAEzD;AAGA,SAASC,EAASC,GAAiC;AACjD,QAAMC,IAAM,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AACtC,SAAOA,EAAQ,QAAQ,SAAYC,IAAM,GAAGA,CAAG,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AAC3E;AAMA,SAASE,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AAsDO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOX,GACpBa,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIlB,GAAQ;AAIhB,YAAME,IAAS,GAAGM,EAAS,EAAE,KAAKR,EAAO,QAAQ,KAAK,IAAI,KAAKA,EAAO,OAAO,GAAA,CAAI,CAAC,GAAGW,GAAc;AACnG,mBAAMM,EAAQ,MAAMH,EAAQ,MAAMZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,GAC/Dd;AAAA,IACT;AAAA,IAEA,MAAM,OAAOA,GAAQF,GAAQ;AAE3B,aADiB,MAAMiB,EAAQ,MAAMH,EAAQ,QAAQZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,IACvEd,IAAS;AAAA,IAC7B;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMI,IAAQ,MAAMW,EAAQ,MAAMH,EAAQ,KAAKZ,CAAM,CAAC;AACtD,UAAII,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKJ,GAAQ;AACjB,YAAMe,EAAQ,MAAMH,EAAQ,OAAOZ,CAAM,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,QAAQO,GAAS;;AACrB,YAAMW,KAAOC,IAAAP,EAAQ,SAAR,gBAAAO,EAAc,KAAKP;AAChC,UAAIM,MAAS;AACX,cAAM,IAAIjB;AAAA,UACR;AAAA,UACA;AAAA,QAAA;AAGJ,YAAMc,EAAQ,YAAY;AACxB,yBAAiBK,KAAOF,EAAKZ,EAASC,CAAO,CAAC,EAAG,OAAMK,EAAQ,OAAOQ,CAAG;AAAA,MAC3E,CAAC;AAAA,IACH;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport { AuthError, type Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * a product that enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The IdP's session id, the ID token's `sid`, kept across refreshes.\n *\n * It is what a back-channel logout names when one browser session ends at Keycloak rather than\n * every session the person holds, and {@link ticketStore} writes it into the ticket for that\n * reason. Absent on a realm that does not emit it, and then a logout can only end them all.\n */\n readonly sid?: string;\n /**\n * The session's access token: what an exchange presents for a resource server's token, and\n * never forwarded itself — it names every organization the person belongs to and no API.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to exchange, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in, and {@link Session.expiresAt} is this same\n * number: the moment the next renewal is due.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\n/**\n * Who a back-channel logout names: a person, and optionally one of their IdP sessions.\n *\n * The shape of OpenID Connect Back-Channel Logout 1.0's own claims, `sub` and `sid`.\n */\nexport interface SessionSubject {\n readonly sub: string;\n readonly sid?: string;\n}\n\n/**\n * Where sessions live between requests. A stable ticket, updated in place — Duende BFF's server-side\n * session — rather than a new ticket per renewal.\n *\n * The ticket is issued **once, at sign-in**, which is the session-fixation defence: whatever cookie\n * a browser arrived at the callback with, it leaves with a ticket nobody has seen before. After\n * that the ticket names the session for its whole life, and a renewal changes what the row holds\n * rather than which row it is. That is what lets a renewal that happens in the proxy leave the\n * cookie alone, and what lets two tabs renewing at once agree on the session they share.\n */\nexport interface SessionStore {\n /**\n * Store a record from a sign-in and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /**\n * Replace the record behind `ticket` after a renewal, and return the ticket to carry from now on\n * — or `null` when there is no longer a session to replace.\n *\n * A store with server state answers `ticket` itself and the cookie does not change. A store whose\n * ticket *is* the record — {@link statelessStore} — has no row to update and answers a new\n * ticket, which is the cookie being re-sealed.\n *\n * **The write is conditional.** A back-channel logout may delete the row while a renewal is in\n * flight; an unconditional write would bring the ended session back. `null` is the renewal\n * learning it lost that race, and the session ends instead.\n */\n update(ticket: string, record: SessionRecord): Promise<string | null>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n /**\n * Forget every session of `sub`, or only the one Keycloak calls `sid` — a back-channel logout.\n *\n * A store that cannot find a person's sessions rejects with `AuthError` `session/irrevocable`\n * rather than answering as though it had: a logout that ends nothing must not report success to\n * the identity provider.\n */\n dropAll(subject: SessionSubject): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing**, and\n * **`dropAll` refuses** with `session/irrevocable`. Signing out clears the cookie, which is enough\n * for the person holding the browser and is not enough for anyone else — a copy of that cookie\n * taken beforehand keeps working until it expires. A session lifetime is therefore a real security\n * parameter under this store, and neither \"sign out everywhere\" nor a back-channel logout is\n * implementable on top of it — Auth0's SDK requires a session store for back-channel logout for\n * the same reason. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * `update` re-seals: the record changed, so the ticket that *is* the record changes with it, and\n * the cookie carrying it is reissued.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async update(_ticket, record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n async dropAll() {\n throw new AuthError(\n \"session/irrevocable\",\n \"a stateless session lives in the cookie, so no server can end it: give relyingParty a ticketStore\",\n );\n },\n };\n}\n\n/**\n * What a store needs from a deployment's own database — read, write, a conditional replace and a\n * delete — and a fifth that only a back-channel logout needs.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n /**\n * Overwrite `key` only if it still exists, and say whether it did — Redis `SET … XX`, SQL\n * `UPDATE … WHERE key = ?` and its row count.\n *\n * One atomic step, because the check and the write must not have a gap between them: that gap\n * is where a back-channel logout's delete lands and a renewal's write undoes it.\n */\n replace(key: string, value: string, ttl: number): Promise<boolean>;\n delete(key: string): Promise<void>;\n /**\n * Every key that begins with `prefix` — Redis `SCAN MATCH <prefix>*`, SQL `LIKE '<prefix>%'`.\n *\n * Optional, because only `dropAll` uses it; without it a back-channel logout answers that it\n * cannot end anything. The prefix never contains a glob metacharacter — see\n * {@link ticketStore} — so the Redis pattern is literal as written. A SQL adapter still escapes\n * `%` and `_`, which percent-encoding produces.\n */\n keys?(prefix: string): AsyncIterable<string>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * `encodeURIComponent`, and the five characters it leaves alone that a glob or a pattern reads:\n * `!`, `'`, `(`, `)` and `*`. A subject spelled `*` must not become a prefix that matches everyone.\n */\nfunction keySegment(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\n/** Where a person's tickets begin, or one IdP session's: `<sub>:` and `<sub>:<sid>:`. */\nfunction prefixOf(subject: SessionSubject): string {\n const sub = `${keySegment(subject.sub)}:`;\n return subject.sid === undefined ? sub : `${sub}${keySegment(subject.sid)}:`;\n}\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * kanzoAuth(() => ({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * replace: async (key, value, ttl) => (await redis.set(key, value, { XX: true, EX: ttl })) === \"OK\",\n * delete: (key) => redis.del(key),\n * // node-redis 5 yields a batch of keys per SCAN step\n * async *keys(prefix) {\n * for await (const batch of redis.scanIterator({ MATCH: `${prefix}*` })) yield* batch;\n * },\n * }),\n * }));\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject and the IdP session, and that is deliberate\n *\n * A ticket is `<sub>:<sid>:<random>`, each part percent-encoded. The random part is the whole of\n * the security — the subject and session id are not secrets and are not trusted on the way back\n * in, because the record they name is read from the row and never from the key. What the prefix\n * buys is the one operation a flat random key makes impossible: *every session belonging to this\n * person*, or *the one Keycloak just ended*. That is `dropAll`, which a back-channel logout calls,\n * and it is a prefix scan through the adapter's `keys` — the same `SCAN sub:*` a deployment could\n * write by hand for \"sign out on every device\".\n *\n * ## `update` keeps the ticket\n *\n * A renewal rewrites the row under the same key, so the cookie that names it is untouched. It goes\n * through `replace`, never `write`, so a row a back-channel logout deleted stays deleted. It also\n * restarts the row's `ttl`, which can then outlive the cookie by up to one `ttl`; that is a row the\n * adapter forgets later, never a session, because nothing can present the expired cookie.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // Encoded per part, not as a whole key: a `sub` is a uuid on every realm anyone has seen, and\n // on the one that makes it something with a colon in it the prefix must still be the prefix.\n // The random part needs no encoding — base64url is already key-safe.\n const ticket = `${prefixOf({ sub: record.session.user.id, sid: record.sid ?? \"\" })}${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async update(ticket, record) {\n const replaced = await bounded(() => adapter.replace(ticket, JSON.stringify(record), ttl));\n return replaced ? ticket : null;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n\n async dropAll(subject) {\n const keys = adapter.keys?.bind(adapter);\n if (keys === undefined) {\n throw new AuthError(\n \"session/irrevocable\",\n \"the ticket adapter has no `keys`, so a person's sessions cannot be found to end them\",\n );\n }\n await bounded(async () => {\n for await (const key of keys(prefixOf(subject))) await adapter.delete(key);\n });\n },\n };\n}\n"],"names":["statelessStore","record","_ticket","ticket","AuthError","DEFAULT_TTL","keySegment","value","c","prefixOf","subject","sub","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","keys","_a","key"],"mappings":";;AAiJO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,OAAOC,GAASD,GAAQ;AAC5B,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIE,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,IACA,MAAM,UAAU;AACd,YAAM,IAAIC;AAAA,QACR;AAAA,QACA;AAAA,MAAA;AAAA,IAEJ;AAAA,EAAA;AAEJ;AA0DA,MAAMC,IAAc,MAAS;AAM7B,SAASC,EAAWC,GAAuB;AACzC,SAAO,mBAAmBA,CAAK,EAAE;AAAA,IAC/B;AAAA,IACA,CAACC,MAAM,IAAIA,EAAE,WAAW,CAAC,EAAE,SAAS,EAAE,EAAE,aAAa;AAAA,EAAA;AAEzD;AAGA,SAASC,EAASC,GAAiC;AACjD,QAAMC,IAAM,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AACtC,SAAOA,EAAQ,QAAQ,SAAYC,IAAM,GAAGA,CAAG,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AAC3E;AAMA,SAASE,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AAsDO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOX,GACpBa,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIlB,GAAQ;AAIhB,YAAME,IAAS,GAAGM,EAAS,EAAE,KAAKR,EAAO,QAAQ,KAAK,IAAI,KAAKA,EAAO,OAAO,GAAA,CAAI,CAAC,GAAGW,GAAc;AACnG,mBAAMM,EAAQ,MAAMH,EAAQ,MAAMZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,GAC/Dd;AAAA,IACT;AAAA,IAEA,MAAM,OAAOA,GAAQF,GAAQ;AAE3B,aADiB,MAAMiB,EAAQ,MAAMH,EAAQ,QAAQZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,IACvEd,IAAS;AAAA,IAC7B;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMI,IAAQ,MAAMW,EAAQ,MAAMH,EAAQ,KAAKZ,CAAM,CAAC;AACtD,UAAII,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKJ,GAAQ;AACjB,YAAMe,EAAQ,MAAMH,EAAQ,OAAOZ,CAAM,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,QAAQO,GAAS;;AACrB,YAAMW,KAAOC,IAAAP,EAAQ,SAAR,gBAAAO,EAAc,KAAKP;AAChC,UAAIM,MAAS;AACX,cAAM,IAAIjB;AAAA,UACR;AAAA,UACA;AAAA,QAAA;AAGJ,YAAMc,EAAQ,YAAY;AACxB,yBAAiBK,KAAOF,EAAKZ,EAASC,CAAO,CAAC,EAAG,OAAMK,EAAQ,OAAOQ,CAAG;AAAA,MAC3E,CAAC;AAAA,IACH;AAAA,EAAA;AAEJ;"}
package/dist/types.d.ts CHANGED
@@ -128,6 +128,11 @@ export type AuthErrorCode =
128
128
  * organization" rather than let an unreadable 400 arrive at someone who typed a link wrong.
129
129
  */
130
130
  | "organization/invalid"
131
+ /**
132
+ * The realm would not issue a token for the organization asked for: the person is not a member of
133
+ * it. A resource server is never called with a token that names no organization in its place.
134
+ */
135
+ | "organization/denied"
131
136
  /** The callback's `state` is absent, different, or has no transaction to match against. */
132
137
  | "callback/state-mismatch"
133
138
  /** The ID token's `nonce` is not the one that was sent — a replay. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mGAAmG;AACnG,MAAM,WAAW,QAAQ;IACvB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,CAAC;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,IAAI;IACnB,kFAAkF;IAClF,UAAU,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5C,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,iGAAiG;IACjG,QAAQ,CAAC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB;;;GAGG;GACD,qBAAqB;AACvB;;;;GAIG;GACD,gBAAgB;AAClB;;;;;;GAMG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B;;;GAGG;GACD,uBAAuB;AACzB;;;GAGG;GACD,eAAe;AACjB;;;;GAIG;GACD,qBAAqB;AACvB,wGAAwG;GACtG,iBAAiB;AACnB,+DAA+D;GAC7D,YAAY,CAAC;AAEjB,qBAAa,SAAU,SAAQ,KAAK;IAGhC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAE5B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;IAJtE,SAAkB,IAAI,eAAe;gBAE1B,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM,EACN,IAAI,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO,EACzE,OAAO,CAAC,EAAE,YAAY;CAIzB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mGAAmG;AACnG,MAAM,WAAW,QAAQ;IACvB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,CAAC;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,IAAI;IACnB,kFAAkF;IAClF,UAAU,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5C,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,iGAAiG;IACjG,QAAQ,CAAC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB;;;GAGG;GACD,qBAAqB;AACvB;;;;GAIG;GACD,gBAAgB;AAClB;;;;;;GAMG;GACD,sBAAsB;AACxB;;;GAGG;GACD,qBAAqB;AACvB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B;;;GAGG;GACD,uBAAuB;AACzB;;;GAGG;GACD,eAAe;AACjB;;;;GAIG;GACD,qBAAqB;AACvB,wGAAwG;GACtG,iBAAiB;AACnB,+DAA+D;GAC7D,YAAY,CAAC;AAEjB,qBAAa,SAAU,SAAQ,KAAK;IAGhC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAE5B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;IAJtE,SAAkB,IAAI,eAAe;gBAE1B,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM,EACN,IAAI,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO,EACzE,OAAO,CAAC,EAAE,YAAY;CAIzB"}
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../src/types.ts"],"sourcesContent":["/**\n * What a session is, and nothing else. No React, no fetch, no Keycloak — those are `claims.ts`'s\n * problem and the doors'. A consumer reading one file to learn the model should read this one.\n */\n\n/** The person. Every field but `id` is optional because a realm decides which scopes it grants. */\nexport interface AuthUser {\n /** The IdP's stable subject (`sub`). Never an email: an email can be reassigned. */\n readonly id: string;\n readonly email?: string;\n readonly name?: string;\n readonly username?: string;\n}\n\n/**\n * One organization the person belongs to, with the roles they hold *inside it*.\n *\n * `alias` is the addressable name — the one in a hostname and in a Keycloak scope. `id` is the\n * stable uuid, present only when the realm's organization mapper is configured to include it, and\n * is the one to store: an alias can be renamed.\n */\nexport interface Organization {\n readonly alias: string;\n readonly id?: string;\n readonly roles: readonly string[];\n}\n\n/**\n * The whole of what the client knows about who is signed in.\n *\n * **Membership and the current organization are two different things, and only the first is\n * stored.** Membership is stable and comes from the token. Which organization a request is *in* is\n * a property of that request — its host, its path, a cookie — so `organization` is resolved per\n * request by the product's resolver and never written into the session record. That is what lets\n * two tabs sit in two organizations at once: there is no shared value for them to fight over.\n *\n * `roles` are the realm and client roles: global to the person. Roles held inside an organization\n * live on that `Organization`. They are kept apart on purpose, because merging them is how a role\n * granted in one organization comes to authorise something in another.\n */\nexport interface Session {\n readonly user: AuthUser;\n readonly roles: readonly string[];\n readonly organizations: readonly Organization[];\n /**\n * The alias of the organization this request addresses, as the product's resolver answered it.\n *\n * An address, not a proof of membership: `can` answers `false` for an organization the person\n * does not belong to, which is how a product tells \"not a member here\" from \"no tenant\".\n */\n readonly organization?: string;\n /**\n * Epoch milliseconds: when the access token expires, which is when the next renewal is due. The\n * client never uses it to decide access.\n */\n readonly expiresAt: number;\n}\n\n/** Where to come back to, and which organization to ask for, when sending someone to the IdP. */\nexport interface SignInOptions {\n /** Defaults to the current URL. */\n readonly returnTo?: string;\n /**\n * Ask Keycloak for one organization's scope in place of `organization:*`, which every sign-in\n * otherwise requests — plain `organization` would make Keycloak prompt for a choice.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds in the browser: the seam between the hooks and the session behind them.\n *\n * RFC 10017 names three architectures for browser applications, and this package implements the\n * one it recommends for business applications: a **Backend For Frontend**, where the token never\n * reaches the browser and a cookie carries the session. `bffAuth` is that implementation. The\n * interface stays an interface so `useSession`, `Gate` and a test double are written against what\n * a session *does*, not against the transport underneath.\n */\nexport interface Auth {\n /** The session now, or `null`. Answers from cache and renews when near expiry. */\n getSession(): Promise<Session | null>;\n /**\n * Call `onChange` when the session does — signed in, signed out, renewed, or changed in another\n * tab. Returns the unsubscribe.\n */\n subscribe(onChange: () => void): () => void;\n signIn(options?: SignInOptions): Promise<void>;\n signOut(options?: { readonly returnTo?: string }): Promise<void>;\n /** A `fetch` that stays authenticated: the cookie rides along, and one retry after a renewal. */\n readonly fetch: typeof globalThis.fetch;\n}\n\n/**\n * Why a credential was refused, or who did not answer when one was asked for, as a code a product\n * can route on.\n *\n * A boolean cannot be acted upon: \"not signed in\" sends the person to the IdP, \"signed in but not a\n * member\" sends them to a page that says so, and \"the IdP did not answer\" sends them nowhere — it is\n * an outage to name, and sending them to sign in is the loop that hides it. The codes are\n * `area/kind`, the grammar one host registry keys fossil's codes, the rest of kanzo-ui's and its own\n * server's in.\n *\n * **A programming or deployment fault is not one**: `useSession` called outside its provider, or a\n * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would\n * invite a product to `catch` them beside a refusal and route them to a sign-in page, which is the\n * wrong answer to \"you wired this up wrong\".\n *\n * *What would reverse it:* a product needing to route on one of those programmatically rather than\n * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.\n */\nexport type AuthErrorCode =\n /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */\n | \"claims/no-subject\"\n /** There is no session. The person has not signed in, or it expired. */\n | \"session/absent\"\n /**\n * The session could not be read: the session store failed, or the BFF's session endpoint answered\n * something other than a session or a 401. `data.status` is that answer's status.\n */\n | \"session/unavailable\"\n /**\n * The session did not answer within `data.after` milliseconds: in the browser, the BFF's session\n * endpoint; on the server, a `ticketStore`'s adapter — reported there as the cause of a\n * `session/unavailable`.\n */\n | \"session/silent\"\n /**\n * The organization asked for is not an alias, so it was not put into a scope.\n *\n * The value reaches `begin` from a query parameter on every product with an organization\n * switcher: a space in it is scope injection, and a product wants to answer \"no such\n * organization\" rather than let an unreadable 400 arrive at someone who typed a link wrong.\n */\n | \"organization/invalid\"\n /** The callback's `state` is absent, different, or has no transaction to match against. */\n | \"callback/state-mismatch\"\n /** The ID token's `nonce` is not the one that was sent — a replay. */\n | \"callback/nonce-mismatch\"\n /**\n * The token endpoint refused the authorization code or answered with something unusable — no ID\n * token, no access token, a client it does not recognise. A deployment fault or a replayed code.\n */\n | \"token/exchange-failed\"\n /**\n * A token was refused and the session is over: the IdP answered `invalid_grant` to the refresh\n * token (its SSO session went idle, or was ended), or a back-channel logout token did not verify.\n */\n | \"token/refused\"\n /**\n * The session store cannot end a session from the server: a stateless store keeps the session in\n * the cookie, and a ticket adapter without `keys` cannot find a person's sessions. A back-channel\n * logout answers 501 for it.\n */\n | \"session/irrevocable\"\n /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */\n | \"idp/unreachable\"\n /** The IdP did not answer within `data.after` milliseconds. */\n | \"idp/silent\";\n\nexport class AuthError extends Error {\n override readonly name = \"AuthError\";\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n readonly data: { readonly after?: number; readonly status?: number } = {},\n options?: ErrorOptions,\n ) {\n super(message, options);\n }\n}\n"],"names":["AuthError","code","message","data","options","__publicField"],"mappings":";;;AA+JO,MAAMA,UAAkB,MAAM;AAAA,EAEnC,YACWC,GACTC,GACSC,IAA8D,CAAA,GACvEC,GACA;AACA,UAAMF,GAASE,CAAO;AAPN,IAAAC,EAAA,cAAO;AAEd,SAAA,OAAAJ,GAEA,KAAA,OAAAE;AAAA,EAIX;AACF;"}
1
+ {"version":3,"file":"types.js","sources":["../src/types.ts"],"sourcesContent":["/**\n * What a session is, and nothing else. No React, no fetch, no Keycloak — those are `claims.ts`'s\n * problem and the doors'. A consumer reading one file to learn the model should read this one.\n */\n\n/** The person. Every field but `id` is optional because a realm decides which scopes it grants. */\nexport interface AuthUser {\n /** The IdP's stable subject (`sub`). Never an email: an email can be reassigned. */\n readonly id: string;\n readonly email?: string;\n readonly name?: string;\n readonly username?: string;\n}\n\n/**\n * One organization the person belongs to, with the roles they hold *inside it*.\n *\n * `alias` is the addressable name — the one in a hostname and in a Keycloak scope. `id` is the\n * stable uuid, present only when the realm's organization mapper is configured to include it, and\n * is the one to store: an alias can be renamed.\n */\nexport interface Organization {\n readonly alias: string;\n readonly id?: string;\n readonly roles: readonly string[];\n}\n\n/**\n * The whole of what the client knows about who is signed in.\n *\n * **Membership and the current organization are two different things, and only the first is\n * stored.** Membership is stable and comes from the token. Which organization a request is *in* is\n * a property of that request — its host, its path, a cookie — so `organization` is resolved per\n * request by the product's resolver and never written into the session record. That is what lets\n * two tabs sit in two organizations at once: there is no shared value for them to fight over.\n *\n * `roles` are the realm and client roles: global to the person. Roles held inside an organization\n * live on that `Organization`. They are kept apart on purpose, because merging them is how a role\n * granted in one organization comes to authorise something in another.\n */\nexport interface Session {\n readonly user: AuthUser;\n readonly roles: readonly string[];\n readonly organizations: readonly Organization[];\n /**\n * The alias of the organization this request addresses, as the product's resolver answered it.\n *\n * An address, not a proof of membership: `can` answers `false` for an organization the person\n * does not belong to, which is how a product tells \"not a member here\" from \"no tenant\".\n */\n readonly organization?: string;\n /**\n * Epoch milliseconds: when the access token expires, which is when the next renewal is due. The\n * client never uses it to decide access.\n */\n readonly expiresAt: number;\n}\n\n/** Where to come back to, and which organization to ask for, when sending someone to the IdP. */\nexport interface SignInOptions {\n /** Defaults to the current URL. */\n readonly returnTo?: string;\n /**\n * Ask Keycloak for one organization's scope in place of `organization:*`, which every sign-in\n * otherwise requests — plain `organization` would make Keycloak prompt for a choice.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds in the browser: the seam between the hooks and the session behind them.\n *\n * RFC 10017 names three architectures for browser applications, and this package implements the\n * one it recommends for business applications: a **Backend For Frontend**, where the token never\n * reaches the browser and a cookie carries the session. `bffAuth` is that implementation. The\n * interface stays an interface so `useSession`, `Gate` and a test double are written against what\n * a session *does*, not against the transport underneath.\n */\nexport interface Auth {\n /** The session now, or `null`. Answers from cache and renews when near expiry. */\n getSession(): Promise<Session | null>;\n /**\n * Call `onChange` when the session does — signed in, signed out, renewed, or changed in another\n * tab. Returns the unsubscribe.\n */\n subscribe(onChange: () => void): () => void;\n signIn(options?: SignInOptions): Promise<void>;\n signOut(options?: { readonly returnTo?: string }): Promise<void>;\n /** A `fetch` that stays authenticated: the cookie rides along, and one retry after a renewal. */\n readonly fetch: typeof globalThis.fetch;\n}\n\n/**\n * Why a credential was refused, or who did not answer when one was asked for, as a code a product\n * can route on.\n *\n * A boolean cannot be acted upon: \"not signed in\" sends the person to the IdP, \"signed in but not a\n * member\" sends them to a page that says so, and \"the IdP did not answer\" sends them nowhere — it is\n * an outage to name, and sending them to sign in is the loop that hides it. The codes are\n * `area/kind`, the grammar one host registry keys fossil's codes, the rest of kanzo-ui's and its own\n * server's in.\n *\n * **A programming or deployment fault is not one**: `useSession` called outside its provider, or a\n * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would\n * invite a product to `catch` them beside a refusal and route them to a sign-in page, which is the\n * wrong answer to \"you wired this up wrong\".\n *\n * *What would reverse it:* a product needing to route on one of those programmatically rather than\n * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.\n */\nexport type AuthErrorCode =\n /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */\n | \"claims/no-subject\"\n /** There is no session. The person has not signed in, or it expired. */\n | \"session/absent\"\n /**\n * The session could not be read: the session store failed, or the BFF's session endpoint answered\n * something other than a session or a 401. `data.status` is that answer's status.\n */\n | \"session/unavailable\"\n /**\n * The session did not answer within `data.after` milliseconds: in the browser, the BFF's session\n * endpoint; on the server, a `ticketStore`'s adapter — reported there as the cause of a\n * `session/unavailable`.\n */\n | \"session/silent\"\n /**\n * The organization asked for is not an alias, so it was not put into a scope.\n *\n * The value reaches `begin` from a query parameter on every product with an organization\n * switcher: a space in it is scope injection, and a product wants to answer \"no such\n * organization\" rather than let an unreadable 400 arrive at someone who typed a link wrong.\n */\n | \"organization/invalid\"\n /**\n * The realm would not issue a token for the organization asked for: the person is not a member of\n * it. A resource server is never called with a token that names no organization in its place.\n */\n | \"organization/denied\"\n /** The callback's `state` is absent, different, or has no transaction to match against. */\n | \"callback/state-mismatch\"\n /** The ID token's `nonce` is not the one that was sent — a replay. */\n | \"callback/nonce-mismatch\"\n /**\n * The token endpoint refused the authorization code or answered with something unusable — no ID\n * token, no access token, a client it does not recognise. A deployment fault or a replayed code.\n */\n | \"token/exchange-failed\"\n /**\n * A token was refused and the session is over: the IdP answered `invalid_grant` to the refresh\n * token (its SSO session went idle, or was ended), or a back-channel logout token did not verify.\n */\n | \"token/refused\"\n /**\n * The session store cannot end a session from the server: a stateless store keeps the session in\n * the cookie, and a ticket adapter without `keys` cannot find a person's sessions. A back-channel\n * logout answers 501 for it.\n */\n | \"session/irrevocable\"\n /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */\n | \"idp/unreachable\"\n /** The IdP did not answer within `data.after` milliseconds. */\n | \"idp/silent\";\n\nexport class AuthError extends Error {\n override readonly name = \"AuthError\";\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n readonly data: { readonly after?: number; readonly status?: number } = {},\n options?: ErrorOptions,\n ) {\n super(message, options);\n }\n}\n"],"names":["AuthError","code","message","data","options","__publicField"],"mappings":";;;AAoKO,MAAMA,UAAkB,MAAM;AAAA,EAEnC,YACWC,GACTC,GACSC,IAA8D,CAAA,GACvEC,GACA;AACA,UAAMF,GAASE,CAAO;AAPN,IAAAC,EAAA,cAAO;AAEd,SAAA,OAAAJ,GAEA,KAAA,OAAAE;AAAA,EAIX;AACF;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Kanzo authentication over Keycloak — a Backend For Frontend for Next (kanzoAuth), the claim vocabulary read into one Session, and the role evaluation that knows about organizations. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude auth from the generic vocabulary by name.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "react-dom": "^19.0.0",
58
58
  "rollup-plugin-preserve-directives": "^0.4.0"
59
59
  },
60
- "//size-limit": "The root barrel was 677 B at the first commit — the claim reader, the role predicate and the types — against a 1 kB budget set deliberately tight, with a note saying the raise would be a line in the commit that landed the hooks rather than a quiet edit. This is that line: 677 B -> 2.19 kB, and the budget goes to 2.5 kB. What grew is the provider, three hooks, `Gate` and `bffAuth`, which is the whole of what a consumer imports to have a session. Every budget here is set just above its first measurement for the same reason: one with 70% headroom detects nothing. Each subpath ignores its own engine, because the consumer installs that explicitly and what is being measured is our adapter over it. What these numbers are really watching for is a door reaching through another: the root barrel must never learn a protocol, and the two Node doors must never learn React. A jump of kilobytes on any of them means one of those happened. Second raise, and it is a decision rather than a quiet edit: the session lifecycle — a refresh route, `authToken`, `authProxy`, `ticketStore` and the same-site check — moved the two Node doors, `server` 3.2 kB -> 3.4 kB (budget 3.6) and `next` 4 kB -> 4.59 kB (budget 5). What is being watched for is unchanged and still holds: neither door learned React and the barrel learned no protocol, which is why the other two numbers barely moved. Third raise, 2026-10-01, as a decision (Ángel; branch `feat/failure-guarantees`): the failure guarantees — a `failed` status carrying the thrown value, a 30 s deadline on every wait this package makes, the coded IdP and session failures and the problem-page redirect — moved all four: barrel 2.22 -> 2.6 kB (budget 2.75), `browser` 1.77 -> 2.1 kB (2.25), `server` 3.4 -> 3.73 kB (3.9), `next` 4.59 -> 5.03 kB (5.25). Same watch, still holding: no door learned another's engine; the growth is code paths that used to collapse into `anonymous` or a bare 500. Fourth change, 2026-10-05, as a decision (branch `fix/auth-return-to`, `kanzoAuth`): `./browser` is gone with its peer, and `authFetch` and `useOrganization` left the barrel, so the barrel went down, 2.6 -> 2.37 kB (budget lowered to 2.5). The two Node doors went up: `server` 3.73 -> 4.75 kB (5) for back-channel logout, a transaction cookie per `state`, renewal in place with the cross-process re-read, and the store's `update`/`dropAll`; `next` 5.03 -> 6.65 kB (7) for that plus the proxy as session authority, which replaced a middleware that only looked for a cookie. Same watch, still holding: neither Node door learned React, and the barrel learned no protocol.",
60
+ "//size-limit": "The root barrel was 677 B at the first commit — the claim reader, the role predicate and the types — against a 1 kB budget set deliberately tight, with a note saying the raise would be a line in the commit that landed the hooks rather than a quiet edit. This is that line: 677 B -> 2.19 kB, and the budget goes to 2.5 kB. What grew is the provider, three hooks, `Gate` and `bffAuth`, which is the whole of what a consumer imports to have a session. Every budget here is set just above its first measurement for the same reason: one with 70% headroom detects nothing. Each subpath ignores its own engine, because the consumer installs that explicitly and what is being measured is our adapter over it. What these numbers are really watching for is a door reaching through another: the root barrel must never learn a protocol, and the two Node doors must never learn React. A jump of kilobytes on any of them means one of those happened. Second raise, and it is a decision rather than a quiet edit: the session lifecycle — a refresh route, `authToken`, `authProxy`, `ticketStore` and the same-site check — moved the two Node doors, `server` 3.2 kB -> 3.4 kB (budget 3.6) and `next` 4 kB -> 4.59 kB (budget 5). What is being watched for is unchanged and still holds: neither door learned React and the barrel learned no protocol, which is why the other two numbers barely moved. Third raise, 2026-10-01, as a decision (Ángel; branch `feat/failure-guarantees`): the failure guarantees — a `failed` status carrying the thrown value, a 30 s deadline on every wait this package makes, the coded IdP and session failures and the problem-page redirect — moved all four: barrel 2.22 -> 2.6 kB (budget 2.75), `browser` 1.77 -> 2.1 kB (2.25), `server` 3.4 -> 3.73 kB (3.9), `next` 4.59 -> 5.03 kB (5.25). Same watch, still holding: no door learned another's engine; the growth is code paths that used to collapse into `anonymous` or a bare 500. Fourth change, 2026-10-05, as a decision (branch `fix/auth-return-to`, `kanzoAuth`): `./browser` is gone with its peer, and `authFetch` and `useOrganization` left the barrel, so the barrel went down, 2.6 -> 2.37 kB (budget lowered to 2.5). The two Node doors went up: `server` 3.73 -> 4.75 kB (5) for back-channel logout, a transaction cookie per `state`, renewal in place with the cross-process re-read, and the store's `update`/`dropAll`; `next` 5.03 -> 6.65 kB (7) for that plus the proxy as session authority, which replaced a middleware that only looked for a cookie. Same watch, still holding: neither Node door learned React, and the barrel learned no protocol. Fifth raise, 2026-10-06, as a decision (Ángel; PR #97, one credential model): the BFF exchanges the session's token per call (RFC 8693) for one naming one API and one organization, caches it per audience and organization, and `apis` mounts several upstreams. `server` 4.78 -> 5.25 kB (5.5), `next` 6.71 -> 7.29 kB (7.5); the barrel is unchanged at 2.37 kB. Same watch, still holding: neither Node door learned React, and the barrel learned no protocol.",
61
61
  "size-limit": [
62
62
  {
63
63
  "name": "root barrel (JS)",
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "name": "server subpath (JS)",
74
74
  "path": "dist/server.js",
75
- "limit": "5 kB",
75
+ "limit": "5.5 kB",
76
76
  "ignore": [
77
77
  "react",
78
78
  "react-dom",
@@ -84,7 +84,7 @@
84
84
  {
85
85
  "name": "next subpath (JS)",
86
86
  "path": "dist/next.js",
87
- "limit": "7 kB",
87
+ "limit": "7.5 kB",
88
88
  "ignore": [
89
89
  "react",
90
90
  "react-dom",