@kanzo-tech/auth 0.18.0 → 0.19.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.
Files changed (49) hide show
  1. package/dist/auth-context.d.ts +3 -1
  2. package/dist/auth-context.d.ts.map +1 -1
  3. package/dist/auth-context.js.map +1 -1
  4. package/dist/auth-provider.js +19 -18
  5. package/dist/auth-provider.js.map +1 -1
  6. package/dist/bff-auth.d.ts +2 -1
  7. package/dist/bff-auth.d.ts.map +1 -1
  8. package/dist/bff-auth.js +77 -46
  9. package/dist/bff-auth.js.map +1 -1
  10. package/dist/browser.d.ts.map +1 -1
  11. package/dist/browser.js +65 -42
  12. package/dist/browser.js.map +1 -1
  13. package/dist/claims.js +1 -1
  14. package/dist/claims.js.map +1 -1
  15. package/dist/cookie-session.d.ts.map +1 -1
  16. package/dist/cookie-session.js +1 -1
  17. package/dist/cookie-session.js.map +1 -1
  18. package/dist/deadline.d.ts +14 -0
  19. package/dist/deadline.d.ts.map +1 -0
  20. package/dist/deadline.js +17 -0
  21. package/dist/deadline.js.map +1 -0
  22. package/dist/gate.js +9 -9
  23. package/dist/gate.js.map +1 -1
  24. package/dist/issuer.d.ts +0 -2
  25. package/dist/issuer.d.ts.map +1 -1
  26. package/dist/issuer.js +17 -17
  27. package/dist/issuer.js.map +1 -1
  28. package/dist/next-middleware.d.ts +6 -0
  29. package/dist/next-middleware.d.ts.map +1 -1
  30. package/dist/next-middleware.js +15 -13
  31. package/dist/next-middleware.js.map +1 -1
  32. package/dist/next-proxy.d.ts.map +1 -1
  33. package/dist/next-proxy.js +27 -26
  34. package/dist/next-proxy.js.map +1 -1
  35. package/dist/next-routes.d.ts +9 -0
  36. package/dist/next-routes.d.ts.map +1 -1
  37. package/dist/next-routes.js +66 -46
  38. package/dist/next-routes.js.map +1 -1
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +151 -109
  41. package/dist/server.js.map +1 -1
  42. package/dist/types.d.ts +40 -21
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +9 -4
  45. package/dist/types.js.map +1 -1
  46. package/dist/use-session.d.ts +2 -1
  47. package/dist/use-session.d.ts.map +1 -1
  48. package/dist/use-session.js.map +1 -1
  49. package/package.json +6 -6
