@kanzo-tech/auth 0.9.0 → 0.10.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.
- package/dist/next.d.ts +1 -2
- package/dist/next.d.ts.map +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/next.d.ts
CHANGED
|
@@ -36,8 +36,7 @@
|
|
|
36
36
|
* ## This door must never reach React's client half
|
|
37
37
|
*
|
|
38
38
|
* The modules behind it import `./server` and `./claims` directly and never `./index`, for the
|
|
39
|
-
* reason `server.ts`'s own header gives. `next.test.ts` walks the relative imports from here
|
|
40
|
-
* `scripts/smoke-install.mjs` reads the built bytes.
|
|
39
|
+
* reason `server.ts`'s own header gives. `next.test.ts` walks the relative imports from here.
|
|
41
40
|
*
|
|
42
41
|
* ## Why one barrel does not put `openid-client` on the edge
|
|
43
42
|
*
|
package/dist/next.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAC1F,OAAO,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,YAAY,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/server.js.map
CHANGED
|
@@ -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 `scripts/smoke-install.mjs` asserts the built bytes for it.\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 { 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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,7 +66,7 @@
|
|
|
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
|
|
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.",
|
|
70
70
|
"size-limit": [
|
|
71
71
|
{
|
|
72
72
|
"name": "root barrel (JS)",
|