@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.
- package/dist/auth-context.d.ts +3 -1
- package/dist/auth-context.d.ts.map +1 -1
- package/dist/auth-context.js.map +1 -1
- package/dist/auth-provider.js +19 -18
- package/dist/auth-provider.js.map +1 -1
- package/dist/bff-auth.d.ts +2 -1
- package/dist/bff-auth.d.ts.map +1 -1
- package/dist/bff-auth.js +77 -46
- package/dist/bff-auth.js.map +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +65 -42
- package/dist/browser.js.map +1 -1
- package/dist/claims.js +1 -1
- package/dist/claims.js.map +1 -1
- package/dist/cookie-session.d.ts.map +1 -1
- package/dist/cookie-session.js +1 -1
- package/dist/cookie-session.js.map +1 -1
- package/dist/deadline.d.ts +14 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +17 -0
- package/dist/deadline.js.map +1 -0
- package/dist/gate.js +9 -9
- package/dist/gate.js.map +1 -1
- package/dist/issuer.d.ts +0 -2
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +17 -17
- package/dist/issuer.js.map +1 -1
- package/dist/next-middleware.d.ts +6 -0
- package/dist/next-middleware.d.ts.map +1 -1
- package/dist/next-middleware.js +15 -13
- package/dist/next-middleware.js.map +1 -1
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +27 -26
- package/dist/next-proxy.js.map +1 -1
- package/dist/next-routes.d.ts +9 -0
- package/dist/next-routes.d.ts.map +1 -1
- package/dist/next-routes.js +66 -46
- package/dist/next-routes.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +151 -109
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +40 -21
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -4
- package/dist/types.js.map +1 -1
- package/dist/use-session.d.ts +2 -1
- package/dist/use-session.d.ts.map +1 -1
- package/dist/use-session.js.map +1 -1
- package/package.json +6 -6
package/dist/claims.js
CHANGED
|
@@ -33,7 +33,7 @@ function b(r, t) {
|
|
|
33
33
|
var f, m, p;
|
|
34
34
|
const e = a(r) ?? {}, n = o(e.sub);
|
|
35
35
|
if (n === void 0)
|
|
36
|
-
throw new d("claims
|
|
36
|
+
throw new d("claims/no-subject", "the claim set carries no `sub`, so it names nobody");
|
|
37
37
|
const s = l((f = a(e.realm_access)) == null ? void 0 : f.roles), i = l(
|
|
38
38
|
(p = a((m = a(e.resource_access)) == null ? void 0 : m[t.clientId])) == null ? void 0 : p.roles
|
|
39
39
|
), c = e.exp, u = typeof c == "number" && Number.isFinite(c) ? c * 1e3 : 0;
|
package/dist/claims.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claims.js","sources":["../src/claims.ts"],"sourcesContent":["import { AuthError, type Organization, type Session } from \"./types\";\n\n/**\n * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that\n * knows what Keycloak calls things.\n *\n * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what\n * makes the vocabulary testable without a realm, and it is why every door can share one reading of\n * it instead of each parsing the token its own way.\n *\n * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`\n * — the claims Keycloak emits with no configuration — and membership is the `organization` claim\n * from the organization scope. A deployment that renames these has made work for itself; a\n * deployment that uses them gets this file for free.\n */\n\n/** What the reader needs to know about the application doing the reading. */\nexport interface ClaimsConfig {\n /**\n * This application's Keycloak client id. It selects two things: which entry of `resource_access`\n * is ours, and which organization groups are ours — see {@link roleFromGroupPath}.\n */\n readonly clientId: string;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\nfunction asStrings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((v): v is string => typeof v === \"string\") : [];\n}\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\n/**\n * A group path, as the role it grants *this* application — or `null` when it grants nothing here.\n *\n * Keycloak writes group membership as a path: `/keasy/owner`. Organization Groups (26.6) give each\n * organization its own hierarchy, so the convention this package reads is that **the first segment\n * is the application** when there is more than one:\n *\n * - `/keasy/owner` under client `keasy` → `owner`\n * - `/hub/reader` under client `keasy` → `null`, because it is another application's role\n * - `/owner` → `owner`, a role the organization grants across every application\n *\n * The filtering is not a nicety. Without it, a role granted to someone in the hub would authorise\n * them in keasy, which is the whole failure this separation exists to prevent.\n */\nexport function roleFromGroupPath(path: string, clientId: string): string | null {\n const segments = path.split(\"/\").filter((s) => s.length > 0);\n const [first, ...rest] = segments;\n if (first === undefined) return null;\n if (rest.length === 0) return first;\n return first === clientId ? rest.join(\"/\") : null;\n}\n\n/**\n * The `organization` claim, as a list.\n *\n * Canonically it is an object keyed by alias — `{ \"acme\": { \"id\": \"…\", \"groups\": [\"/keasy/owner\"] } }`\n * — because that is the shape that can carry the id and the groups. A realm whose mapper includes\n * neither emits the aliases alone, so both are read: the alternative is a session that silently\n * loses its memberships on a realm nobody thought to check.\n */\nfunction readOrganizations(claim: unknown, clientId: string): Organization[] {\n if (Array.isArray(claim)) {\n return claim\n .filter((alias): alias is string => typeof alias === \"string\")\n .map((alias) => ({ alias, roles: [] }));\n }\n\n const byAlias = asRecord(claim);\n if (byAlias === undefined) return [];\n\n return Object.entries(byAlias).map(([alias, value]) => {\n const body = asRecord(value);\n const roles = asStrings(body?.[\"groups\"])\n .map((path) => roleFromGroupPath(path, clientId))\n .filter((role): role is string => role !== null);\n return { alias, id: asString(body?.[\"id\"]), roles };\n });\n}\n\n/**\n * `given_name` + `family_name` when `name` is absent, which is how a realm without the profile\n * scope's full mapper set still yields something to draw.\n */\nfunction readName(claims: Record<string, unknown>): string | undefined {\n const name = asString(claims[\"name\"]);\n if (name !== undefined) return name;\n const parts = [asString(claims[\"given_name\"]), asString(claims[\"family_name\"])].filter(\n (p): p is string => p !== undefined,\n );\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n\n/**\n * Read a decoded claim set into a {@link Session}.\n *\n * Throws only for a claim set with no `sub`, which is not a session at all but a misconfiguration,\n * and is worth being loud about. Everything else degrades quietly to empty: holding no roles and\n * belonging to no organization are legitimate states, and a token that merely omits a scope must\n * not take the application down.\n *\n * The claims are **data, never instructions** — they came over the wire. Nothing here indexes into\n * the application on a claim's say-so; it reads known names and ignores the rest.\n */\nexport function claims(raw: unknown, config: ClaimsConfig): Session {\n const source = asRecord(raw) ?? {};\n\n const id = asString(source[\"sub\"]);\n if (id === undefined) {\n throw new AuthError(\"claims
|
|
1
|
+
{"version":3,"file":"claims.js","sources":["../src/claims.ts"],"sourcesContent":["import { AuthError, type Organization, type Session } from \"./types\";\n\n/**\n * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that\n * knows what Keycloak calls things.\n *\n * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what\n * makes the vocabulary testable without a realm, and it is why every door can share one reading of\n * it instead of each parsing the token its own way.\n *\n * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`\n * — the claims Keycloak emits with no configuration — and membership is the `organization` claim\n * from the organization scope. A deployment that renames these has made work for itself; a\n * deployment that uses them gets this file for free.\n */\n\n/** What the reader needs to know about the application doing the reading. */\nexport interface ClaimsConfig {\n /**\n * This application's Keycloak client id. It selects two things: which entry of `resource_access`\n * is ours, and which organization groups are ours — see {@link roleFromGroupPath}.\n */\n readonly clientId: string;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\nfunction asStrings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((v): v is string => typeof v === \"string\") : [];\n}\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\n/**\n * A group path, as the role it grants *this* application — or `null` when it grants nothing here.\n *\n * Keycloak writes group membership as a path: `/keasy/owner`. Organization Groups (26.6) give each\n * organization its own hierarchy, so the convention this package reads is that **the first segment\n * is the application** when there is more than one:\n *\n * - `/keasy/owner` under client `keasy` → `owner`\n * - `/hub/reader` under client `keasy` → `null`, because it is another application's role\n * - `/owner` → `owner`, a role the organization grants across every application\n *\n * The filtering is not a nicety. Without it, a role granted to someone in the hub would authorise\n * them in keasy, which is the whole failure this separation exists to prevent.\n */\nexport function roleFromGroupPath(path: string, clientId: string): string | null {\n const segments = path.split(\"/\").filter((s) => s.length > 0);\n const [first, ...rest] = segments;\n if (first === undefined) return null;\n if (rest.length === 0) return first;\n return first === clientId ? rest.join(\"/\") : null;\n}\n\n/**\n * The `organization` claim, as a list.\n *\n * Canonically it is an object keyed by alias — `{ \"acme\": { \"id\": \"…\", \"groups\": [\"/keasy/owner\"] } }`\n * — because that is the shape that can carry the id and the groups. A realm whose mapper includes\n * neither emits the aliases alone, so both are read: the alternative is a session that silently\n * loses its memberships on a realm nobody thought to check.\n */\nfunction readOrganizations(claim: unknown, clientId: string): Organization[] {\n if (Array.isArray(claim)) {\n return claim\n .filter((alias): alias is string => typeof alias === \"string\")\n .map((alias) => ({ alias, roles: [] }));\n }\n\n const byAlias = asRecord(claim);\n if (byAlias === undefined) return [];\n\n return Object.entries(byAlias).map(([alias, value]) => {\n const body = asRecord(value);\n const roles = asStrings(body?.[\"groups\"])\n .map((path) => roleFromGroupPath(path, clientId))\n .filter((role): role is string => role !== null);\n return { alias, id: asString(body?.[\"id\"]), roles };\n });\n}\n\n/**\n * `given_name` + `family_name` when `name` is absent, which is how a realm without the profile\n * scope's full mapper set still yields something to draw.\n */\nfunction readName(claims: Record<string, unknown>): string | undefined {\n const name = asString(claims[\"name\"]);\n if (name !== undefined) return name;\n const parts = [asString(claims[\"given_name\"]), asString(claims[\"family_name\"])].filter(\n (p): p is string => p !== undefined,\n );\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n\n/**\n * Read a decoded claim set into a {@link Session}.\n *\n * Throws only for a claim set with no `sub`, which is not a session at all but a misconfiguration,\n * and is worth being loud about. Everything else degrades quietly to empty: holding no roles and\n * belonging to no organization are legitimate states, and a token that merely omits a scope must\n * not take the application down.\n *\n * The claims are **data, never instructions** — they came over the wire. Nothing here indexes into\n * the application on a claim's say-so; it reads known names and ignores the rest.\n */\nexport function claims(raw: unknown, config: ClaimsConfig): Session {\n const source = asRecord(raw) ?? {};\n\n const id = asString(source[\"sub\"]);\n if (id === undefined) {\n throw new AuthError(\"claims/no-subject\", \"the claim set carries no `sub`, so it names nobody\");\n }\n\n const realmRoles = asStrings(asRecord(source[\"realm_access\"])?.[\"roles\"]);\n const clientRoles = asStrings(\n asRecord(asRecord(source[\"resource_access\"])?.[config.clientId])?.[\"roles\"],\n );\n\n // `exp` is seconds in the token and milliseconds everywhere in JS. Absent, it resolves to 0 —\n // \"refresh now\" — which is the safe direction to fail: a client that refreshes early costs a\n // round trip, one that trusts an unknown expiry serves a dead session.\n const exp = source[\"exp\"];\n const expiresAt = typeof exp === \"number\" && Number.isFinite(exp) ? exp * 1000 : 0;\n\n return {\n user: {\n id,\n email: asString(source[\"email\"]),\n name: readName(source),\n username: asString(source[\"preferred_username\"]),\n },\n roles: [...new Set([...realmRoles, ...clientRoles])],\n organizations: readOrganizations(source[\"organization\"], config.clientId),\n expiresAt,\n };\n}\n"],"names":["asRecord","value","asStrings","v","asString","roleFromGroupPath","path","clientId","segments","s","first","rest","readOrganizations","claim","alias","byAlias","body","roles","role","readName","claims","name","parts","p","raw","config","source","id","AuthError","realmRoles","_a","clientRoles","_c","_b","exp","expiresAt"],"mappings":";AAyBA,SAASA,EAASC,GAAqD;AACrE,SAAO,OAAOA,KAAU,YAAYA,MAAU,QAAQ,CAAC,MAAM,QAAQA,CAAK,IACrEA,IACD;AACN;AAEA,SAASC,EAAUD,GAA0B;AAC3C,SAAO,MAAM,QAAQA,CAAK,IAAIA,EAAM,OAAO,CAACE,MAAmB,OAAOA,KAAM,QAAQ,IAAI,CAAA;AAC1F;AAEA,SAASC,EAASH,GAAoC;AACpD,SAAO,OAAOA,KAAU,YAAYA,EAAM,SAAS,IAAIA,IAAQ;AACjE;AAgBO,SAASI,EAAkBC,GAAcC,GAAiC;AAC/E,QAAMC,IAAWF,EAAK,MAAM,GAAG,EAAE,OAAO,CAACG,MAAMA,EAAE,SAAS,CAAC,GACrD,CAACC,GAAO,GAAGC,CAAI,IAAIH;AACzB,SAAIE,MAAU,SAAkB,OAC5BC,EAAK,WAAW,IAAUD,IACvBA,MAAUH,IAAWI,EAAK,KAAK,GAAG,IAAI;AAC/C;AAUA,SAASC,EAAkBC,GAAgBN,GAAkC;AAC3E,MAAI,MAAM,QAAQM,CAAK;AACrB,WAAOA,EACJ,OAAO,CAACC,MAA2B,OAAOA,KAAU,QAAQ,EAC5D,IAAI,CAACA,OAAW,EAAE,OAAAA,GAAO,OAAO,CAAA,IAAK;AAG1C,QAAMC,IAAUf,EAASa,CAAK;AAC9B,SAAIE,MAAY,SAAkB,CAAA,IAE3B,OAAO,QAAQA,CAAO,EAAE,IAAI,CAAC,CAACD,GAAOb,CAAK,MAAM;AACrD,UAAMe,IAAOhB,EAASC,CAAK,GACrBgB,IAAQf,EAAUc,KAAA,gBAAAA,EAAO,MAAS,EACrC,IAAI,CAACV,MAASD,EAAkBC,GAAMC,CAAQ,CAAC,EAC/C,OAAO,CAACW,MAAyBA,MAAS,IAAI;AACjD,WAAO,EAAE,OAAAJ,GAAO,IAAIV,EAASY,KAAA,gBAAAA,EAAO,EAAK,GAAG,OAAAC,EAAA;AAAA,EAC9C,CAAC;AACH;AAMA,SAASE,EAASC,GAAqD;AACrE,QAAMC,IAAOjB,EAASgB,EAAO,IAAO;AACpC,MAAIC,MAAS,OAAW,QAAOA;AAC/B,QAAMC,IAAQ,CAAClB,EAASgB,EAAO,UAAa,GAAGhB,EAASgB,EAAO,WAAc,CAAC,EAAE;AAAA,IAC9E,CAACG,MAAmBA,MAAM;AAAA,EAAA;AAE5B,SAAOD,EAAM,SAAS,IAAIA,EAAM,KAAK,GAAG,IAAI;AAC9C;AAaO,SAASF,EAAOI,GAAcC,GAA+B;;AAClE,QAAMC,IAAS1B,EAASwB,CAAG,KAAK,CAAA,GAE1BG,IAAKvB,EAASsB,EAAO,GAAM;AACjC,MAAIC,MAAO;AACT,UAAM,IAAIC,EAAU,qBAAqB,oDAAoD;AAG/F,QAAMC,IAAa3B,GAAU4B,IAAA9B,EAAS0B,EAAO,YAAe,MAA/B,gBAAAI,EAAmC,KAAQ,GAClEC,IAAc7B;AAAA,KAClB8B,IAAAhC,GAASiC,IAAAjC,EAAS0B,EAAO,eAAkB,MAAlC,gBAAAO,EAAsCR,EAAO,SAAS,MAA/D,gBAAAO,EAAmE;AAAA,EAAO,GAMtEE,IAAMR,EAAO,KACbS,IAAY,OAAOD,KAAQ,YAAY,OAAO,SAASA,CAAG,IAAIA,IAAM,MAAO;AAEjF,SAAO;AAAA,IACL,MAAM;AAAA,MACJ,IAAAP;AAAA,MACA,OAAOvB,EAASsB,EAAO,KAAQ;AAAA,MAC/B,MAAMP,EAASO,CAAM;AAAA,MACrB,UAAUtB,EAASsB,EAAO,kBAAqB;AAAA,IAAA;AAAA,IAEjD,OAAO,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAGG,GAAY,GAAGE,CAAW,CAAC,CAAC;AAAA,IACnD,eAAenB,EAAkBc,EAAO,cAAiBD,EAAO,QAAQ;AAAA,IACxE,WAAAU;AAAA,EAAA;AAEJ;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cookie-session.d.ts","sourceRoot":"","sources":["../src/cookie-session.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACrC,wGAAwG;IACxG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChC,uGAAuG;IACvG,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC3D,0DAA0D;IAC1D,KAAK,IAAI,MAAM,CAAC;CACjB;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAQpB;AAaD,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"cookie-session.d.ts","sourceRoot":"","sources":["../src/cookie-session.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACrC,wGAAwG;IACxG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,6CAA6C;IAC7C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChC,uGAAuG;IACvG,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC3D,0DAA0D;IAC1D,KAAK,IAAI,MAAM,CAAC;CACjB;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAQpB;AAaD,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAAC,CAAC,CAAC,CAkD3E"}
|
package/dist/cookie-session.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cookie-session.js","sources":["../src/cookie-session.ts"],"sourcesContent":["import { EncryptJWT, jwtDecrypt } from \"jose\";\n\n/**\n * A value sealed into a cookie, and read back out of one.\n *\n * There is **one** mechanism here and it is used twice: for the session that outlives a request,\n * and for the short-lived transaction that carries `state`, `nonce` and the PKCE verifier between\n * the two legs of the authorization code flow. Holding the transaction in a cookie rather than in\n * server memory is what makes the BFF stateless by default — keasy's Rust keeps those three in a\n * SQLite-backed server session, and pays for a session store before anyone has signed in.\n *\n * ## The attributes, and why each one\n *\n * - **`__Host-` prefix**, added here and not declinable. It forces `Secure` and `Path=/`, forbids\n * `Domain`, and — the part that matters — a page on a sibling subdomain cannot write it. Without\n * the prefix, anything that can serve `evil.example.test` can set a cookie that arrives at\n * `app.example.test` looking exactly like ours.\n * - **`HttpOnly`**, so script cannot read it. In a BFF the browser is not supposed to hold the\n * credential at all; this is the enforcement of that sentence.\n * - **`SameSite=Lax`**, not `Strict`. `Strict` withholds the cookie on the top-level navigation\n * *back* from the identity provider, so the callback arrives without the transaction it needs\n * and every sign-in fails. `Lax` sends it on exactly that navigation and on nothing else risky.\n * - **JWE, not a signature.** The payload is a refresh token: signing would authenticate it and\n * leave it readable to anyone who can see the cookie. `dir` + `A256GCM` is authenticated\n * encryption, so a tampered byte fails to decrypt rather than decrypting to something else.\n */\n\n/**\n * The 4 KB a browser is required to keep, and the reason this module has a size guard.\n *\n * A cookie over the limit is not rejected loudly — it is *dropped*, and the symptom is a sign-in\n * that appears to work and a session that is never there. A `SessionStore` is the way out, and the\n * error says so.\n */\nconst COOKIE_LIMIT = 4096;\n\nexport interface SealedCookieConfig {\n /**\n * The name **after** the `__Host-` prefix, which this module adds. A caller cannot decline it:\n * the prefix is the only cookie attribute a browser enforces on our behalf.\n */\n readonly name: string;\n /**\n * The sealing secret. Any length — it is hashed to the 256-bit key — but it is a *secret*, not a\n * password: generate it, do not choose it.\n */\n readonly secret: string | Uint8Array;\n /** Seconds. It is both the cookie's `Max-Age` and the JWE's `exp`, so neither can outlive the other. */\n readonly maxAge: number;\n}\n\nexport interface SealedCookie<T> {\n /** The full cookie name, prefix included. */\n readonly name: string;\n /** The value of a `Set-Cookie` header carrying `value`. */\n seal(value: T): Promise<string>;\n /** Read from a request's `Cookie` header. `null` for absent, tampered, or expired — all one answer. */\n read(header: string | null | undefined): Promise<T | null>;\n /** The value of a `Set-Cookie` header that removes it. */\n clear(): string;\n}\n\n/** One cookie value out of a request's `Cookie` header, or `undefined`. */\nexport function cookieValue(\n header: string | null | undefined,\n name: string,\n): string | undefined {\n if (header === null || header === undefined || header.length === 0) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n\nasync function keyFrom(secret: string | Uint8Array): Promise<Uint8Array> {\n const input = typeof secret === \"string\" ? new TextEncoder().encode(secret) : secret;\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", input as BufferSource));\n}\n\nfunction setCookie(name: string, value: string, maxAge: number): string {\n // No `Domain`: `__Host-` forbids it, and forbidding it is the point — a cookie without a domain\n // is the one a sibling host cannot reach.\n return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n}\n\nexport function sealedCookie<T>(config: SealedCookieConfig): SealedCookie<T> {\n const name = `__Host-${config.name}`;\n // Derived once, lazily: the digest is cheap but a request path should not pay for it per call.\n let key:
|
|
1
|
+
{"version":3,"file":"cookie-session.js","sources":["../src/cookie-session.ts"],"sourcesContent":["import { EncryptJWT, jwtDecrypt } from \"jose\";\n\n/**\n * A value sealed into a cookie, and read back out of one.\n *\n * There is **one** mechanism here and it is used twice: for the session that outlives a request,\n * and for the short-lived transaction that carries `state`, `nonce` and the PKCE verifier between\n * the two legs of the authorization code flow. Holding the transaction in a cookie rather than in\n * server memory is what makes the BFF stateless by default — keasy's Rust keeps those three in a\n * SQLite-backed server session, and pays for a session store before anyone has signed in.\n *\n * ## The attributes, and why each one\n *\n * - **`__Host-` prefix**, added here and not declinable. It forces `Secure` and `Path=/`, forbids\n * `Domain`, and — the part that matters — a page on a sibling subdomain cannot write it. Without\n * the prefix, anything that can serve `evil.example.test` can set a cookie that arrives at\n * `app.example.test` looking exactly like ours.\n * - **`HttpOnly`**, so script cannot read it. In a BFF the browser is not supposed to hold the\n * credential at all; this is the enforcement of that sentence.\n * - **`SameSite=Lax`**, not `Strict`. `Strict` withholds the cookie on the top-level navigation\n * *back* from the identity provider, so the callback arrives without the transaction it needs\n * and every sign-in fails. `Lax` sends it on exactly that navigation and on nothing else risky.\n * - **JWE, not a signature.** The payload is a refresh token: signing would authenticate it and\n * leave it readable to anyone who can see the cookie. `dir` + `A256GCM` is authenticated\n * encryption, so a tampered byte fails to decrypt rather than decrypting to something else.\n */\n\n/**\n * The 4 KB a browser is required to keep, and the reason this module has a size guard.\n *\n * A cookie over the limit is not rejected loudly — it is *dropped*, and the symptom is a sign-in\n * that appears to work and a session that is never there. A `SessionStore` is the way out, and the\n * error says so.\n */\nconst COOKIE_LIMIT = 4096;\n\nexport interface SealedCookieConfig {\n /**\n * The name **after** the `__Host-` prefix, which this module adds. A caller cannot decline it:\n * the prefix is the only cookie attribute a browser enforces on our behalf.\n */\n readonly name: string;\n /**\n * The sealing secret. Any length — it is hashed to the 256-bit key — but it is a *secret*, not a\n * password: generate it, do not choose it.\n */\n readonly secret: string | Uint8Array;\n /** Seconds. It is both the cookie's `Max-Age` and the JWE's `exp`, so neither can outlive the other. */\n readonly maxAge: number;\n}\n\nexport interface SealedCookie<T> {\n /** The full cookie name, prefix included. */\n readonly name: string;\n /** The value of a `Set-Cookie` header carrying `value`. */\n seal(value: T): Promise<string>;\n /** Read from a request's `Cookie` header. `null` for absent, tampered, or expired — all one answer. */\n read(header: string | null | undefined): Promise<T | null>;\n /** The value of a `Set-Cookie` header that removes it. */\n clear(): string;\n}\n\n/** One cookie value out of a request's `Cookie` header, or `undefined`. */\nexport function cookieValue(\n header: string | null | undefined,\n name: string,\n): string | undefined {\n if (header === null || header === undefined || header.length === 0) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n\nasync function keyFrom(secret: string | Uint8Array): Promise<Uint8Array> {\n const input = typeof secret === \"string\" ? new TextEncoder().encode(secret) : secret;\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", input as BufferSource));\n}\n\nfunction setCookie(name: string, value: string, maxAge: number): string {\n // No `Domain`: `__Host-` forbids it, and forbidding it is the point — a cookie without a domain\n // is the one a sibling host cannot reach.\n return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n}\n\nexport function sealedCookie<T>(config: SealedCookieConfig): SealedCookie<T> {\n const name = `__Host-${config.name}`;\n // Derived once, lazily: the digest is cheap but a request path should not pay for it per call.\n // The key is kept, not the promise, so a digest that failed is attempted again.\n let key: Uint8Array | undefined;\n const material = async () => (key ??= await keyFrom(config.secret));\n\n return {\n name,\n\n async seal(value) {\n // The payload is wrapped rather than spread, so a field named `exp` or `iss` in a `Session`\n // could never come to mean the JWT claim of the same name.\n const token = await new EncryptJWT({ v: value })\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(`${config.maxAge}s`)\n .encrypt(await material());\n\n const header = setCookie(name, token, config.maxAge);\n if (header.length > COOKIE_LIMIT) {\n throw new Error(\n `${name} is ${header.length} bytes and a browser is only required to keep ${COOKIE_LIMIT}; ` +\n \"a cookie over the limit is dropped silently and the session simply never appears. \" +\n \"Give relyingParty `store: ticketStore(adapter)` so the cookie carries an opaque \" +\n \"ticket instead of the tokens: a record holding an access token does not fit here.\",\n );\n }\n return header;\n },\n\n async read(header) {\n const token = cookieValue(header, name);\n if (token === undefined) return null;\n try {\n const { payload } = await jwtDecrypt(token, await material());\n return (payload[\"v\"] ?? null) as T | null;\n } catch {\n // Forged, re-keyed, truncated by a proxy, or simply expired. None of them is a session,\n // and none of them is worth a different answer to the caller — `bff-auth` reads the same\n // endpoint the same way. A thrown error here would only ever be caught and turned into\n // this.\n return null;\n }\n },\n\n clear() {\n return setCookie(name, \"\", 0);\n },\n };\n}\n"],"names":["COOKIE_LIMIT","cookieValue","header","name","part","eq","keyFrom","secret","input","setCookie","value","maxAge","sealedCookie","config","key","material","token","EncryptJWT","payload","jwtDecrypt"],"mappings":";AAkCA,MAAMA,IAAe;AA6Bd,SAASC,EACdC,GACAC,GACoB;AACpB,MAAI,EAAAD,KAAW,QAAgCA,EAAO,WAAW;AACjE,eAAWE,KAAQF,EAAO,MAAM,GAAG,GAAG;AACpC,YAAMG,IAAKD,EAAK,QAAQ,GAAG;AAC3B,UAAIC,MAAO,MACPD,EAAK,MAAM,GAAGC,CAAE,EAAE,KAAA,MAAWF;AAAM,eAAOC,EAAK,MAAMC,IAAK,CAAC,EAAE,KAAA;AAAA,IACnE;AAEF;AAEA,eAAeC,EAAQC,GAAkD;AACvE,QAAMC,IAAQ,OAAOD,KAAW,WAAW,IAAI,cAAc,OAAOA,CAAM,IAAIA;AAC9E,SAAO,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,WAAWC,CAAqB,CAAC;AACpF;AAEA,SAASC,EAAUN,GAAcO,GAAeC,GAAwB;AAGtE,SAAO,GAAGR,CAAI,IAAIO,CAAK,qDAAqDC,CAAM;AACpF;AAEO,SAASC,EAAgBC,GAA6C;AAC3E,QAAMV,IAAO,UAAUU,EAAO,IAAI;AAGlC,MAAIC;AACJ,QAAMC,IAAW,YAAaD,UAAQ,MAAMR,EAAQO,EAAO,MAAM;AAEjE,SAAO;AAAA,IACL,MAAAV;AAAA,IAEA,MAAM,KAAKO,GAAO;AAGhB,YAAMM,IAAQ,MAAM,IAAIC,EAAW,EAAE,GAAGP,EAAA,CAAO,EAC5C,mBAAmB,EAAE,KAAK,OAAO,KAAK,UAAA,CAAW,EACjD,YAAA,EACA,kBAAkB,GAAGG,EAAO,MAAM,GAAG,EACrC,QAAQ,MAAME,EAAA,CAAU,GAErBb,IAASO,EAAUN,GAAMa,GAAOH,EAAO,MAAM;AACnD,UAAIX,EAAO,SAASF;AAClB,cAAM,IAAI;AAAA,UACR,GAAGG,CAAI,OAAOD,EAAO,MAAM,iDAAiDF,CAAY;AAAA,QAAA;AAM5F,aAAOE;AAAA,IACT;AAAA,IAEA,MAAM,KAAKA,GAAQ;AACjB,YAAMc,IAAQf,EAAYC,GAAQC,CAAI;AACtC,UAAIa,MAAU,OAAW,QAAO;AAChC,UAAI;AACF,cAAM,EAAE,SAAAE,MAAY,MAAMC,EAAWH,GAAO,MAAMD,GAAU;AAC5D,eAAQG,EAAQ,KAAQ;AAAA,MAC1B,QAAQ;AAKN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,QAAQ;AACN,aAAOT,EAAUN,GAAM,IAAI,CAAC;AAAA,IAC9B;AAAA,EAAA;AAEJ;"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AuthErrorCode } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Thirty seconds: the figure fossil's `/docs/design/failure` sets for a request to an API from the
|
|
4
|
+
* browser and for a request to an identity provider. Where a library makes the wait —
|
|
5
|
+
* `openid-client`, `oidc-client-ts` — it is handed this figure; where this package makes it,
|
|
6
|
+
* {@link deadline} does.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEADLINE = 30000;
|
|
9
|
+
/**
|
|
10
|
+
* Run `work` with a signal that aborts after {@link DEADLINE}, and reject with `code` and
|
|
11
|
+
* `{ after }` when it does — whether or not `work` honours the signal.
|
|
12
|
+
*/
|
|
13
|
+
export declare function deadline<T>(code: AuthErrorCode, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
14
|
+
//# sourceMappingURL=deadline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deadline.d.ts","sourceRoot":"","sources":["../src/deadline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAExD;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,QAAS,CAAC;AAE/B;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,IAAI,EAAE,aAAa,EACnB,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GACxC,OAAO,CAAC,CAAC,CAAC,CAYZ"}
|
package/dist/deadline.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AuthError as a } from "./types.js";
|
|
2
|
+
const r = 3e4;
|
|
3
|
+
function c(e, i) {
|
|
4
|
+
const o = new AbortController();
|
|
5
|
+
return new Promise((l, n) => {
|
|
6
|
+
const s = setTimeout(() => {
|
|
7
|
+
const t = new a(e, `no answer within ${r} ms`, { after: r });
|
|
8
|
+
o.abort(t), n(t);
|
|
9
|
+
}, r);
|
|
10
|
+
i(o.signal).then(l, n).finally(() => clearTimeout(s));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
r as DEADLINE,
|
|
15
|
+
c as deadline
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=deadline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deadline.js","sources":["../src/deadline.ts"],"sourcesContent":["import { AuthError, type AuthErrorCode } from \"./types\";\n\n/**\n * Thirty seconds: the figure fossil's `/docs/design/failure` sets for a request to an API from the\n * browser and for a request to an identity provider. Where a library makes the wait —\n * `openid-client`, `oidc-client-ts` — it is handed this figure; where this package makes it,\n * {@link deadline} does.\n */\nexport const DEADLINE = 30_000;\n\n/**\n * Run `work` with a signal that aborts after {@link DEADLINE}, and reject with `code` and\n * `{ after }` when it does — whether or not `work` honours the signal.\n */\nexport function deadline<T>(\n code: AuthErrorCode,\n work: (signal: AbortSignal) => Promise<T>,\n): Promise<T> {\n const controller = new AbortController();\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => {\n const error = new AuthError(code, `no answer within ${DEADLINE} ms`, { after: DEADLINE });\n controller.abort(error);\n reject(error);\n }, DEADLINE);\n work(controller.signal)\n .then(resolve, reject)\n .finally(() => clearTimeout(timer));\n });\n}\n"],"names":["DEADLINE","deadline","code","work","controller","resolve","reject","timer","error","AuthError"],"mappings":";AAQO,MAAMA,IAAW;AAMjB,SAASC,EACdC,GACAC,GACY;AACZ,QAAMC,IAAa,IAAI,gBAAA;AACvB,SAAO,IAAI,QAAW,CAACC,GAASC,MAAW;AACzC,UAAMC,IAAQ,WAAW,MAAM;AAC7B,YAAMC,IAAQ,IAAIC,EAAUP,GAAM,oBAAoBF,CAAQ,OAAO,EAAE,OAAOA,EAAA,CAAU;AACxF,MAAAI,EAAW,MAAMI,CAAK,GACtBF,EAAOE,CAAK;AAAA,IACd,GAAGR,CAAQ;AACX,IAAAG,EAAKC,EAAW,MAAM,EACnB,KAAKC,GAASC,CAAM,EACpB,QAAQ,MAAM,aAAaC,CAAK,CAAC;AAAA,EACtC,CAAC;AACH;"}
|
package/dist/gate.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as
|
|
3
|
-
import { can as
|
|
4
|
-
import { useSession as
|
|
2
|
+
import { jsx as s, Fragment as l } from "react/jsx-runtime";
|
|
3
|
+
import { can as m } from "./can.js";
|
|
4
|
+
import { useSession as u } from "./use-session.js";
|
|
5
5
|
function p({
|
|
6
|
-
role:
|
|
7
|
-
organization:
|
|
8
|
-
fallback:
|
|
9
|
-
children:
|
|
6
|
+
role: r,
|
|
7
|
+
organization: o,
|
|
8
|
+
fallback: t = null,
|
|
9
|
+
children: e
|
|
10
10
|
}) {
|
|
11
|
-
const { session:
|
|
12
|
-
return
|
|
11
|
+
const { session: i, status: n } = u();
|
|
12
|
+
return n === "loading" || n === "failed" ? null : /* @__PURE__ */ s(l, { children: m(i, r, o) ? e : t });
|
|
13
13
|
}
|
|
14
14
|
export {
|
|
15
15
|
p as Gate
|
package/dist/gate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { can } from \"./can\";\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only\n * here is ungated.\n *\n * `organization` asks the question inside that organization rather than against the realm roles,\n * and {@link can} does not merge the two.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { session, status } = useSession();\n\n // While the session is still being read, neither answer is known to be true, so
|
|
1
|
+
{"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { can } from \"./can\";\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only\n * here is ungated.\n *\n * `organization` asks the question inside that organization rather than against the realm roles,\n * and {@link can} does not merge the two.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { session, status } = useSession();\n\n // While the session is still being read, or could not be, neither answer is known to be true, so\n // neither is drawn. Rendering the fallback here is the flicker worth avoiding — \"you cannot do\n // this\" shown to someone who can, for as long as the session endpoint takes — and rendering the\n // children is worse, because it flashes a control and then retracts it.\n if (status === \"loading\" || status === \"failed\") return null;\n\n return <>{can(session, role, organization) ? children : fallback}</>;\n}\n"],"names":[],"mappings":";;;;AAcO;AAAc;AACnB;AACA;AACW;AAEb;AAME;AAMA;AAGF;;;;"}
|
package/dist/issuer.d.ts
CHANGED
|
@@ -58,8 +58,6 @@ export interface IssuerConfig {
|
|
|
58
58
|
* on a key rotation.
|
|
59
59
|
*/
|
|
60
60
|
readonly verifySignatures?: boolean;
|
|
61
|
-
/** Seconds. Applies to discovery and to every request the resulting configuration makes. */
|
|
62
|
-
readonly timeout?: number;
|
|
63
61
|
}
|
|
64
62
|
/**
|
|
65
63
|
* A configuration that discovers on demand.
|
package/dist/issuer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"issuer.d.ts","sourceRoot":"","sources":["../src/issuer.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,WAAW,EAEhB,KAAK,UAAU,EAChB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"issuer.d.ts","sourceRoot":"","sources":["../src/issuer.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,WAAW,EAEhB,KAAK,UAAU,EAChB,MAAM,eAAe,CAAC;AAIvB;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,YAAY;IAC3B,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;IAC7C;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,iGAAiG;IACjG,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;CACrC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,qGAAqG;IACrG,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,UAAU,IAAI,OAAO,CAAC,aAAa,CAAC,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,IAAI,GAAE,OAAO,UAAU,CAAC,KAAwB,GAC/C,WAAW,CAQb;AAED,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAuDnD"}
|
package/dist/issuer.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { customFetch as
|
|
1
|
+
import { customFetch as v, allowInsecureRequests as d, enableNonRepudiationChecks as m, PrivateKeyJwt as w, ClientSecretPost as y, discovery as R } from "openid-client";
|
|
2
2
|
import { singleFlight as S } from "./single-flight.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { DEADLINE as b } from "./deadline.js";
|
|
4
|
+
function I(e, s, o = globalThis.fetch) {
|
|
5
|
+
const t = new URL(e).origin, n = s.replace(/\/+$/, "");
|
|
6
|
+
return (r, c) => o(
|
|
7
|
+
r.startsWith(t) ? `${n}${r.slice(t.length)}` : r,
|
|
8
|
+
c
|
|
8
9
|
);
|
|
9
10
|
}
|
|
10
11
|
function K(e) {
|
|
11
|
-
const
|
|
12
|
-
[
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
...e.
|
|
17
|
-
...e.verifySignatures ?? !s ? [m] : []
|
|
12
|
+
const s = new URL(e.issuer), o = e.fetch ?? ((i, p) => globalThis.fetch(i, p)), t = e.internalOrigin ?? s.origin, n = {
|
|
13
|
+
[v]: I(e.issuer, t, o),
|
|
14
|
+
timeout: b / 1e3
|
|
15
|
+
}, r = new URL(t).protocol === "https:", c = [
|
|
16
|
+
...e.allowInsecureHttp === !0 ? [d] : [],
|
|
17
|
+
...e.verifySignatures ?? !r ? [m] : []
|
|
18
18
|
];
|
|
19
|
-
|
|
19
|
+
c.length > 0 && (n.execute = c);
|
|
20
20
|
const u = e.privateKey !== void 0 ? w(e.privateKey) : y(e.clientSecret), h = e.clientSecret !== void 0 ? { client_secret: e.clientSecret } : {};
|
|
21
21
|
let a;
|
|
22
22
|
const l = S(async () => {
|
|
23
|
-
const
|
|
24
|
-
return a =
|
|
23
|
+
const i = await R(s, e.clientId, h, u, n);
|
|
24
|
+
return a = i, i;
|
|
25
25
|
});
|
|
26
26
|
return {
|
|
27
27
|
async configuration() {
|
|
@@ -37,6 +37,6 @@ function K(e) {
|
|
|
37
37
|
}
|
|
38
38
|
export {
|
|
39
39
|
K as issuer,
|
|
40
|
-
|
|
40
|
+
I as rewriteOrigin
|
|
41
41
|
};
|
|
42
42
|
//# sourceMappingURL=issuer.js.map
|
package/dist/issuer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"issuer.js","sources":["../src/issuer.ts"],"sourcesContent":["import {\n ClientSecretPost,\n PrivateKeyJwt,\n allowInsecureRequests,\n customFetch,\n enableNonRepudiationChecks,\n discovery,\n type ClientMetadata,\n type Configuration,\n type CryptoKey,\n type CustomFetch,\n type DiscoveryRequestOptions,\n type PrivateKey,\n} from \"openid-client\";\nimport { singleFlight } from \"./single-flight\";\n\n/**\n * Discovery and client configuration for the confidential client behind `./server`.\n *\n * Two things live here that a plain `discovery()` call does not give you, and both come from\n * running this against Keycloak in anger:\n *\n * 1. **The issuer's origin can be rewritten for server→Keycloak traffic.** The browser must be\n * sent to the public issuer, and the server usually cannot reach it — in a cluster it reaches\n * `http://keycloak:8080`. Rewriting the *transport* rather than the issuer keeps the two\n * truthful at once: discovery still validates the `issuer` in the document against the public\n * URL, because that is what Keycloak puts there.\n * 2. **Discovery is allowed to fail.** Keycloak is frequently not up when the application is, and\n * a library that hides a retry loop inside itself takes that decision away from the caller. A\n * failed discovery is simply not cached, so the *next* call tries again — the retry is the\n * caller's `await`, on the caller's schedule.\n */\n\nexport interface IssuerConfig {\n /** The **public** issuer URL, exactly as Keycloak reports it: `https://id.example/realms/kanzo`. */\n readonly issuer: string;\n readonly clientId: string;\n /** Client secret authentication. Used when {@link IssuerConfig.privateKey} is absent. */\n readonly clientSecret?: string;\n /**\n * `private_key_jwt`, which is the stronger of the two: the secret never travels. Preferred\n * wherever the deployment can hold a key, and it wins over `clientSecret` when both are given.\n */\n readonly privateKey?: CryptoKey | PrivateKey;\n /**\n * Where *this process* reaches Keycloak, when that is not where the browser reaches it —\n * `http://keycloak:8080`. Only the origin is replaced; the path is the issuer's own.\n */\n readonly internalOrigin?: string;\n /** Injectable for tests, and the seam the origin rewrite is built on. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /**\n * Allow a plain-HTTP issuer. A compose file on a laptop serves `http://localhost:8080`, and\n * without this nothing local can be configured at all. It is not needed for an HTTP\n * {@link IssuerConfig.internalOrigin}: the rewrite happens below the protocol check.\n */\n readonly allowInsecureHttp?: boolean;\n /**\n * Verify the ID token's signature, and not only its claims.\n *\n * **Defaults to whether the token endpoint is actually reached over TLS**, rather than to a flat\n * `false`. The specification lets a code grant skip this — an ID token arriving over a TLS\n * connection to the token endpoint, from a request authenticated as this client, is vouched for\n * by the channel (OpenID Connect Core §3.1.3.7 step 6) — and `openid-client` leaves it off for\n * that reason, which is why a sign-in fetches no JWKS at all.\n *\n * But the exemption is a claim *about the channel*, and an {@link IssuerConfig.internalOrigin} of\n * `http://keycloak:8080` withdraws it: the hop TLS was supposed to protect is plaintext inside\n * the cluster, so nothing is vouching for anything. Inheriting the reference's default there\n * would be inheriting its conclusion without its premise. So the default is derived from the\n * effective origin's protocol, and it can still be set explicitly either way.\n *\n * On, it costs one JWKS fetch, cached — and it is what makes {@link Issuer.rediscover} reachable\n * on a key rotation.\n */\n readonly verifySignatures?: boolean;\n
|
|
1
|
+
{"version":3,"file":"issuer.js","sources":["../src/issuer.ts"],"sourcesContent":["import {\n ClientSecretPost,\n PrivateKeyJwt,\n allowInsecureRequests,\n customFetch,\n enableNonRepudiationChecks,\n discovery,\n type ClientMetadata,\n type Configuration,\n type CryptoKey,\n type CustomFetch,\n type DiscoveryRequestOptions,\n type PrivateKey,\n} from \"openid-client\";\nimport { singleFlight } from \"./single-flight\";\nimport { DEADLINE } from \"./deadline\";\n\n/**\n * Discovery and client configuration for the confidential client behind `./server`.\n *\n * Two things live here that a plain `discovery()` call does not give you, and both come from\n * running this against Keycloak in anger:\n *\n * 1. **The issuer's origin can be rewritten for server→Keycloak traffic.** The browser must be\n * sent to the public issuer, and the server usually cannot reach it — in a cluster it reaches\n * `http://keycloak:8080`. Rewriting the *transport* rather than the issuer keeps the two\n * truthful at once: discovery still validates the `issuer` in the document against the public\n * URL, because that is what Keycloak puts there.\n * 2. **Discovery is allowed to fail.** Keycloak is frequently not up when the application is, and\n * a library that hides a retry loop inside itself takes that decision away from the caller. A\n * failed discovery is simply not cached, so the *next* call tries again — the retry is the\n * caller's `await`, on the caller's schedule.\n */\n\nexport interface IssuerConfig {\n /** The **public** issuer URL, exactly as Keycloak reports it: `https://id.example/realms/kanzo`. */\n readonly issuer: string;\n readonly clientId: string;\n /** Client secret authentication. Used when {@link IssuerConfig.privateKey} is absent. */\n readonly clientSecret?: string;\n /**\n * `private_key_jwt`, which is the stronger of the two: the secret never travels. Preferred\n * wherever the deployment can hold a key, and it wins over `clientSecret` when both are given.\n */\n readonly privateKey?: CryptoKey | PrivateKey;\n /**\n * Where *this process* reaches Keycloak, when that is not where the browser reaches it —\n * `http://keycloak:8080`. Only the origin is replaced; the path is the issuer's own.\n */\n readonly internalOrigin?: string;\n /** Injectable for tests, and the seam the origin rewrite is built on. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /**\n * Allow a plain-HTTP issuer. A compose file on a laptop serves `http://localhost:8080`, and\n * without this nothing local can be configured at all. It is not needed for an HTTP\n * {@link IssuerConfig.internalOrigin}: the rewrite happens below the protocol check.\n */\n readonly allowInsecureHttp?: boolean;\n /**\n * Verify the ID token's signature, and not only its claims.\n *\n * **Defaults to whether the token endpoint is actually reached over TLS**, rather than to a flat\n * `false`. The specification lets a code grant skip this — an ID token arriving over a TLS\n * connection to the token endpoint, from a request authenticated as this client, is vouched for\n * by the channel (OpenID Connect Core §3.1.3.7 step 6) — and `openid-client` leaves it off for\n * that reason, which is why a sign-in fetches no JWKS at all.\n *\n * But the exemption is a claim *about the channel*, and an {@link IssuerConfig.internalOrigin} of\n * `http://keycloak:8080` withdraws it: the hop TLS was supposed to protect is plaintext inside\n * the cluster, so nothing is vouching for anything. Inheriting the reference's default there\n * would be inheriting its conclusion without its premise. So the default is derived from the\n * effective origin's protocol, and it can still be set explicitly either way.\n *\n * On, it costs one JWKS fetch, cached — and it is what makes {@link Issuer.rediscover} reachable\n * on a key rotation.\n */\n readonly verifySignatures?: boolean;\n}\n\n/**\n * A configuration that discovers on demand.\n *\n * Holding the handle rather than the {@link Configuration} is what makes rotation expressible:\n * `rediscover()` throws the current one away, and with it the JWKS that openid-client cached\n * inside it.\n */\nexport interface Issuer {\n /** The configuration, discovering once and reusing it. Rejects — and caches nothing — on failure. */\n configuration(): Promise<Configuration>;\n /**\n * Discard what was discovered and fetch it again.\n *\n * This is the answer to signing-key rotation, and it is deliberately *reactive*: the trigger is\n * a verification that failed, never a timer. A timer refreshes when nothing is wrong and is\n * still stale at the moment something is.\n */\n rediscover(): Promise<Configuration>;\n}\n\n/**\n * A `fetch` that replaces one origin with another before sending.\n *\n * `from` may be any URL — its origin is what is taken — so the issuer URL itself can be passed\n * without the caller splitting it first. When the two origins are equal this is a pass-through,\n * which is why there is no second code path for \"no rewrite configured\".\n */\nexport function rewriteOrigin(\n from: string,\n to: string,\n base: typeof globalThis.fetch = globalThis.fetch,\n): CustomFetch {\n const source = new URL(from).origin;\n const target = to.replace(/\\/+$/, \"\");\n return (url, options) =>\n base(\n url.startsWith(source) ? `${target}${url.slice(source.length)}` : url,\n options as unknown as RequestInit,\n );\n}\n\nexport function issuer(config: IssuerConfig): Issuer {\n const server = new URL(config.issuer);\n const base: typeof globalThis.fetch =\n config.fetch ?? ((input, init) => globalThis.fetch(input, init));\n\n const reachedAt = config.internalOrigin ?? server.origin;\n\n const options: DiscoveryRequestOptions = {\n [customFetch]: rewriteOrigin(config.issuer, reachedAt, base),\n timeout: DEADLINE / 1000,\n };\n\n // The channel is what the specification's exemption rests on, so the default asks whether there\n // is one rather than assuming it. Explicit beats derived in both directions.\n const overTls = new URL(reachedAt).protocol === \"https:\";\n const execute = [\n ...(config.allowInsecureHttp === true ? [allowInsecureRequests] : []),\n ...((config.verifySignatures ?? !overTls) ? [enableNonRepudiationChecks] : []),\n ];\n if (execute.length > 0) options.execute = execute;\n\n // `private_key_jwt` over a shared secret wherever the deployment can hold a key. `None()` is\n // absent on purpose: this door is the confidential client, and a public one belongs behind\n // `./browser` where PKCE alone is the protection.\n const clientAuth =\n config.privateKey !== undefined\n ? PrivateKeyJwt(config.privateKey)\n : ClientSecretPost(config.clientSecret);\n\n const metadata: Partial<ClientMetadata> =\n config.clientSecret !== undefined ? { client_secret: config.clientSecret } : {};\n\n let current: Configuration | undefined;\n\n // Single-flight for the reason it exists everywhere in this package: six requests arriving\n // during a cold start would otherwise each fetch the discovery document. Here the slot is\n // cleared on failure, which *is* the caller-driven retry — the next `await` starts a new attempt.\n const fetchOnce = singleFlight(async () => {\n const discovered = await discovery(server, config.clientId, metadata, clientAuth, options);\n current = discovered;\n return discovered;\n });\n\n return {\n async configuration() {\n return current ?? fetchOnce();\n },\n // A `rediscover()` that lands while a discovery is already running joins that one rather than\n // starting a newer one. It is the narrow price of single-flight, and it is bounded: the call\n // it joins is at most one request old, and a second failure rediscovers again.\n async rediscover() {\n current = undefined;\n return fetchOnce();\n },\n };\n}\n"],"names":["rewriteOrigin","from","to","base","source","target","url","options","issuer","config","server","input","init","reachedAt","customFetch","DEADLINE","overTls","execute","allowInsecureRequests","enableNonRepudiationChecks","clientAuth","PrivateKeyJwt","ClientSecretPost","metadata","current","fetchOnce","singleFlight","discovered","discovery"],"mappings":";;;AA0GO,SAASA,EACdC,GACAC,GACAC,IAAgC,WAAW,OAC9B;AACb,QAAMC,IAAS,IAAI,IAAIH,CAAI,EAAE,QACvBI,IAASH,EAAG,QAAQ,QAAQ,EAAE;AACpC,SAAO,CAACI,GAAKC,MACXJ;AAAA,IACEG,EAAI,WAAWF,CAAM,IAAI,GAAGC,CAAM,GAAGC,EAAI,MAAMF,EAAO,MAAM,CAAC,KAAKE;AAAA,IAClEC;AAAA,EAAA;AAEN;AAEO,SAASC,EAAOC,GAA8B;AACnD,QAAMC,IAAS,IAAI,IAAID,EAAO,MAAM,GAC9BN,IACJM,EAAO,UAAU,CAACE,GAAOC,MAAS,WAAW,MAAMD,GAAOC,CAAI,IAE1DC,IAAYJ,EAAO,kBAAkBC,EAAO,QAE5CH,IAAmC;AAAA,IACvC,CAACO,CAAW,GAAGd,EAAcS,EAAO,QAAQI,GAAWV,CAAI;AAAA,IAC3D,SAASY,IAAW;AAAA,EAAA,GAKhBC,IAAU,IAAI,IAAIH,CAAS,EAAE,aAAa,UAC1CI,IAAU;AAAA,IACd,GAAIR,EAAO,sBAAsB,KAAO,CAACS,CAAqB,IAAI,CAAA;AAAA,IAClE,GAAKT,EAAO,oBAAoB,CAACO,IAAW,CAACG,CAA0B,IAAI,CAAA;AAAA,EAAC;AAE9E,EAAIF,EAAQ,SAAS,MAAGV,EAAQ,UAAUU;AAK1C,QAAMG,IACJX,EAAO,eAAe,SAClBY,EAAcZ,EAAO,UAAU,IAC/Ba,EAAiBb,EAAO,YAAY,GAEpCc,IACJd,EAAO,iBAAiB,SAAY,EAAE,eAAeA,EAAO,aAAA,IAAiB,CAAA;AAE/E,MAAIe;AAKJ,QAAMC,IAAYC,EAAa,YAAY;AACzC,UAAMC,IAAa,MAAMC,EAAUlB,GAAQD,EAAO,UAAUc,GAAUH,GAAYb,CAAO;AACzF,WAAAiB,IAAUG,GACHA;AAAA,EACT,CAAC;AAED,SAAO;AAAA,IACL,MAAM,gBAAgB;AACpB,aAAOH,KAAWC,EAAA;AAAA,IACpB;AAAA;AAAA;AAAA;AAAA,IAIA,MAAM,aAAa;AACjB,aAAAD,IAAU,QACHC,EAAA;AAAA,IACT;AAAA,EAAA;AAEJ;"}
|
|
@@ -10,6 +10,12 @@ export interface AuthMiddlewareConfig {
|
|
|
10
10
|
readonly public?: readonly string[];
|
|
11
11
|
/** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */
|
|
12
12
|
readonly basePath?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The `problemPage` given to `authRoutes`. Always public — whoever is sent there has no session,
|
|
15
|
+
* and sending them to sign in instead is a loop when signing in is what failed. Default
|
|
16
|
+
* `/auth/problem`.
|
|
17
|
+
*/
|
|
18
|
+
readonly problemPage?: string;
|
|
13
19
|
/** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */
|
|
14
20
|
readonly cookieName?: string;
|
|
15
21
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-middleware.d.ts","sourceRoot":"","sources":["../src/next-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"next-middleware.d.ts","sourceRoot":"","sources":["../src/next-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAoD7D,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,uGAAuG;IACvG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,kGAAkG;IAClG,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAOD,wBAAgB,cAAc,CAC5B,MAAM,GAAE,oBAAyB,GAChC,CAAC,OAAO,EAAE,WAAW,KAAK,YAAY,CAsBxC"}
|
package/dist/next-middleware.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
import { NextResponse as
|
|
2
|
-
const
|
|
3
|
-
function
|
|
1
|
+
import { NextResponse as r } from "next/server";
|
|
2
|
+
const u = "__Host-kanzo-session", p = "/auth/problem";
|
|
3
|
+
function m(e) {
|
|
4
4
|
return e.slice(e.lastIndexOf("/") + 1).includes(".");
|
|
5
5
|
}
|
|
6
|
-
function
|
|
7
|
-
const
|
|
6
|
+
function x(e = {}) {
|
|
7
|
+
const s = (e.basePath ?? "/api/auth").replace(/\/$/, ""), c = e.cookieName ?? u, i = [s, e.problemPage ?? p, ...e.public ?? []].map(
|
|
8
|
+
(t) => t.replace(/\/$/, "")
|
|
9
|
+
);
|
|
8
10
|
return (t) => {
|
|
9
|
-
const { pathname: n, search:
|
|
10
|
-
if (
|
|
11
|
-
if (
|
|
12
|
-
return
|
|
13
|
-
if (t.cookies.has(
|
|
14
|
-
const a = new URL(`${
|
|
15
|
-
return a.searchParams.set("returnTo", `${n}${
|
|
11
|
+
const { pathname: n, search: l } = t.nextUrl;
|
|
12
|
+
if (m(n)) return r.next();
|
|
13
|
+
if (i.some((o) => n === o || n.startsWith(`${o}/`)))
|
|
14
|
+
return r.next();
|
|
15
|
+
if (t.cookies.has(c)) return r.next();
|
|
16
|
+
const a = new URL(`${s}/signin`, t.nextUrl);
|
|
17
|
+
return a.searchParams.set("returnTo", `${n}${l}`), r.redirect(a);
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
export {
|
|
19
|
-
|
|
21
|
+
x as authMiddleware
|
|
20
22
|
};
|
|
21
23
|
//# sourceMappingURL=next-middleware.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-middleware.js","sources":["../src/next-middleware.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\n\n/**\n * The edge middleware that sends an anonymous browser to the sign-in route.\n *\n * ```ts\n * // middleware.ts\n * export const middleware = authMiddleware({ public: [\"/health\"] });\n * ```\n *\n * ## It checks for presence, and nothing else\n *\n * The cookie is sealed with JWE, and this file never opens it — no key, no `jose`, no store. Two\n * reasons, and the second is the one that matters:\n *\n * 1. Middleware runs on every request, including the ones that are about to be answered from a\n * cache. Decrypting there buys a redirect decision that a route handler and `authSession` are\n * both going to make again, properly, a millisecond later.\n * 2. **A redirect is not an authorization.** Letting a request past here grants nothing: the page\n * behind it reads the session itself, and the resource server behind *that* validates an access\n * token. A forged cookie gets someone as far as a page that will find no session and say so.\n * Treating this as the check is how a middleware becomes load-bearing and then gets edited by\n * someone who does not know it is.\n *\n * So this module imports nothing from the package. That is deliberate and it has a cost: the\n * cookie's name is written here a second time, and `next-middleware.test.ts` ties the two together\n * by signing in through a fake realm and asserting the default is the name `relyingParty` actually\n * emitted. A transcribed constant with no test is how the number in a generated fixture goes stale.\n *\n * ## The matcher\n *\n * A `middleware.ts` also exports its own `config.matcher`, and keasy learned what belongs in it the\n * expensive way: a matcher that missed static files sent `/fossil/fossil_wasm_bg.wasm` to the\n * sign-in page, and the app loaded without its WebAssembly. The exemption is applied *here* as\n * well, on the path, so it holds whatever matcher a consumer writes — a fix for the class rather\n * than for the regexp. The matcher a product starts from:\n *\n * ```ts\n * export const config = { matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\..*).*)\"] };\n * ```\n */\n\n/**\n * The cookie `relyingParty` issues: `sealedCookie` prefixes every name with `__Host-`.\n *\n * Held by \"defaults to the cookie name relyingParty actually issues\" in `next-middleware.test.ts`.\n */\nconst SESSION_COOKIE = \"__Host-kanzo-session\";\n\nexport interface AuthMiddlewareConfig {\n /**\n * Path prefixes that need no session — a health probe, a marketing page, a legal notice.\n *\n * Matched on segment boundaries, so `/health` exempts `/health` and `/health/live` and does\n * **not** exempt `/healthcare`. A plain `startsWith` is one keystroke away from opening a route\n * nobody meant to open.\n */\n readonly public?: readonly string[];\n /** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */\n readonly basePath?: string;\n /** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */\n readonly cookieName?: string;\n}\n\n/** A path whose last segment carries a dot: a static file, not a page. */\nfunction isFile(pathname: string): boolean {\n return pathname.slice(pathname.lastIndexOf(\"/\") + 1).includes(\".\");\n}\n\nexport function authMiddleware(\n config: AuthMiddlewareConfig = {},\n): (request: NextRequest) => NextResponse {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const cookieName = config.cookieName ?? SESSION_COOKIE;\n const open = [base, ...(config.public ?? [])].map((prefix)
|
|
1
|
+
{"version":3,"file":"next-middleware.js","sources":["../src/next-middleware.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\n\n/**\n * The edge middleware that sends an anonymous browser to the sign-in route.\n *\n * ```ts\n * // middleware.ts\n * export const middleware = authMiddleware({ public: [\"/health\"] });\n * ```\n *\n * ## It checks for presence, and nothing else\n *\n * The cookie is sealed with JWE, and this file never opens it — no key, no `jose`, no store. Two\n * reasons, and the second is the one that matters:\n *\n * 1. Middleware runs on every request, including the ones that are about to be answered from a\n * cache. Decrypting there buys a redirect decision that a route handler and `authSession` are\n * both going to make again, properly, a millisecond later.\n * 2. **A redirect is not an authorization.** Letting a request past here grants nothing: the page\n * behind it reads the session itself, and the resource server behind *that* validates an access\n * token. A forged cookie gets someone as far as a page that will find no session and say so.\n * Treating this as the check is how a middleware becomes load-bearing and then gets edited by\n * someone who does not know it is.\n *\n * So this module imports nothing from the package. That is deliberate and it has a cost: the\n * cookie's name is written here a second time, and `next-middleware.test.ts` ties the two together\n * by signing in through a fake realm and asserting the default is the name `relyingParty` actually\n * emitted. A transcribed constant with no test is how the number in a generated fixture goes stale.\n *\n * ## The matcher\n *\n * A `middleware.ts` also exports its own `config.matcher`, and keasy learned what belongs in it the\n * expensive way: a matcher that missed static files sent `/fossil/fossil_wasm_bg.wasm` to the\n * sign-in page, and the app loaded without its WebAssembly. The exemption is applied *here* as\n * well, on the path, so it holds whatever matcher a consumer writes — a fix for the class rather\n * than for the regexp. The matcher a product starts from:\n *\n * ```ts\n * export const config = { matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\..*).*)\"] };\n * ```\n */\n\n/**\n * The cookie `relyingParty` issues: `sealedCookie` prefixes every name with `__Host-`.\n *\n * Held by \"defaults to the cookie name relyingParty actually issues\" in `next-middleware.test.ts`.\n */\nconst SESSION_COOKIE = \"__Host-kanzo-session\";\n\n/** `authRoutes`'s default `problemPage`, repeated because importing it would put `openid-client` on the edge. */\nconst PROBLEM_PAGE = \"/auth/problem\";\n\nexport interface AuthMiddlewareConfig {\n /**\n * Path prefixes that need no session — a health probe, a marketing page, a legal notice.\n *\n * Matched on segment boundaries, so `/health` exempts `/health` and `/health/live` and does\n * **not** exempt `/healthcare`. A plain `startsWith` is one keystroke away from opening a route\n * nobody meant to open.\n */\n readonly public?: readonly string[];\n /** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */\n readonly basePath?: string;\n /**\n * The `problemPage` given to `authRoutes`. Always public — whoever is sent there has no session,\n * and sending them to sign in instead is a loop when signing in is what failed. Default\n * `/auth/problem`.\n */\n readonly problemPage?: string;\n /** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */\n readonly cookieName?: string;\n}\n\n/** A path whose last segment carries a dot: a static file, not a page. */\nfunction isFile(pathname: string): boolean {\n return pathname.slice(pathname.lastIndexOf(\"/\") + 1).includes(\".\");\n}\n\nexport function authMiddleware(\n config: AuthMiddlewareConfig = {},\n): (request: NextRequest) => NextResponse {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const cookieName = config.cookieName ?? SESSION_COOKIE;\n const open = [base, config.problemPage ?? PROBLEM_PAGE, ...(config.public ?? [])].map((prefix) =>\n prefix.replace(/\\/$/, \"\"),\n );\n\n return (request) => {\n const { pathname, search } = request.nextUrl;\n\n if (isFile(pathname)) return NextResponse.next();\n if (open.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {\n return NextResponse.next();\n }\n if (request.cookies.has(cookieName)) return NextResponse.next();\n\n const away = new URL(`${base}/signin`, request.nextUrl);\n // Where they were going, so the callback can put them back. `authRoutes` confines it to this\n // origin before sealing it, which is the check this line is relying on rather than repeating.\n away.searchParams.set(\"returnTo\", `${pathname}${search}`);\n return NextResponse.redirect(away);\n };\n}\n"],"names":["SESSION_COOKIE","PROBLEM_PAGE","isFile","pathname","authMiddleware","config","base","cookieName","open","prefix","request","search","NextResponse","away"],"mappings":";AA+CA,MAAMA,IAAiB,wBAGjBC,IAAe;AAwBrB,SAASC,EAAOC,GAA2B;AACzC,SAAOA,EAAS,MAAMA,EAAS,YAAY,GAAG,IAAI,CAAC,EAAE,SAAS,GAAG;AACnE;AAEO,SAASC,EACdC,IAA+B,IACS;AACxC,QAAMC,KAAQD,EAAO,YAAY,aAAa,QAAQ,OAAO,EAAE,GACzDE,IAAaF,EAAO,cAAcL,GAClCQ,IAAO,CAACF,GAAMD,EAAO,eAAeJ,GAAc,GAAII,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,IAAI,CAACI,MACrFA,EAAO,QAAQ,OAAO,EAAE;AAAA,EAAA;AAG1B,SAAO,CAACC,MAAY;AAClB,UAAM,EAAE,UAAAP,GAAU,QAAAQ,EAAA,IAAWD,EAAQ;AAErC,QAAIR,EAAOC,CAAQ,EAAG,QAAOS,EAAa,KAAA;AAC1C,QAAIJ,EAAK,KAAK,CAACC,MAAWN,MAAaM,KAAUN,EAAS,WAAW,GAAGM,CAAM,GAAG,CAAC;AAChF,aAAOG,EAAa,KAAA;AAEtB,QAAIF,EAAQ,QAAQ,IAAIH,CAAU,EAAG,QAAOK,EAAa,KAAA;AAEzD,UAAMC,IAAO,IAAI,IAAI,GAAGP,CAAI,WAAWI,EAAQ,OAAO;AAGtD,WAAAG,EAAK,aAAa,IAAI,YAAY,GAAGV,CAAQ,GAAGQ,CAAM,EAAE,GACjDC,EAAa,SAASC,CAAI;AAAA,EACnC;AACF;"}
|
package/dist/next-proxy.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAKxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,MAAM,WAAW,eAAgB,SAAQ,iBAAiB;IACxD,wFAAwF;IACxF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wGAAwG;IACxG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;GAOG;AAEH,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1C,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC3C,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1C,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9C;AAmDD,wBAAgB,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,iBAAiB,CA2EpE"}
|
package/dist/next-proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { authToken as k } from "./next-token.js";
|
|
2
|
-
import { isSameSite as
|
|
2
|
+
import { isSameSite as g } from "./same-site.js";
|
|
3
|
+
import { outage as y } from "./next-routes.js";
|
|
3
4
|
import { AuthError as R } from "./types.js";
|
|
4
5
|
const w = [
|
|
5
6
|
"connection",
|
|
@@ -10,30 +11,30 @@ const w = [
|
|
|
10
11
|
"trailer",
|
|
11
12
|
"transfer-encoding",
|
|
12
13
|
"upgrade"
|
|
13
|
-
],
|
|
14
|
-
function T(
|
|
15
|
-
const
|
|
16
|
-
for (const a of i)
|
|
17
|
-
return
|
|
14
|
+
], x = [...w, "cookie", "authorization", "host", "content-length", "accept-encoding"], O = [...w, "set-cookie", "content-encoding", "content-length"];
|
|
15
|
+
function T(o, i) {
|
|
16
|
+
const r = new Headers(o);
|
|
17
|
+
for (const a of i) r.delete(a);
|
|
18
|
+
return r;
|
|
18
19
|
}
|
|
19
|
-
function
|
|
20
|
-
const i = k(
|
|
21
|
-
if (!
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
let
|
|
20
|
+
function A(o) {
|
|
21
|
+
const i = k(o), r = (...e) => globalThis.fetch(...e), a = o.basePath.replace(/\/$/, ""), u = new URL(o.target), E = u.pathname.replace(/\/$/, ""), s = (e) => new Response(null, { status: e, headers: { "cache-control": "no-store" } }), t = async (e) => {
|
|
22
|
+
if (!g(e)) return s(403);
|
|
23
|
+
const d = new URL(e.url);
|
|
24
|
+
if (!d.pathname.startsWith(a)) return s(400);
|
|
25
|
+
const l = new URL(u.href);
|
|
26
|
+
l.pathname = `${E}${d.pathname.slice(a.length)}`, l.search = d.search;
|
|
27
|
+
let c;
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
-
} catch (
|
|
30
|
-
if (!(
|
|
31
|
-
return
|
|
29
|
+
c = await i(e.headers.get("cookie"), { renewWithin: o.renewWithin });
|
|
30
|
+
} catch (n) {
|
|
31
|
+
if (!(n instanceof R)) throw n;
|
|
32
|
+
return s(y(n.code) ?? 401);
|
|
32
33
|
}
|
|
33
|
-
if (
|
|
34
|
-
const p = T(e.headers,
|
|
35
|
-
p.set("authorization", `Bearer ${
|
|
36
|
-
const m = e.method === "GET" || e.method === "HEAD" ? null : e.body,
|
|
34
|
+
if (c === null) return s(401);
|
|
35
|
+
const p = T(e.headers, x);
|
|
36
|
+
p.set("authorization", `Bearer ${c.accessToken}`);
|
|
37
|
+
const m = e.method === "GET" || e.method === "HEAD" ? null : e.body, h = await r(l, {
|
|
37
38
|
method: e.method,
|
|
38
39
|
headers: p,
|
|
39
40
|
body: m,
|
|
@@ -44,9 +45,9 @@ function b(n) {
|
|
|
44
45
|
// A 302 from the upstream is the upstream's answer and belongs to the caller. Following it
|
|
45
46
|
// here would send the bearer token to whatever host the `Location` names.
|
|
46
47
|
redirect: "manual"
|
|
47
|
-
}), f = T(
|
|
48
|
-
for (const
|
|
49
|
-
return new Response(
|
|
48
|
+
}), f = T(h.headers, O);
|
|
49
|
+
for (const n of c.cookies) f.append("set-cookie", n);
|
|
50
|
+
return new Response(h.body, { status: h.status, statusText: h.statusText, headers: f });
|
|
50
51
|
};
|
|
51
52
|
return {
|
|
52
53
|
GET: t,
|
|
@@ -59,6 +60,6 @@ function b(n) {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
export {
|
|
62
|
-
|
|
63
|
+
A as authProxy
|
|
63
64
|
};
|
|
64
65
|
//# sourceMappingURL=next-proxy.js.map
|
package/dist/next-proxy.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { authToken } from \"./next-token\";\nimport type { AuthSessionConfig } from \"./next-session\";\nimport { isSameSite } from \"./same-site\";\nimport { AuthError } from \"./types\";\n\n/**\n * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a\n * bearer token, and the cookie that got it here stops at this line.\n *\n * ```ts\n * // app/api/data/[...path]/route.ts\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = authProxy({\n * issuer, clientId, clientSecret, secret,\n * basePath: \"/api/data\",\n * target: \"https://reports.internal/v1\",\n * });\n * ```\n *\n * ## Why this is in the package and not in the product\n *\n * Because it is the same ninety lines every time, and one of them is load-bearing in a way that\n * does not look it. **The `cookie` header must not be forwarded.** Leave it on and the resource\n * server receives a second credential — the session cookie — alongside the bearer token it asked\n * for, which is precisely the confusion the BFF pattern exists to remove: from then on a bug at\n * the far end can act as the person rather than as the token, and the token's scope and lifetime\n * stop being the boundary. Every other line here — the hop-by-hop headers, `duplex: \"half\"`,\n * `redirect: \"manual\"`, not buffering the body — is the sort of thing that is either right or\n * produces a symptom three layers away, and none of it is a product's idea of its own domain.\n *\n * ## What it answers without asking upstream\n *\n * - **401** when there is no session, or when the renewal was refused. There is nothing to\n * forward: a request with no credential is not the resource server's to refuse.\n * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.\n *\n * ## Renewal happens here, because here is somewhere a cookie can be set\n *\n * `authToken` renews when the access token is within a minute of expiry and hands back the\n * `Set-Cookie` that carries the rotated session; this attaches it to the proxied response. That is\n * the whole of the answer to *\"the token lives an hour and the cookie lives eight\"* — the seven\n * hours in between stop being seven hours of 401s that nothing recovers from.\n */\n\nexport interface AuthProxyConfig extends AuthSessionConfig {\n /** Where the upstream lives. A path here is a prefix: `https://reports.internal/v1`. */\n readonly target: string;\n /** Where this route file is mounted. Stripped from the path before the rest is appended to `target`. */\n readonly basePath: string;\n /** Seconds of remaining lifetime below which the access token is renewed. Default 60. */\n readonly renewWithin?: number;\n}\n\n/**\n * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`\n * one, which is a distinction worth a sentence because it is one field away from being invisible.\n * That `fetch` exists to reach the *identity provider* — it is the seam `internalOrigin` uses to\n * come at Keycloak from inside a cluster. A deployment that set it and found its resource-server\n * traffic going the same way would have every right to be surprised, so there is one name for one\n * transport and the other one is the platform's.\n */\n\nexport interface AuthProxyHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n PUT(request: Request): Promise<Response>;\n PATCH(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n OPTIONS(request: Request): Promise<Response>;\n}\n\n/**\n * Headers that describe **this** connection and not the message, per RFC 9110 §7.6.1.\n *\n * Forwarding them is how a proxy promises an upstream a connection it does not have. `te` and\n * `trailer` are here for the same reason the others are, and `upgrade` matters most: a forwarded\n * `Upgrade: websocket` invites an answer this handler has no way to complete.\n */\nconst HOP_BY_HOP = [\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n];\n\n/**\n * What is stripped from the browser's request on the way out, beyond the hop-by-hop set.\n *\n * - `cookie` — the whole point, above.\n * - `authorization` — ours is the only one that may be on this request; a caller's own would\n * otherwise decide which of the two the upstream reads.\n * - `host` — it names this server, not the upstream's, and `fetch` sets the right one.\n * - `content-length` — the body is re-streamed, and a length that survived a re-encode would be a\n * lie the transport has to discover.\n * - `accept-encoding` — the client's compression negotiation is with *us*; `fetch` runs its own\n * with the upstream and hands back a decoded body. Passing this on is how a response comes back\n * labelled `gzip` and already decompressed, which no browser recovers from.\n */\nconst NOT_FORWARDED = [...HOP_BY_HOP, \"cookie\", \"authorization\", \"host\", \"content-length\", \"accept-encoding\"];\n\n/**\n * What is stripped from the upstream's answer.\n *\n * `set-cookie` is the one worth the sentence: under this pattern the browser's cookie relationship\n * is with the BFF alone, and a resource server that could set a cookie on this origin could set\n * one named like ours. `content-encoding` and `content-length` go because `fetch` already decoded\n * the body, so both now describe a representation that no longer exists.\n */\nconst NOT_RETURNED = [...HOP_BY_HOP, \"set-cookie\", \"content-encoding\", \"content-length\"];\n\nfunction copyHeaders(from: Headers, without: readonly string[]): Headers {\n const headers = new Headers(from);\n for (const name of without) headers.delete(name);\n return headers;\n}\n\nexport function authProxy(config: AuthProxyConfig): AuthProxyHandlers {\n const token = authToken(config);\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n const base = config.basePath.replace(/\\/$/, \"\");\n const target = new URL(config.target);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\n\n const refuse = (status: number) =>\n new Response(null, { status, headers: { \"cache-control\": \"no-store\" } });\n\n const handle = async (request: Request): Promise<Response> => {\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path outside the mount is not one this handler was mounted for, and refusing it *is* the\n // traversal check: the URL parser has already resolved every dot segment — `..` and its\n // percent-encoded spellings alike, which is the parser's job and not a thing to re-implement\n // — so a path that tried to climb has already fallen out of the prefix by the time it is read.\n if (!url.pathname.startsWith(base)) return refuse(400);\n\n // Assigning `pathname` rather than composing a string: a path beginning `//` parsed as a *URL*\n // is protocol-relative and names another host, and `//evil.test/x` is a path a browser will\n // happily send. Set as a component it cannot reach the origin at all.\n const upstream = new URL(target.href);\n upstream.pathname = `${prefix}${url.pathname.slice(base.length)}`;\n upstream.search = url.search;\n\n let held: Awaited<ReturnType<typeof token>>;\n try {\n held = await token(request.headers.get(\"cookie\"), { renewWithin: config.renewWithin });\n } catch (error) {\n // A refused renewal is the end of the session and not an upstream failure. Anything else is\n // a fault this module has no reading of, and hiding it behind a 401 would send a person to\n // sign in again over a misconfiguration that will still be there when they get back.\n if (!(error instanceof AuthError)) throw error;\n return refuse(401);\n }\n if (held === null) return refuse(401);\n\n const headers = copyHeaders(request.headers, NOT_FORWARDED);\n headers.set(\"authorization\", `Bearer ${held.accessToken}`);\n\n const body = request.method === \"GET\" || request.method === \"HEAD\" ? null : request.body;\n const answer = await send(upstream, {\n method: request.method,\n headers,\n body,\n // The body is a stream and is forwarded as one: an upload is not read into this server's\n // memory on its way past, and a server-sent event stream is not buffered until it ends —\n // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.\n ...(body === null ? {} : { duplex: \"half\" }),\n // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it\n // here would send the bearer token to whatever host the `Location` names.\n redirect: \"manual\",\n } as RequestInit);\n\n const out = copyHeaders(answer.headers, NOT_RETURNED);\n // Ours last, so a renewal is never lost to an upstream that had opinions about cookies.\n for (const cookie of held.cookies) out.append(\"set-cookie\", cookie);\n\n return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });\n };\n\n return {\n GET: handle,\n POST: handle,\n PUT: handle,\n PATCH: handle,\n DELETE: handle,\n HEAD: handle,\n OPTIONS: handle,\n };\n}\n"],"names":["HOP_BY_HOP","NOT_FORWARDED","NOT_RETURNED","copyHeaders","from","without","headers","name","authProxy","config","token","authToken","send","args","base","target","prefix","refuse","status","handle","request","isSameSite","url","upstream","held","error","AuthError","body","answer","out","cookie"],"mappings":";;;AA8EA,MAAMA,IAAa;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAeMC,IAAgB,CAAC,GAAGD,GAAY,UAAU,iBAAiB,QAAQ,kBAAkB,iBAAiB,GAUtGE,IAAe,CAAC,GAAGF,GAAY,cAAc,oBAAoB,gBAAgB;AAEvF,SAASG,EAAYC,GAAeC,GAAqC;AACvE,QAAMC,IAAU,IAAI,QAAQF,CAAI;AAChC,aAAWG,KAAQF,EAAS,CAAAC,EAAQ,OAAOC,CAAI;AAC/C,SAAOD;AACT;AAEO,SAASE,EAAUC,GAA4C;AACpE,QAAMC,IAAQC,EAAUF,CAAM,GACxBG,IAAO,IAAIC,MAA8C,WAAW,MAAM,GAAGA,CAAI,GACjFC,IAAOL,EAAO,SAAS,QAAQ,OAAO,EAAE,GACxCM,IAAS,IAAI,IAAIN,EAAO,MAAM,GAE9BO,IAASD,EAAO,SAAS,QAAQ,OAAO,EAAE,GAE1CE,IAAS,CAACC,MACd,IAAI,SAAS,MAAM,EAAE,QAAAA,GAAQ,SAAS,EAAE,iBAAiB,WAAA,GAAc,GAEnEC,IAAS,OAAOC,MAAwC;AAC5D,QAAI,CAACC,EAAWD,CAAO,EAAG,QAAOH,EAAO,GAAG;AAE3C,UAAMK,IAAM,IAAI,IAAIF,EAAQ,GAAG;AAM/B,QAAI,CAACE,EAAI,SAAS,WAAWR,CAAI,EAAG,QAAOG,EAAO,GAAG;AAKrD,UAAMM,IAAW,IAAI,IAAIR,EAAO,IAAI;AACpC,IAAAQ,EAAS,WAAW,GAAGP,CAAM,GAAGM,EAAI,SAAS,MAAMR,EAAK,MAAM,CAAC,IAC/DS,EAAS,SAASD,EAAI;AAEtB,QAAIE;AACJ,QAAI;AACF,MAAAA,IAAO,MAAMd,EAAMU,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaX,EAAO,YAAA,CAAa;AAAA,IACvF,SAASgB,GAAO;AAId,UAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,aAAOR,EAAO,GAAG;AAAA,IACnB;AACA,QAAIO,MAAS,KAAM,QAAOP,EAAO,GAAG;AAEpC,UAAMX,IAAUH,EAAYiB,EAAQ,SAASnB,CAAa;AAC1D,IAAAK,EAAQ,IAAI,iBAAiB,UAAUkB,EAAK,WAAW,EAAE;AAEzD,UAAMG,IAAOP,EAAQ,WAAW,SAASA,EAAQ,WAAW,SAAS,OAAOA,EAAQ,MAC9EQ,IAAS,MAAMhB,EAAKW,GAAU;AAAA,MAClC,QAAQH,EAAQ;AAAA,MAChB,SAAAd;AAAA,MACA,MAAAqB;AAAA;AAAA;AAAA;AAAA,MAIA,GAAIA,MAAS,OAAO,CAAA,IAAK,EAAE,QAAQ,OAAA;AAAA;AAAA;AAAA,MAGnC,UAAU;AAAA,IAAA,CACI,GAEVE,IAAM1B,EAAYyB,EAAO,SAAS1B,CAAY;AAEpD,eAAW4B,KAAUN,EAAK,QAAS,CAAAK,EAAI,OAAO,cAAcC,CAAM;AAElE,WAAO,IAAI,SAASF,EAAO,MAAM,EAAE,QAAQA,EAAO,QAAQ,YAAYA,EAAO,YAAY,SAASC,GAAK;AAAA,EACzG;AAEA,SAAO;AAAA,IACL,KAAKV;AAAA,IACL,MAAMA;AAAA,IACN,KAAKA;AAAA,IACL,OAAOA;AAAA,IACP,QAAQA;AAAA,IACR,MAAMA;AAAA,IACN,SAASA;AAAA,EAAA;AAEb;"}
|
|
1
|
+
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { authToken } from \"./next-token\";\nimport type { AuthSessionConfig } from \"./next-session\";\nimport { isSameSite } from \"./same-site\";\nimport { outage } from \"./next-routes\";\nimport { AuthError } from \"./types\";\n\n/**\n * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a\n * bearer token, and the cookie that got it here stops at this line.\n *\n * ```ts\n * // app/api/data/[...path]/route.ts\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = authProxy({\n * issuer, clientId, clientSecret, secret,\n * basePath: \"/api/data\",\n * target: \"https://reports.internal/v1\",\n * });\n * ```\n *\n * ## Why this is in the package and not in the product\n *\n * Because it is the same ninety lines every time, and one of them is load-bearing in a way that\n * does not look it. **The `cookie` header must not be forwarded.** Leave it on and the resource\n * server receives a second credential — the session cookie — alongside the bearer token it asked\n * for, which is precisely the confusion the BFF pattern exists to remove: from then on a bug at\n * the far end can act as the person rather than as the token, and the token's scope and lifetime\n * stop being the boundary. Every other line here — the hop-by-hop headers, `duplex: \"half\"`,\n * `redirect: \"manual\"`, not buffering the body — is the sort of thing that is either right or\n * produces a symptom three layers away, and none of it is a product's idea of its own domain.\n *\n * ## What it answers without asking upstream\n *\n * - **401** when there is no session, or when the renewal was refused. There is nothing to\n * forward: a request with no credential is not the resource server's to refuse.\n * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.\n *\n * ## Renewal happens here, because here is somewhere a cookie can be set\n *\n * `authToken` renews when the access token is within a minute of expiry and hands back the\n * `Set-Cookie` that carries the rotated session; this attaches it to the proxied response. That is\n * the whole of the answer to *\"the token lives an hour and the cookie lives eight\"* — the seven\n * hours in between stop being seven hours of 401s that nothing recovers from.\n */\n\nexport interface AuthProxyConfig extends AuthSessionConfig {\n /** Where the upstream lives. A path here is a prefix: `https://reports.internal/v1`. */\n readonly target: string;\n /** Where this route file is mounted. Stripped from the path before the rest is appended to `target`. */\n readonly basePath: string;\n /** Seconds of remaining lifetime below which the access token is renewed. Default 60. */\n readonly renewWithin?: number;\n}\n\n/**\n * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`\n * one, which is a distinction worth a sentence because it is one field away from being invisible.\n * That `fetch` exists to reach the *identity provider* — it is the seam `internalOrigin` uses to\n * come at Keycloak from inside a cluster. A deployment that set it and found its resource-server\n * traffic going the same way would have every right to be surprised, so there is one name for one\n * transport and the other one is the platform's.\n */\n\nexport interface AuthProxyHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n PUT(request: Request): Promise<Response>;\n PATCH(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n OPTIONS(request: Request): Promise<Response>;\n}\n\n/**\n * Headers that describe **this** connection and not the message, per RFC 9110 §7.6.1.\n *\n * Forwarding them is how a proxy promises an upstream a connection it does not have. `te` and\n * `trailer` are here for the same reason the others are, and `upgrade` matters most: a forwarded\n * `Upgrade: websocket` invites an answer this handler has no way to complete.\n */\nconst HOP_BY_HOP = [\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n];\n\n/**\n * What is stripped from the browser's request on the way out, beyond the hop-by-hop set.\n *\n * - `cookie` — the whole point, above.\n * - `authorization` — ours is the only one that may be on this request; a caller's own would\n * otherwise decide which of the two the upstream reads.\n * - `host` — it names this server, not the upstream's, and `fetch` sets the right one.\n * - `content-length` — the body is re-streamed, and a length that survived a re-encode would be a\n * lie the transport has to discover.\n * - `accept-encoding` — the client's compression negotiation is with *us*; `fetch` runs its own\n * with the upstream and hands back a decoded body. Passing this on is how a response comes back\n * labelled `gzip` and already decompressed, which no browser recovers from.\n */\nconst NOT_FORWARDED = [...HOP_BY_HOP, \"cookie\", \"authorization\", \"host\", \"content-length\", \"accept-encoding\"];\n\n/**\n * What is stripped from the upstream's answer.\n *\n * `set-cookie` is the one worth the sentence: under this pattern the browser's cookie relationship\n * is with the BFF alone, and a resource server that could set a cookie on this origin could set\n * one named like ours. `content-encoding` and `content-length` go because `fetch` already decoded\n * the body, so both now describe a representation that no longer exists.\n */\nconst NOT_RETURNED = [...HOP_BY_HOP, \"set-cookie\", \"content-encoding\", \"content-length\"];\n\nfunction copyHeaders(from: Headers, without: readonly string[]): Headers {\n const headers = new Headers(from);\n for (const name of without) headers.delete(name);\n return headers;\n}\n\nexport function authProxy(config: AuthProxyConfig): AuthProxyHandlers {\n const token = authToken(config);\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n const base = config.basePath.replace(/\\/$/, \"\");\n const target = new URL(config.target);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\n\n const refuse = (status: number) =>\n new Response(null, { status, headers: { \"cache-control\": \"no-store\" } });\n\n const handle = async (request: Request): Promise<Response> => {\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path outside the mount is not one this handler was mounted for, and refusing it *is* the\n // traversal check: the URL parser has already resolved every dot segment — `..` and its\n // percent-encoded spellings alike, which is the parser's job and not a thing to re-implement\n // — so a path that tried to climb has already fallen out of the prefix by the time it is read.\n if (!url.pathname.startsWith(base)) return refuse(400);\n\n // Assigning `pathname` rather than composing a string: a path beginning `//` parsed as a *URL*\n // is protocol-relative and names another host, and `//evil.test/x` is a path a browser will\n // happily send. Set as a component it cannot reach the origin at all.\n const upstream = new URL(target.href);\n upstream.pathname = `${prefix}${url.pathname.slice(base.length)}`;\n upstream.search = url.search;\n\n let held: Awaited<ReturnType<typeof token>>;\n try {\n held = await token(request.headers.get(\"cookie\"), { renewWithin: config.renewWithin });\n } catch (error) {\n // A refused renewal is the end of the session and not an upstream failure. An IdP or a store\n // that did not answer is an outage, and anything else is a fault this module has no reading\n // of: hiding either behind a 401 would send a person to sign in again over something that\n // will still be there when they get back.\n if (!(error instanceof AuthError)) throw error;\n return refuse(outage(error.code) ?? 401);\n }\n if (held === null) return refuse(401);\n\n const headers = copyHeaders(request.headers, NOT_FORWARDED);\n headers.set(\"authorization\", `Bearer ${held.accessToken}`);\n\n const body = request.method === \"GET\" || request.method === \"HEAD\" ? null : request.body;\n const answer = await send(upstream, {\n method: request.method,\n headers,\n body,\n // The body is a stream and is forwarded as one: an upload is not read into this server's\n // memory on its way past, and a server-sent event stream is not buffered until it ends —\n // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.\n ...(body === null ? {} : { duplex: \"half\" }),\n // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it\n // here would send the bearer token to whatever host the `Location` names.\n redirect: \"manual\",\n } as RequestInit);\n\n const out = copyHeaders(answer.headers, NOT_RETURNED);\n // Ours last, so a renewal is never lost to an upstream that had opinions about cookies.\n for (const cookie of held.cookies) out.append(\"set-cookie\", cookie);\n\n return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });\n };\n\n return {\n GET: handle,\n POST: handle,\n PUT: handle,\n PATCH: handle,\n DELETE: handle,\n HEAD: handle,\n OPTIONS: handle,\n };\n}\n"],"names":["HOP_BY_HOP","NOT_FORWARDED","NOT_RETURNED","copyHeaders","from","without","headers","name","authProxy","config","token","authToken","send","args","base","target","prefix","refuse","status","handle","request","isSameSite","url","upstream","held","error","AuthError","outage","body","answer","out","cookie"],"mappings":";;;;AA+EA,MAAMA,IAAa;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAeMC,IAAgB,CAAC,GAAGD,GAAY,UAAU,iBAAiB,QAAQ,kBAAkB,iBAAiB,GAUtGE,IAAe,CAAC,GAAGF,GAAY,cAAc,oBAAoB,gBAAgB;AAEvF,SAASG,EAAYC,GAAeC,GAAqC;AACvE,QAAMC,IAAU,IAAI,QAAQF,CAAI;AAChC,aAAWG,KAAQF,EAAS,CAAAC,EAAQ,OAAOC,CAAI;AAC/C,SAAOD;AACT;AAEO,SAASE,EAAUC,GAA4C;AACpE,QAAMC,IAAQC,EAAUF,CAAM,GACxBG,IAAO,IAAIC,MAA8C,WAAW,MAAM,GAAGA,CAAI,GACjFC,IAAOL,EAAO,SAAS,QAAQ,OAAO,EAAE,GACxCM,IAAS,IAAI,IAAIN,EAAO,MAAM,GAE9BO,IAASD,EAAO,SAAS,QAAQ,OAAO,EAAE,GAE1CE,IAAS,CAACC,MACd,IAAI,SAAS,MAAM,EAAE,QAAAA,GAAQ,SAAS,EAAE,iBAAiB,WAAA,GAAc,GAEnEC,IAAS,OAAOC,MAAwC;AAC5D,QAAI,CAACC,EAAWD,CAAO,EAAG,QAAOH,EAAO,GAAG;AAE3C,UAAMK,IAAM,IAAI,IAAIF,EAAQ,GAAG;AAM/B,QAAI,CAACE,EAAI,SAAS,WAAWR,CAAI,EAAG,QAAOG,EAAO,GAAG;AAKrD,UAAMM,IAAW,IAAI,IAAIR,EAAO,IAAI;AACpC,IAAAQ,EAAS,WAAW,GAAGP,CAAM,GAAGM,EAAI,SAAS,MAAMR,EAAK,MAAM,CAAC,IAC/DS,EAAS,SAASD,EAAI;AAEtB,QAAIE;AACJ,QAAI;AACF,MAAAA,IAAO,MAAMd,EAAMU,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaX,EAAO,YAAA,CAAa;AAAA,IACvF,SAASgB,GAAO;AAKd,UAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,aAAOR,EAAOU,EAAOF,EAAM,IAAI,KAAK,GAAG;AAAA,IACzC;AACA,QAAID,MAAS,KAAM,QAAOP,EAAO,GAAG;AAEpC,UAAMX,IAAUH,EAAYiB,EAAQ,SAASnB,CAAa;AAC1D,IAAAK,EAAQ,IAAI,iBAAiB,UAAUkB,EAAK,WAAW,EAAE;AAEzD,UAAMI,IAAOR,EAAQ,WAAW,SAASA,EAAQ,WAAW,SAAS,OAAOA,EAAQ,MAC9ES,IAAS,MAAMjB,EAAKW,GAAU;AAAA,MAClC,QAAQH,EAAQ;AAAA,MAChB,SAAAd;AAAA,MACA,MAAAsB;AAAA;AAAA;AAAA;AAAA,MAIA,GAAIA,MAAS,OAAO,CAAA,IAAK,EAAE,QAAQ,OAAA;AAAA;AAAA;AAAA,MAGnC,UAAU;AAAA,IAAA,CACI,GAEVE,IAAM3B,EAAY0B,EAAO,SAAS3B,CAAY;AAEpD,eAAW6B,KAAUP,EAAK,QAAS,CAAAM,EAAI,OAAO,cAAcC,CAAM;AAElE,WAAO,IAAI,SAASF,EAAO,MAAM,EAAE,QAAQA,EAAO,QAAQ,YAAYA,EAAO,YAAY,SAASC,GAAK;AAAA,EACzG;AAEA,SAAO;AAAA,IACL,KAAKX;AAAA,IACL,MAAMA;AAAA,IACN,KAAKA;AAAA,IACL,OAAOA;AAAA,IACP,QAAQA;AAAA,IACR,MAAMA;AAAA,IACN,SAASA;AAAA,EAAA;AAEb;"}
|
package/dist/next-routes.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthSessionConfig } from './next-session';
|
|
2
|
+
import { AuthErrorCode } from './types';
|
|
2
3
|
export interface AuthRoutesConfig extends AuthSessionConfig {
|
|
3
4
|
/**
|
|
4
5
|
* Override the callback URL. Absent, it is derived from the incoming request: the origin it
|
|
@@ -10,10 +11,18 @@ export interface AuthRoutesConfig extends AuthSessionConfig {
|
|
|
10
11
|
* URL out loud here rather than discover this.
|
|
11
12
|
*/
|
|
12
13
|
readonly redirectUri?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The product's page that renders a failed sign-in, sign-in callback or sign-out, reached as
|
|
16
|
+
* `?code=<AuthErrorCode>`. Default `/auth/problem`. `authMiddleware` keeps the same default
|
|
17
|
+
* public: whoever lands here has, by definition, no session.
|
|
18
|
+
*/
|
|
19
|
+
readonly problemPage?: string;
|
|
13
20
|
}
|
|
14
21
|
export interface AuthRouteHandlers {
|
|
15
22
|
GET(request: Request): Promise<Response>;
|
|
16
23
|
POST(request: Request): Promise<Response>;
|
|
17
24
|
}
|
|
25
|
+
/** The status for a party that did not answer, or `undefined` for a refusal. */
|
|
26
|
+
export declare function outage(code: AuthErrorCode): number | undefined;
|
|
18
27
|
export declare function authRoutes(config: AuthRoutesConfig): AuthRouteHandlers;
|
|
19
28
|
//# sourceMappingURL=next-routes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,OAAO,EAAa,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAkDxD,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAKD,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC3C;AA0CD,gFAAgF;AAChF,wBAAgB,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAK9D;AAgBD,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,iBAAiB,CAmHtE"}
|