@@ -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 { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\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\nconst DEFAULT_SCOPE = \"openid profile email\";\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 * 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 const error = new AuthError(code, message);\n if (cause !== undefined) error.cause = cause;\n throw error;\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. keasy's Rust learned this and 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 * 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. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\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/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant 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 /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate 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 /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\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. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\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\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 the 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 = config.store ?? statelessStore();\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 // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Adopted> => {\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 next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === 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\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\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 const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session.absent\", \"there is no session cookie to refresh\");\n }\n const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session.absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await provider.configuration(), spent);\n } catch (error) {\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; the slot above exists to keep us from causing it.\n refuse(\"token.exchange-failed\", \"the refresh token was refused\", error);\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n });\n };\n\n return {\n async begin(options = {}) {\n const configuration = await provider.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: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${options.organization}`,\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(configuration, parameters).href,\n cookies: [\n await transaction.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback.state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback.state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await provider.configuration());\n } catch (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 refuse(\"token.exchange-failed\", \"the authorization code could not be exchanged\", error);\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 refuse(\n \"token.exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n retried,\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. keasy's Rust calls\n // `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const { renewed } = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\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 (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token.exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.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 (record?.idToken !== undefined) parameters[\"id_token_hint\"] = 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 provider.configuration(), parameters).href,\n cookies: [session.clear()],\n };\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 TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","error","AuthError","codeOf","isNonceMismatch","node","depth","isStaleKeyMaterial","ORGANIZATION","renewals","keyedSingleFlight","relyingParty","config","provider","issuer","store","statelessStore","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","record","ticket","renew","spent","refreshTokenGrant","options","configuration","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":"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"}
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 { 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\nconst DEFAULT_SCOPE = \"openid profile email\";\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 * 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. keasy's Rust learned this and 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`.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\") return \"idp/silent\";\n if (code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\") return \"idp/unreachable\";\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\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. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const fail = (error: unknown): never =>\n refuse(\"session/unavailable\", \"the session store did not answer\", error);\n return {\n async put(record) {\n try {\n return await store.put(record);\n } catch (error) {\n return fail(error);\n }\n },\n async get(ticket) {\n try {\n return await store.get(ticket);\n } catch (error) {\n return fail(error);\n }\n },\n async drop(ticket) {\n try {\n await store.drop(ticket);\n } catch (error) {\n fail(error);\n }\n },\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/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant 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 /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate 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 /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\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. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\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\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 the 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 // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Adopted> => {\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 next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === 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\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\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 const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session/absent\", \"there is no session cookie to refresh\");\n }\n const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session/absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; the slot above exists to keep us from causing it. An IdP that did not answer has\n // refused nothing, and says so.\n throw fromIdp(error, \"token/exchange-failed\", \"the refresh token was refused\");\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n });\n };\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: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${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.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, 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. keasy's Rust calls\n // `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const { renewed } = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\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 (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.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 (record?.idToken !== undefined) parameters[\"id_token_hint\"] = 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}\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 TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","fail","record","ticket","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","renew","spent","refreshTokenGrant","options","discovered","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":";;;;;;;;;;;AA6CA,MAAMA,IAAgB,wBAEhBC,IAAkB,MAAS,IAE3BC,IAAsB,KAStBC,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;AAOA,SAASK,EAAWL,GAA8D;AAChF,QAAML,IAAOI,EAAOC,CAAK;AACzB,MAAIL,MAAS,gBAAiB,QAAO;AAErC,MADIA,MAAS,mCACTK,aAAiB,aAAaL,MAAS,OAAW,QAAO;AAE/D;AAeA,MAAMW,IAAe,0DAgBfC,IAAWC,EAAA;AAGjB,SAASC,EAAUC,GAAmC;AACpD,QAAMC,IAAO,CAACX,MACZN,EAAO,uBAAuB,oCAAoCM,CAAK;AACzE,SAAO;AAAA,IACL,MAAM,IAAIY,GAAQ;AAChB,UAAI;AACF,eAAO,MAAMF,EAAM,IAAIE,CAAM;AAAA,MAC/B,SAASZ,GAAO;AACd,eAAOW,EAAKX,CAAK;AAAA,MACnB;AAAA,IACF;AAAA,IACA,MAAM,IAAIa,GAAQ;AAChB,UAAI;AACF,eAAO,MAAMH,EAAM,IAAIG,CAAM;AAAA,MAC/B,SAASb,GAAO;AACd,eAAOW,EAAKX,CAAK;AAAA,MACnB;AAAA,IACF;AAAA,IACA,MAAM,KAAKa,GAAQ;AACjB,UAAI;AACF,cAAMH,EAAM,KAAKG,CAAM;AAAA,MACzB,SAASb,GAAO;AACd,QAAAW,EAAKX,CAAK;AAAA,MACZ;AAAA,IACF;AAAA,EAAA;AAEJ;AAgHO,SAASc,GAAaC,GAA0C;AACrE,QAAMC,IAAWC,EAAOF,CAAM,GACxBL,IAAQD,EAAUM,EAAO,SAASG,GAAgB,GAClDC,IAAQC,GAGRC,IAAU,CAACrB,GAAgBsB,GAAyB1B,MAA+B;AACvF,UAAMD,IAAOU,EAAWL,CAAK,KAAKsB;AAClC,WAAO,IAAIxB,EAAUH,GAAMC,GAASD,MAAS,eAAe,EAAE,OAAAwB,EAAA,IAAU,CAAA,GAAI,EAAE,OAAOnB,GAAO;AAAA,EAC9F,GAGMuB,IAAgB,YAAoC;AACxD,QAAI;AACF,aAAO,MAAMP,EAAS,cAAA;AAAA,IACxB,SAAShB,GAAO;AACd,YAAMqB,EAAQrB,GAAO,mBAAmB,gDAAgD;AAAA,IAC1F;AAAA,EACF,GAEMwB,IAAuCC,EAAa;AAAA,IACxD,MAAM;AAAA,IACN,QAAQV,EAAO;AAAA,IACf,QAAQA,EAAO,UAAUxB;AAAA,EAAA,CAC1B,GAIKmC,IAAyCD,EAAa;AAAA,IAC1D,MAAM;AAAA,IACN,QAAQV,EAAO;AAAA,IACf,QAAQvB;AAAA,EAAA,CACT,GAEKmC,IAAa,OAAOC,MAAqE;AAC7F,UAAMC,IAAS,MAAML,EAAQ,KAAKI,CAAM;AACxC,WAAIC,MAAW,OAAa,OACrBnB,EAAM,IAAImB,EAAO,MAAM;AAAA,EAChC,GAGMC,IAAQ,OAAOC,GAAgBC,MAAqD;AAExF,UAAMC,IAAWF,EAAO,OAAA,GAClBG,IAAOD,MAAa,SAAYD,KAAA,gBAAAA,EAAU,UAAUG,EAAOF,GAAUlB,CAAM;AACjF,IAAImB,MAAS,UACXxC,EAAO,yBAAyB,4DAA4D;AAO9F,UAAM0C,IAAWL,EAAO,UAAA,GAElBnB,IAAwB;AAAA,MAC5B,SAASsB;AAAA,MACT,aAAaH,EAAO;AAAA,MACpB,sBAAsBK,MAAa,SAAY,SAAY,KAAK,IAAA,IAAQA,IAAW;AAAA;AAAA;AAAA,MAGnF,cAAcL,EAAO,kBAAiBC,KAAA,gBAAAA,EAAU;AAAA,MAChD,SAASD,EAAO,aAAYC,KAAA,gBAAAA,EAAU;AAAA,IAAA,GAGlCnB,IAAS,MAAMH,EAAM,IAAIE,CAAM;AACrC,WAAO,EAAE,SAAS,EAAE,SAASsB,GAAM,SAAS,CAAC,MAAMV,EAAQ,KAAK,EAAE,QAAAX,EAAA,CAAQ,CAAC,EAAA,GAAK,QAAAD,EAAA;AAAA,EAClF,GAGMyB,IAAQ,OAAOT,MAAwD;AAC3E,UAAMC,IAAS,MAAML,EAAQ,KAAKI,CAAM,GAClChB,IAASiB,MAAW,OAAO,OAAO,MAAMnB,EAAM,IAAImB,EAAO,MAAM;AACrE,KAAIA,MAAW,QAAQjB,MAAW,SAChClB,EAAO,kBAAkB,uCAAuC;AAElE,UAAM4C,IAAQ1B,EAAO;AACrB,WAAI0B,MAAU,UACZ5C,EAAO,kBAAkB,6DAA6D,GAMjFa,EAASsB,EAAO,QAAQ,YAAY;AACzC,UAAIE;AACJ,UAAI;AACF,QAAAA,IAAS,MAAMQ,EAAkB,MAAMhB,EAAA,GAAiBe,CAAK;AAAA,MAC/D,SAAStC,GAAO;AACd,cAAIA,aAAiBF,IAAiBE,IAKhCqB,EAAQrB,GAAO,yBAAyB,+BAA+B;AAAA,MAC/E;AAIA,mBAAMU,EAAM,KAAKmB,EAAO,MAAM,GACvBC,EAAMC,GAAQnB,CAAM;AAAA,IAC7B,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,MAAM,MAAM4B,IAAU,IAAI;AACxB,YAAMC,IAAa,MAAMlB,EAAA,GAEnBmB,IAAWC,EAAA,GACXC,IAAQC,EAAA,GACRC,IAAQC,EAAA;AAEd,MAAIP,EAAQ,iBAAiB,UAAa,CAAClC,EAAa,KAAKkC,EAAQ,YAAY,KAC/E9C;AAAA,QACE;AAAA,QACA,KAAK8C,EAAQ,YAAY;AAAA,MAAA;AAI7B,YAAMQ,IAAqC;AAAA,QACzC,cAAcjC,EAAO;AAAA;AAAA;AAAA,QAGrB,OACEyB,EAAQ,iBAAiB,SACpBzB,EAAO,SAASzB,IACjB,GAAGyB,EAAO,SAASzB,CAAa,iBAAiBkD,EAAQ,YAAY;AAAA,QAC3E,gBAAgB,MAAMS,EAA2BP,CAAQ;AAAA,QACzD,uBAAuB;AAAA,QACvB,OAAAE;AAAA,QACA,OAAAE;AAAA,MAAA;AAGF,aAAO;AAAA,QACL,KAAKI,EAAsBT,GAAYO,CAAU,EAAE;AAAA,QACnD,SAAS;AAAA,UACP,MAAMtB,EAAY,KAAK,EAAE,OAAAkB,GAAO,OAAAE,GAAO,UAAAJ,GAAU,UAAUF,EAAQ,YAAY,IAAA,CAAK;AAAA,QAAA;AAAA,MACtF;AAAA,IAEJ;AAAA,IAEA,MAAM,SAASW,GAAS;AACtB,YAAMC,IAAU,MAAM1B,EAAY,KAAKyB,EAAQ,MAAM;AACrD,MAAIC,MAAY,QACd1D;AAAA,QACE;AAAA,QACA;AAAA,MAAA;AAIJ,YAAM2D,IAAU,IAAI,IAAIF,EAAQ,GAAG;AACnC,MAAIE,EAAQ,aAAa,IAAI,OAAO,MAAMD,EAAQ,SAChD1D;AAAA,QACE;AAAA,QACA;AAAA,MAAA;AAIJ,YAAM4D,IAAS;AAAA,QACb,kBAAkBF,EAAQ;AAAA,QAC1B,eAAeA,EAAQ;AAAA,QACvB,eAAeA,EAAQ;AAAA,MAAA,GAGnBG,IAAQ,CAAChC,MACbiC,EAAuBjC,GAAe8B,GAASC,CAAM;AAEvD,UAAIvB;AACJ,UAAI;AACF,QAAAA,IAAS,MAAMwB,EAAM,MAAMhC,GAAe;AAAA,MAC5C,SAASvB,GAAO;AACd,YAAIA,aAAiBF,EAAW,OAAME;AAQtC,YAPIC,EAAgBD,CAAK,KACvBN;AAAA,UACE;AAAA,UACA;AAAA,UACAM;AAAA,QAAA,GAGA,CAACI,EAAmBJ,CAAK;AAC3B,gBAAMqB,EAAQrB,GAAO,yBAAyB,+CAA+C;AAI/F,YAAI;AACF,UAAA+B,IAAS,MAAMwB,EAAM,MAAMvC,EAAS,YAAY;AAAA,QAClD,SAASyC,GAAS;AAChB,gBAAIxD,EAAgBwD,CAAO,KACzB/D;AAAA,YACE;AAAA,YACA;AAAA,YACA+D;AAAA,UAAA,GAGEpC;AAAA,YACJoC;AAAA,YACA;AAAA,YACA;AAAA,UAAA;AAAA,QAEJ;AAAA,MACF;AAMA,YAAM,EAAE,SAAAC,EAAA,IAAY,MAAM5B,EAAMC,GAAQ,IAAI;AAE5C,aAAO;AAAA,QACL,GAAG2B;AAAA,QACH,SAAS,CAAC,GAAGA,EAAQ,SAAShC,EAAY,OAAO;AAAA,QACjD,UAAU0B,EAAQ;AAAA,MAAA;AAAA,IAEtB;AAAA,IAEA,MAAM,KAAKxB,GAAQ;;AACjB,eAAQ+B,IAAA,MAAMhC,EAAWC,CAAM,MAAvB,gBAAA+B,EAA2B,YAAW;AAAA,IAChD;AAAA,IAEA,MAAM,MAAM/B,GAAQY,IAAU,IAAI;AAChC,YAAM5B,IAAS,MAAMe,EAAWC,CAAM;AACtC,UAAIhB,MAAW,KAAM,QAAO;AAE5B,YAAMgD,KAAUpB,EAAQ,eAAe/C,KAAwB,KACzDoE,IAAOjD,EAAO,aAGdkD,IACJlD,EAAO,yBAAyB,UAChCA,EAAO,uBAAuB,KAAK,SAASgD;AAE9C,UAAIC,MAAS,UAAa,CAACC;AACzB,eAAO,EAAE,aAAaD,GAAM,SAASjD,EAAO,SAAS,SAAS,GAAC;AAGjE,YAAM,EAAE,SAAA8C,GAAS,QAAQK,MAAU,MAAM1B,EAAMT,CAAM;AACrD,aAAImC,EAAM,gBAAgB,UACxBrE,EAAO,yBAAyB,4CAA4C,GAEvE,EAAE,aAAaqE,EAAM,aAAa,SAASL,EAAQ,SAAS,SAASA,EAAQ,QAAA;AAAA,IACtF;AAAA,IAEA,MAAM,QAAQ9B,GAAQ;AACpB,cAAQ,MAAMS,EAAMT,CAAM,GAAG;AAAA,IAC/B;AAAA,IAEA,MAAM,IAAIA,GAAQY,IAAU,IAAI;AAC9B,YAAMX,IAAS,MAAML,EAAQ,KAAKI,CAAM,GAClChB,IAASiB,MAAW,OAAO,OAAO,MAAMnB,EAAM,IAAImB,EAAO,MAAM;AACrE,MAAIA,MAAW,QAAM,MAAMnB,EAAM,KAAKmB,EAAO,MAAM;AAEnD,YAAMmB,IAAqC,CAAA,GACrCgB,IAAWxB,EAAQ,YAAYzB,EAAO;AAC5C,aAAIiD,MAAa,WAAWhB,EAAW,2BAA8BgB,KAGjEpD,KAAA,gBAAAA,EAAQ,aAAY,WAAWoC,EAAW,gBAAmBpC,EAAO,UAIjE;AAAA,QACL,KAAKqD,EAAmB,MAAM1C,EAAA,GAAiByB,CAAU,EAAE;AAAA,QAC3D,SAAS,CAACxB,EAAQ,MAAA,CAAO;AAAA,MAAA;AAAA,IAE7B;AAAA,EAAA;AAEJ;"}
package/dist/types.d.ts CHANGED
@@ -82,46 +82,65 @@ export interface Auth {
82
82
  readonly fetch: typeof globalThis.fetch;
83
83
  }
