@kanzo-tech/auth 0.31.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +13 -1
- package/dist/next-proxy.js.map +1 -1
- package/package.json +1 -1
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":"AAAA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAMhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;GAOG;AAEH,MAAM,WAAW,WAAW;IAC1B,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;
|
|
1
|
+
{"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAMhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;GAOG;AAEH,MAAM,WAAW,WAAW;IAC1B,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;AA4ED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,CAsFnE"}
|
package/dist/next-proxy.js
CHANGED
|
@@ -11,7 +11,19 @@ const T = [
|
|
|
11
11
|
"trailer",
|
|
12
12
|
"transfer-encoding",
|
|
13
13
|
"upgrade"
|
|
14
|
-
], O = [
|
|
14
|
+
], O = [
|
|
15
|
+
...T,
|
|
16
|
+
"cookie",
|
|
17
|
+
"authorization",
|
|
18
|
+
"host",
|
|
19
|
+
"content-length",
|
|
20
|
+
"accept-encoding",
|
|
21
|
+
"origin",
|
|
22
|
+
"sec-fetch-site",
|
|
23
|
+
"sec-fetch-mode",
|
|
24
|
+
"sec-fetch-dest",
|
|
25
|
+
"sec-fetch-user"
|
|
26
|
+
], z = [...T, "set-cookie", "content-encoding", "content-length"];
|
|
15
27
|
function D(r) {
|
|
16
28
|
return r === "organization/denied" ? 403 : r === "organization/invalid" ? 400 : r === "token/exchange-failed" ? 502 : 401;
|
|
17
29
|
}
|
package/dist/next-proxy.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { tenantRequest, under, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport { isSameSite } from \"./same-site\";\nimport type { Ended, Token } from \"./server\";\nimport { AuthError, type AuthErrorCode } 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 for that resource server and that organization alone, and the cookie that got it\n * here stops at this line. `kanzoAuth().api`.\n *\n * ```ts\n * // lib/auth.ts: kanzoAuth(() => ({ …, apis: { \"/api/data\": { audience: \"reports\", target: \"https://reports.internal/v1\" } } }))\n * // app/api/[...path]/route.ts — one route file can serve every mount under it\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;\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 — with the cookie cleared in\n * the same answer. There is nothing to forward: a request with no credential is not the resource\n * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is\n * refused too.\n * - **403** when the request is not same-site — `same-site.ts` carries why the package owes this —\n * and when the realm will not issue a token for the organization the request addresses, because\n * the person is not a member of it.\n * - **404** when `kanzoAuth` was given no `apis`, or the mount's `target` names no server for the\n * organization.\n * - **400** for a path under no mount, and for an organization that is not an alias.\n * - **502** when the realm would not exchange the token at all: the application is not allowed to\n * ask for that audience, which is the deployment's to fix and not the person's.\n *\n * ## Renewal happens here too\n *\n * The proxy renews before a page renders, and a client that stays on one page for longer than an\n * access token lives renews here: within a minute of expiry, in place, single-flight per ticket\n * with every other renewal of that session in the process.\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 ApiHandlers {\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\n/** What an `AuthError` from getting the token answers, when it is not the IdP's or the store's outage. */\nfunction refusal(code: AuthErrorCode): number {\n if (code === \"organization/denied\") return 403;\n if (code === \"organization/invalid\") return 400;\n if (code === \"token/exchange-failed\") return 502;\n return 401;\n}\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 forward(instance: () => Promise<Bound>): ApiHandlers {\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n\n const refuse = (status: number, cookies: readonly string[] = []) => {\n const headers = new Headers({ \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status, headers });\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n if (bound.apis.length === 0) return refuse(404);\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path under no 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 every mount by the time it is read.\n const api = bound.apis.find((candidate) => under(url.pathname, [candidate.mount]));\n if (api === undefined) return refuse(400);\n\n const organization = await bound.tenant(tenantRequest(request));\n const target = api.target(organization);\n if (target === undefined) return refuse(404);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\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(api.mount.length)}`;\n upstream.search = url.search;\n\n let held: Token | Ended;\n try {\n held = await bound.party.token(request.headers.get(\"cookie\"), {\n audience: api.audience,\n organization,\n renewWithin: bound.renewWithin,\n });\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) ?? refusal(error.code));\n }\n if (held.ended) return refuse(401, held.cookies);\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","refusal","code","copyHeaders","from","without","headers","name","forward","instance","send","args","refuse","status","cookies","cookie","handle","request","bound","isSameSite","url","api","candidate","under","organization","tenantRequest","target","prefix","upstream","held","error","AuthError","outage","body","answer","out"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { tenantRequest, under, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport { isSameSite } from \"./same-site\";\nimport type { Ended, Token } from \"./server\";\nimport { AuthError, type AuthErrorCode } 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 for that resource server and that organization alone, and the cookie that got it\n * here stops at this line. `kanzoAuth().api`.\n *\n * ```ts\n * // lib/auth.ts: kanzoAuth(() => ({ …, apis: { \"/api/data\": { audience: \"reports\", target: \"https://reports.internal/v1\" } } }))\n * // app/api/[...path]/route.ts — one route file can serve every mount under it\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;\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 — with the cookie cleared in\n * the same answer. There is nothing to forward: a request with no credential is not the resource\n * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is\n * refused too.\n * - **403** when the request is not same-site — `same-site.ts` carries why the package owes this —\n * and when the realm will not issue a token for the organization the request addresses, because\n * the person is not a member of it.\n * - **404** when `kanzoAuth` was given no `apis`, or the mount's `target` names no server for the\n * organization.\n * - **400** for a path under no mount, and for an organization that is not an alias.\n * - **502** when the realm would not exchange the token at all: the application is not allowed to\n * ask for that audience, which is the deployment's to fix and not the person's.\n *\n * ## Renewal happens here too\n *\n * The proxy renews before a page renders, and a client that stays on one page for longer than an\n * access token lives renews here: within a minute of expiry, in place, single-flight per ticket\n * with every other renewal of that session in the process.\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 ApiHandlers {\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 * - `origin` and the Fetch Metadata headers — what a browser says about the page that caused *its*\n * request (RFC 6454 §7, W3C Fetch Metadata), which `isSameSite` has already read. The request to\n * the upstream is this server's, caused by no page, and an upstream that checks `Origin` — Docker\n * Model Runner behind the AI gateway refuses any it does not list with a 403 — would otherwise\n * judge a browser that never addressed it.\n */\nconst NOT_FORWARDED = [\n ...HOP_BY_HOP,\n \"cookie\",\n \"authorization\",\n \"host\",\n \"content-length\",\n \"accept-encoding\",\n \"origin\",\n \"sec-fetch-site\",\n \"sec-fetch-mode\",\n \"sec-fetch-dest\",\n \"sec-fetch-user\",\n];\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\n/** What an `AuthError` from getting the token answers, when it is not the IdP's or the store's outage. */\nfunction refusal(code: AuthErrorCode): number {\n if (code === \"organization/denied\") return 403;\n if (code === \"organization/invalid\") return 400;\n if (code === \"token/exchange-failed\") return 502;\n return 401;\n}\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 forward(instance: () => Promise<Bound>): ApiHandlers {\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n\n const refuse = (status: number, cookies: readonly string[] = []) => {\n const headers = new Headers({ \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status, headers });\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n if (bound.apis.length === 0) return refuse(404);\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path under no 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 every mount by the time it is read.\n const api = bound.apis.find((candidate) => under(url.pathname, [candidate.mount]));\n if (api === undefined) return refuse(400);\n\n const organization = await bound.tenant(tenantRequest(request));\n const target = api.target(organization);\n if (target === undefined) return refuse(404);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\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(api.mount.length)}`;\n upstream.search = url.search;\n\n let held: Token | Ended;\n try {\n held = await bound.party.token(request.headers.get(\"cookie\"), {\n audience: api.audience,\n organization,\n renewWithin: bound.renewWithin,\n });\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) ?? refusal(error.code));\n }\n if (held.ended) return refuse(401, held.cookies);\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","refusal","code","copyHeaders","from","without","headers","name","forward","instance","send","args","refuse","status","cookies","cookie","handle","request","bound","isSameSite","url","api","candidate","under","organization","tenantRequest","target","prefix","upstream","held","error","AuthError","outage","body","answer","out"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Kanzo authentication over Keycloak — a Backend For Frontend for Next (kanzoAuth), the claim vocabulary read into one Session, and the role evaluation that knows about organizations. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude auth from the generic vocabulary by name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|