84
84
  /**
85
- * Why a credential was refused, as a code a product can route on.
85
+ * Why a credential was refused, or who did not answer when one was asked for, as a code a product
86
+ * can route on.
86
87
  *
87
88
  * A boolean cannot be acted upon: "not signed in" sends the person to the IdP, "signed in but not a
88
- * member" sends them to a page that says so, and telling them apart is the difference between a
89
- * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports
90
- * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.
89
+ * member" sends them to a page that says so, and "the IdP did not answer" sends them nowhere — it is
90
+ * an outage to name, and sending them to sign in is the loop that hides it. The codes are
91
+ * `area/kind`, the grammar one host registry keys fossil's codes, the rest of kanzo-ui's and its own
92
+ * server's in.
91
93
  *
92
- * **These codes are about a credential, or about the request for one, and about nothing else.** A
93
- * programming or deployment fault is not one: `useSession` called outside its provider, or a
94
- * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would invite a product to `catch` them
95
- * beside a refusal and route them to a sign-in page, which is the wrong answer to "you wired this
96
- * up wrong" — and it would put a deployment mistake in the same type as a user's session expiring.
94
+ * **A programming or deployment fault is not one**: `useSession` called outside its provider, or a
95
+ * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would
96
+ * invite a product to `catch` them beside a refusal and route them to a sign-in page, which is the
97
+ * wrong answer to "you wired this up wrong".
97
98
  *
98
99
  * *What would reverse it:* a product needing to route on one of those programmatically rather than
99
100
  * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.
100
101
  */
101
102
  export type AuthErrorCode =
102
103
  /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */
103
- "claims.no-subject"
104
+ "claims/no-subject"
104
105
  /** There is no session. The person has not signed in, or it expired. */
105
- | "session.absent"
106
+ | "session/absent"
107
+ /**
108
+ * The session could not be read: the session store failed, or the BFF's session endpoint answered
109
+ * something other than a session or a 401. `data.status` is that answer's status.
110
+ */
111
+ | "session/unavailable"
112
+ /** The BFF's session endpoint did not answer within `data.after` milliseconds. */
113
+ | "session/silent"
106
114
  /** Signed in, but holds no membership of the organization being addressed. */
107
- | "organization.not-a-member"
115
+ | "organization/not-a-member"
108
116
  /**
109
117
  * The organization asked for is not an alias, so it was not put into a scope.
110
118
  *
111
- * The one code here about the *request for* a credential rather than about a credential, and it
112
- * earns that because the value reaches `begin` from a query parameter on every product with an
113
- * organization switcher: a space in it is scope injection, and a product wants to answer "no
114
- * such organization" rather than let an unreadable 400 arrive at someone who typed a link wrong.
119
+ * The value reaches `begin` from a query parameter on every product with an organization
120
+ * switcher: a space in it is scope injection, and a product wants to answer "no such
121
+ * organization" rather than let an unreadable 400 arrive at someone who typed a link wrong.
115
122
  */
116
- | "organization.invalid"
123
+ | "organization/invalid"
117
124
  /** The callback's `state` is absent, different, or has no transaction to match against. */
118
- | "callback.state-mismatch"
125
+ | "callback/state-mismatch"
119
126
  /** The ID token's `nonce` is not the one that was sent — a replay. */
120
- | "callback.nonce-mismatch"
127
+ | "callback/nonce-mismatch"
121
128
  /** The token endpoint refused the code or the refresh token, or returned no ID token. */
122
- | "token.exchange-failed";
129
+ | "token/exchange-failed"
130
+ /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */
131
+ | "idp/unreachable"
132
+ /** The IdP did not answer within `data.after` milliseconds. */
133
+ | "idp/silent";
123
134
  export declare class AuthError extends Error {
124
135
  readonly code: AuthErrorCode;
125
- constructor(code: AuthErrorCode, message: string);
136
+ readonly data: {
137
+ readonly after?: number;
138
+ readonly status?: number;
139
+ };
140
+ readonly name = "AuthError";
141
+ constructor(code: AuthErrorCode, message: string, data?: {
142
+ readonly after?: number;
143
+ readonly status?: number;
144
+ }, options?: ErrorOptions);
126
145
  }
127
146
  //# sourceMappingURL=types.d.ts.map
@@ -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;;;;;;;;;;;GAWG;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,uFAAuF;IACvF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;;;GAUG;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;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB,8EAA8E;GAC5E,2BAA2B;AAC7B;;;;;;;GAOG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B,yFAAyF;GACvF,uBAAuB,CAAC;AAE5B,qBAAa,SAAU,SAAQ,KAAK;IAEhC,QAAQ,CAAC,IAAI,EAAE,aAAa;gBAAnB,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM;CAKlB"}
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;;;;;;;;;;;GAWG;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,uFAAuF;IACvF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;;;GAUG;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;;;OAGG;IACH,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,kFAAkF;GAChF,gBAAgB;AAClB,8EAA8E;GAC5E,2BAA2B;AAC7B;;;;;;GAMG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B,yFAAyF;GACvF,uBAAuB;AACzB,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 CHANGED
@@ -1,9 +1,14 @@
1
- class s extends Error {
2
- constructor(r, t) {
3
- super(t), this.code = r, this.name = "AuthError";
1
+ var h = Object.defineProperty;
2
+ var u = (t, r, o) => r in t ? h(t, r, { enumerable: !0, configurable: !0, writable: !0, value: o }) : t[r] = o;
3
+ var s = (t, r, o) => u(t, typeof r != "symbol" ? r + "" : r, o);
4
+ class n extends Error {
5
+ constructor(o, e, a = {}, c) {
6
+ super(e, c);
7
+ s(this, "name", "AuthError");
8
+ this.code = o, this.data = a;
4
9
  }
5
10
  }
6
11
  export {
7
- s as AuthError
12
+ n as AuthError
8
13
  };
9
14
  //# sourceMappingURL=types.js.map
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 * **There is no active organization here, and the absence is the design.** Membership is stable and\n * comes from the token; which organization you are *looking at* is a property of the request — the\n * URL — and deriving it per request is what lets two tabs sit in two organizations at once. A field\n * here would be the single shared value they would 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 /** Epoch milliseconds. The client uses it to refresh early, never to decide access. */\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 rather than every one the person belongs to.\n * Omitted, a multi-tenant product should request `organization:*` — `DEFAULT_SCOPE` in\n * `browser.ts` carries why the star is not optional.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds, and the only seam between the two deployment patterns.\n *\n * RFC 10017 names three architectures for browser applications and this package implements two:\n * a **Backend For Frontend**, where the token never reaches the browser and a cookie carries the\n * session, and a **browser-based OAuth client** with PKCE, for the SPA that has no server to put a\n * confidential client in. `bffAuth` and `browserAuth` are those two, and they are interchangeable\n * here — which is what lets `useSession`, `Gate` and `auth.fetch` be written once.\n *\n * A product names its pattern on one line, at startup, and nothing downstream knows which it chose.\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 /**\n * A `fetch` that stays authenticated: the bearer token under one pattern, the cookie riding\n * along by itself under the other, and a single retry after a renewal in both.\n */\n readonly fetch: typeof globalThis.fetch;\n}\n\n/**\n * Why a credential was refused, as a code a product 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 telling them apart is the difference between a\n * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports\n * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.\n *\n * **These codes are about a credential, or about the request for one, and about nothing else.** A\n * 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 invite a product to `catch` them\n * beside a refusal and route them to a sign-in page, which is the wrong answer to \"you wired this\n * up wrong\" — and it would put a deployment mistake in the same type as a user's session expiring.\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 /** Signed in, but holds no membership of the organization being addressed. */\n | \"organization.not-a-member\"\n /**\n * The organization asked for is not an alias, so it was not put into a scope.\n *\n * The one code here about the *request for* a credential rather than about a credential, and it\n * earns that because the value reaches `begin` from a query parameter on every product with an\n * organization switcher: a space in it is scope injection, and a product wants to answer \"no\n * such 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 /** The token endpoint refused the code or the refresh token, or returned no ID token. */\n | \"token.exchange-failed\";\n\nexport class AuthError extends Error {\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"AuthError\";\n }\n}\n"],"names":["AuthError","code","message"],"mappings":"AA+HO,MAAMA,UAAkB,MAAM;AAAA,EACnC,YACWC,GACTC,GACA;AACA,UAAMA,CAAO,GAHJ,KAAA,OAAAD,GAIT,KAAK,OAAO;AAAA,EACd;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 * **There is no active organization here, and the absence is the design.** Membership is stable and\n * comes from the token; which organization you are *looking at* is a property of the request — the\n * URL — and deriving it per request is what lets two tabs sit in two organizations at once. A field\n * here would be the single shared value they would 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 /** Epoch milliseconds. The client uses it to refresh early, never to decide access. */\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 rather than every one the person belongs to.\n * Omitted, a multi-tenant product should request `organization:*` — `DEFAULT_SCOPE` in\n * `browser.ts` carries why the star is not optional.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds, and the only seam between the two deployment patterns.\n *\n * RFC 10017 names three architectures for browser applications and this package implements two:\n * a **Backend For Frontend**, where the token never reaches the browser and a cookie carries the\n * session, and a **browser-based OAuth client** with PKCE, for the SPA that has no server to put a\n * confidential client in. `bffAuth` and `browserAuth` are those two, and they are interchangeable\n * here — which is what lets `useSession`, `Gate` and `auth.fetch` be written once.\n *\n * A product names its pattern on one line, at startup, and nothing downstream knows which it chose.\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 /**\n * A `fetch` that stays authenticated: the bearer token under one pattern, the cookie riding\n * along by itself under the other, and a single retry after a renewal in both.\n */\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 /** The BFF's session endpoint did not answer within `data.after` milliseconds. */\n | \"session/silent\"\n /** Signed in, but holds no membership of the organization being addressed. */\n | \"organization/not-a-member\"\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 /** The token endpoint refused the code or the refresh token, or returned no ID token. */\n | \"token/exchange-failed\"\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":";;;AA0IO,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;"}
@@ -4,7 +4,8 @@ import { AuthContextValue } from './auth-context';
4
4
  *
5
5
  * `status` is the field to branch on, not `session === null`: those are the same answer for
6
6
  * "anonymous" and "we have not looked yet", and drawing a sign-in prompt during the second is the
7
- * flicker every application with a session has shipped at least once.
7
+ * flicker every application with a session has shipped at least once. `"failed"` is the session
8
+ * that could not be read, with what was thrown on `error`.
8
9
  */
9
10
  export declare function useSession(): AuthContextValue & {
10
11
  readonly signIn: AuthContextValue["auth"]["signIn"];
@@ -1 +1 @@
1
- {"version":3,"file":"use-session.d.ts","sourceRoot":"","sources":["../src/use-session.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEpE;;;;;;GAMG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC;CACvD,CAcA"}
1
+ {"version":3,"file":"use-session.d.ts","sourceRoot":"","sources":["../src/use-session.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEpE;;;;;;;GAOG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC;CACvD,CAcA"}
@@ -1 +1 @@
1
- {"version":3,"file":"use-session.js","sources":["../src/use-session.ts"],"sourcesContent":["\"use client\";\n\nimport { useContext } from \"react\";\nimport { AuthContext, type AuthContextValue } from \"./auth-context\";\n\n/**\n * Who is signed in, and the two things you can do about it.\n *\n * `status` is the field to branch on, not `session === null`: those are the same answer for\n * \"anonymous\" and \"we have not looked yet\", and drawing a sign-in prompt during the second is the\n * flicker every application with a session has shipped at least once.\n */\nexport function useSession(): AuthContextValue & {\n readonly signIn: AuthContextValue[\"auth\"][\"signIn\"];\n readonly signOut: AuthContextValue[\"auth\"][\"signOut\"];\n} {\n const context = useContext(AuthContext);\n if (context === null) {\n throw new Error(\n \"useSession() was called outside <AuthProvider>. Wrap the application in one, passing the \" +\n \"auth it should use — browserAuth() for a SPA, bffAuth() where there is a server.\",\n );\n }\n\n return {\n ...context,\n signIn: context.auth.signIn,\n signOut: context.auth.signOut,\n };\n}\n"],"names":[],"mappings":";;;AAYO;AAIL;AACA;AACE;AAAU;AACR;AAKJ;AAAO;AACF;AACkB;AACC;AAE1B;;;;"}
1
+ {"version":3,"file":"use-session.js","sources":["../src/use-session.ts"],"sourcesContent":["\"use client\";\n\nimport { useContext } from \"react\";\nimport { AuthContext, type AuthContextValue } from \"./auth-context\";\n\n/**\n * Who is signed in, and the two things you can do about it.\n *\n * `status` is the field to branch on, not `session === null`: those are the same answer for\n * \"anonymous\" and \"we have not looked yet\", and drawing a sign-in prompt during the second is the\n * flicker every application with a session has shipped at least once. `\"failed\"` is the session\n * that could not be read, with what was thrown on `error`.\n */\nexport function useSession(): AuthContextValue & {\n readonly signIn: AuthContextValue[\"auth\"][\"signIn\"];\n readonly signOut: AuthContextValue[\"auth\"][\"signOut\"];\n} {\n const context = useContext(AuthContext);\n if (context === null) {\n throw new Error(\n \"useSession() was called outside <AuthProvider>. Wrap the application in one, passing the \" +\n \"auth it should use — browserAuth() for a SPA, bffAuth() where there is a server.\",\n );\n }\n\n return {\n ...context,\n signIn: context.auth.signIn,\n signOut: context.auth.signOut,\n };\n}\n"],"names":[],"mappings":";;;AAaO;AAIL;AACA;AACE;AAAU;AACR;AAKJ;AAAO;AACF;AACkB;AACC;AAE1B;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Kanzo authentication over Keycloak — the claim vocabulary read into one Session, the role evaluation that knows about organizations, and an authenticated fetch. 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",
@@ -66,12 +66,12 @@
66
66
  "react-dom": "^19.0.0",
67
67
  "rollup-plugin-preserve-directives": "^0.4.0"
68
68
  },
69
- "//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.",
69
+ "//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.",
70
70
  "size-limit": [
71
71
  {
72
72
  "name": "root barrel (JS)",
73
73
  "path": "dist/index.js",
74
- "limit": "2.5 kB",
74
+ "limit": "2.75 kB",
75
75
  "ignore": [
76
76
  "react",
77
77
  "react-dom",
@@ -81,7 +81,7 @@
81
81
  {
82
82
  "name": "browser subpath (JS)",
83
83
  "path": "dist/browser.js",
84
- "limit": "2 kB",
84
+ "limit": "2.25 kB",
85
85
  "ignore": [
86
86
  "react",
87
87
  "react-dom",
@@ -92,7 +92,7 @@
92
92
  {
93
93
  "name": "server subpath (JS)",
94
94
  "path": "dist/server.js",
95
- "limit": "3.6 kB",
95
+ "limit": "3.9 kB",
96
96
  "ignore": [
97
97
  "react",
98
98
  "react-dom",
@@ -104,7 +104,7 @@
104
104
  {
105
105
  "name": "next subpath (JS)",
106
106
  "path": "dist/next.js",
107
- "limit": "5 kB",
107
+ "limit": "5.25 kB",
108
108
  "ignore": [
109
109
  "react",
110
110
  "react-dom",