@kanzo-tech/auth 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/next-auth.d.ts +1 -1
- package/dist/next-auth.d.ts.map +1 -1
- package/dist/next-auth.js.map +1 -1
- package/dist/next-bound.d.ts +37 -18
- package/dist/next-bound.d.ts.map +1 -1
- package/dist/next-bound.js +49 -31
- package/dist/next-bound.js.map +1 -1
- package/dist/next-gate.d.ts.map +1 -1
- package/dist/next-gate.js +41 -44
- package/dist/next-gate.js.map +1 -1
- package/dist/next-proxy.d.ts +12 -5
- package/dist/next-proxy.d.ts.map +1 -1
- package/dist/next-proxy.js +51 -44
- package/dist/next-proxy.js.map +1 -1
- package/dist/next-routes.d.ts.map +1 -1
- package/dist/next-routes.js +12 -12
- package/dist/next-routes.js.map +1 -1
- package/dist/renew-within.d.ts +10 -0
- package/dist/renew-within.d.ts.map +1 -0
- package/dist/renew-within.js +5 -0
- package/dist/renew-within.js.map +1 -0
- package/dist/same-site.d.ts +0 -27
- package/dist/same-site.d.ts.map +1 -1
- package/dist/same-site.js +4 -3
- package/dist/same-site.js.map +1 -1
- package/dist/server.d.ts +44 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +202 -164
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +3 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/next-proxy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { tenantRequest as
|
|
2
|
-
import { outage as
|
|
3
|
-
import { isSameSite as
|
|
4
|
-
import { AuthError as
|
|
1
|
+
import { under as E, tenantRequest as y } from "./next-bound.js";
|
|
2
|
+
import { outage as R } from "./next-routes.js";
|
|
3
|
+
import { isSameSite as x } from "./same-site.js";
|
|
4
|
+
import { AuthError as H } from "./types.js";
|
|
5
5
|
const T = [
|
|
6
6
|
"connection",
|
|
7
7
|
"keep-alive",
|
|
@@ -11,63 +11,70 @@ const T = [
|
|
|
11
11
|
"trailer",
|
|
12
12
|
"transfer-encoding",
|
|
13
13
|
"upgrade"
|
|
14
|
-
],
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
], O = [...T, "cookie", "authorization", "host", "content-length", "accept-encoding"], z = [...T, "set-cookie", "content-encoding", "content-length"];
|
|
15
|
+
function D(r) {
|
|
16
|
+
return r === "organization/denied" ? 403 : r === "organization/invalid" ? 400 : r === "token/exchange-failed" ? 502 : 401;
|
|
17
|
+
}
|
|
18
|
+
function w(r, h) {
|
|
19
|
+
const t = new Headers(r);
|
|
20
|
+
for (const n of h) t.delete(n);
|
|
18
21
|
return t;
|
|
19
22
|
}
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
for (const
|
|
24
|
-
return new Response(null, { status: e, headers:
|
|
25
|
-
},
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
if (!
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
const u = new URL(
|
|
34
|
-
|
|
23
|
+
function N(r) {
|
|
24
|
+
const h = (...e) => globalThis.fetch(...e), t = (e, a = []) => {
|
|
25
|
+
const i = new Headers({ "cache-control": "no-store" });
|
|
26
|
+
for (const c of a) i.append("set-cookie", c);
|
|
27
|
+
return new Response(null, { status: e, headers: i });
|
|
28
|
+
}, n = async (e) => {
|
|
29
|
+
const a = await r();
|
|
30
|
+
if (a.apis.length === 0) return t(404);
|
|
31
|
+
if (!x(e)) return t(403);
|
|
32
|
+
const i = new URL(e.url), c = a.apis.find((o) => E(i.pathname, [o.mount]));
|
|
33
|
+
if (c === void 0) return t(400);
|
|
34
|
+
const l = await a.tenant(y(e)), u = c.target(l);
|
|
35
|
+
if (u === void 0) return t(404);
|
|
36
|
+
const k = u.pathname.replace(/\/$/, ""), f = new URL(u.href);
|
|
37
|
+
f.pathname = `${k}${i.pathname.slice(c.mount.length)}`, f.search = i.search;
|
|
35
38
|
let s;
|
|
36
39
|
try {
|
|
37
|
-
s = await
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
s = await a.party.token(e.headers.get("cookie"), {
|
|
41
|
+
audience: c.audience,
|
|
42
|
+
organization: l,
|
|
43
|
+
renewWithin: a.renewWithin
|
|
44
|
+
});
|
|
45
|
+
} catch (o) {
|
|
46
|
+
if (!(o instanceof H)) throw o;
|
|
47
|
+
return t(R(o.code) ?? D(o.code));
|
|
41
48
|
}
|
|
42
49
|
if (s.ended) return t(401, s.cookies);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
50
|
+
const p = w(e.headers, O);
|
|
51
|
+
p.set("authorization", `Bearer ${s.accessToken}`);
|
|
52
|
+
const m = e.method === "GET" || e.method === "HEAD" ? null : e.body, d = await h(f, {
|
|
46
53
|
method: e.method,
|
|
47
|
-
headers:
|
|
48
|
-
body:
|
|
54
|
+
headers: p,
|
|
55
|
+
body: m,
|
|
49
56
|
// The body is a stream and is forwarded as one: an upload is not read into this server's
|
|
50
57
|
// memory on its way past, and a server-sent event stream is not buffered until it ends —
|
|
51
58
|
// which for an SSE endpoint means never. `duplex` is what Node requires to allow it.
|
|
52
|
-
...
|
|
59
|
+
...m === null ? {} : { duplex: "half" },
|
|
53
60
|
// A 302 from the upstream is the upstream's answer and belongs to the caller. Following it
|
|
54
61
|
// here would send the bearer token to whatever host the `Location` names.
|
|
55
62
|
redirect: "manual"
|
|
56
|
-
}),
|
|
57
|
-
for (const
|
|
58
|
-
return new Response(
|
|
63
|
+
}), g = w(d.headers, z);
|
|
64
|
+
for (const o of s.cookies) g.append("set-cookie", o);
|
|
65
|
+
return new Response(d.body, { status: d.status, statusText: d.statusText, headers: g });
|
|
59
66
|
};
|
|
60
67
|
return {
|
|
61
|
-
GET:
|
|
62
|
-
POST:
|
|
63
|
-
PUT:
|
|
64
|
-
PATCH:
|
|
65
|
-
DELETE:
|
|
66
|
-
HEAD:
|
|
67
|
-
OPTIONS:
|
|
68
|
+
GET: n,
|
|
69
|
+
POST: n,
|
|
70
|
+
PUT: n,
|
|
71
|
+
PATCH: n,
|
|
72
|
+
DELETE: n,
|
|
73
|
+
HEAD: n,
|
|
74
|
+
OPTIONS: n
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
77
|
export {
|
|
71
|
-
|
|
78
|
+
N as forward
|
|
72
79
|
};
|
|
73
80
|
//# 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 { tenantRequest, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport { isSameSite } from \"./same-site\";\nimport type { Ended, Token } from \"./server\";\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. `kanzoAuth().api`.\n *\n * ```ts\n * // lib/auth.ts: kanzoAuth(() => ({ …, api: { mount: \"/api/data\", target: \"https://reports.internal/v1\" } }))\n * // app/api/data/[...path]/route.ts\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 * - **404** when `kanzoAuth` was given no `api`.\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\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.api === undefined) return refuse(404);\n if (!isSameSite(request)) return refuse(403);\n\n const base = bound.api.mount;\n const target = bound.api.target(await bound.tenant(tenantRequest(request)));\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 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: Token | Ended;\n try {\n held = await bound.party.token(request.headers.get(\"cookie\"), { renewWithin: bound.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.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","copyHeaders","from","without","headers","name","forward","instance","send","args","refuse","status","cookies","cookie","handle","request","bound","isSameSite","base","target","tenantRequest","prefix","url","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 */\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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2C,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAa,KAAK,aAAa,EAAgB,MAAM,SAAS,CAAC;AA4DtE,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC3C;AA+CD,gFAAgF;AAChF,wBAAgB,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAK9D;AAmBD,uGAAuG;AACvG,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CA2HpE"}
|
package/dist/next-routes.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isSameSite as
|
|
1
|
+
import { addressedUrl as T, withTenant as P, tenantRequest as R } from "./next-bound.js";
|
|
2
|
+
import { isSameSite as S } from "./same-site.js";
|
|
3
3
|
import { AuthError as w } from "./types.js";
|
|
4
4
|
const p = { "content-type": "application/json", "cache-control": "no-store" };
|
|
5
|
-
function
|
|
5
|
+
function O(n) {
|
|
6
6
|
const r = n.lastIndexOf("/");
|
|
7
7
|
return { action: n.slice(r + 1), base: r <= 0 ? "" : n.slice(0, r) };
|
|
8
8
|
}
|
|
@@ -23,28 +23,28 @@ function f(n, r) {
|
|
|
23
23
|
function u(n, r = {}) {
|
|
24
24
|
return new Response(null, { status: n, headers: { "cache-control": "no-store", ...r } });
|
|
25
25
|
}
|
|
26
|
-
function
|
|
26
|
+
function v(n) {
|
|
27
27
|
if (n === "idp/silent" || n === "session/silent") return 504;
|
|
28
28
|
if (n === "idp/unreachable") return 502;
|
|
29
29
|
if (n === "session/unavailable") return 503;
|
|
30
30
|
}
|
|
31
31
|
function g(n) {
|
|
32
32
|
if (!(n instanceof w)) throw n;
|
|
33
|
-
return d(n.code, n.message,
|
|
33
|
+
return d(n.code, n.message, v(n.code) ?? 400);
|
|
34
34
|
}
|
|
35
35
|
function d(n, r, o, a = []) {
|
|
36
36
|
const s = new Headers(p);
|
|
37
37
|
for (const c of a) s.append("set-cookie", c);
|
|
38
38
|
return new Response(JSON.stringify({ error: n, message: r }), { status: o, headers: s });
|
|
39
39
|
}
|
|
40
|
-
function
|
|
40
|
+
function J(n) {
|
|
41
41
|
const r = async (o) => {
|
|
42
|
-
const a = await n(), s =
|
|
42
|
+
const a = await n(), s = T(o), { action: c, base: b } = O(s.pathname), l = o.headers.get("cookie"), i = a.party, k = a.redirectUri ?? `${s.origin}${b}/callback`, h = (e) => {
|
|
43
43
|
if (!(e instanceof w)) throw e;
|
|
44
44
|
const t = new URL(a.problemPage, s.origin);
|
|
45
45
|
return t.searchParams.set("code", e.code), f(t.href, []);
|
|
46
46
|
};
|
|
47
|
-
if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !
|
|
47
|
+
if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !S(o)) return u(403);
|
|
48
48
|
switch (c) {
|
|
49
49
|
case "signin":
|
|
50
50
|
try {
|
|
@@ -76,9 +76,9 @@ function E(n) {
|
|
|
76
76
|
case "session": {
|
|
77
77
|
let e;
|
|
78
78
|
try {
|
|
79
|
-
e = await i.read(l), e !== null && (e =
|
|
79
|
+
e = await i.read(l), e !== null && (e = P(
|
|
80
80
|
e,
|
|
81
|
-
await a.tenant(
|
|
81
|
+
await a.tenant(R(o))
|
|
82
82
|
));
|
|
83
83
|
} catch (t) {
|
|
84
84
|
return g(t);
|
|
@@ -115,7 +115,7 @@ function E(n) {
|
|
|
115
115
|
return { GET: r, POST: r };
|
|
116
116
|
}
|
|
117
117
|
export {
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
v as outage,
|
|
119
|
+
J as routes
|
|
120
120
|
};
|
|
121
121
|
//# sourceMappingURL=next-routes.js.map
|
package/dist/next-routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { tenantRequest, withTenant, type Bound } from \"./next-bound\";\nimport { isSameSite } from \"./same-site\";\nimport { AuthError, type AuthErrorCode, type Session } from \"./types\";\n\n/**\n * The six routes a Backend For Frontend needs, as one App Router catch-all: `kanzoAuth().routes`.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n * ```\n *\n * `relyingParty` already does the whole flow in strings — a URL and a `Cookie` header in, a URL and\n * `Set-Cookie` values out — so the only thing written here is the translation into `Request` and\n * `Response`, plus the two decisions that translation forces: what a route answers when there is\n * no session, and where it is willing to send a browser afterwards.\n *\n * **Nothing in this module imports `next`.** An App Router route handler is handed a standard\n * `Request` and may answer with a standard `Response`, so the framework's own types would buy\n * nothing and would make this half untestable without it. The door earns its subpath through\n * `next-auth.ts` and `next-gate.ts`, which genuinely cannot be written without `next`.\n *\n * ## The other end of the contract\n *\n * `bffAuth` in the root barrel is the browser half, and it is specific: it `GET`s\n * `${basePath}/session` and reads **401 as \"nobody is signed in\"**, not as a failure; it `POST`s\n * `${basePath}/refresh` when a request of its own comes back 401, and retries that request once if\n * the renewal worked, and signs in once if it was refused; it navigates to\n * `${basePath}/signin?returnTo=…&organization=…` and `${basePath}/signout?returnTo=…`. Those paths\n * and those status codes are the contract, and `next-routes.test.ts` drives a real `bffAuth`\n * against these handlers rather than trusting the two descriptions to agree.\n *\n * ## A failure on a navigation is a page, not a body\n *\n * `signin`, `callback` and `signout` are reached by the browser's address bar, so an `AuthError`\n * there answers with a redirect to the product's `problemPage`, the code in `?code=`, where the\n * product renders it in its own words. `session` and `refresh` are reached by `fetch`, and answer\n * with a status and `{ error, message }`.\n *\n * ## Back-channel logout\n *\n * `backchannel-logout` is where Keycloak posts a logout token when a session ends at the IdP — an\n * administrator's sign-out, a session that expired there, a sign-out from another application —\n * per OpenID Connect Back-Channel Logout 1.0. It answers 200 once the sessions it names are\n * dropped, 400 for a token that does not verify, and 501 when the store cannot end a session from\n * the server (`session/irrevocable`): the specification's answers, so Keycloak's admin console\n * reports the failure rather than believing a logout happened.\n *\n * ## Which routes a cross-site request may reach\n *\n * `callback` and `signin` must be reachable from anywhere — one *is* a navigation from the\n * identity provider, and the other is a link somebody is allowed to put on another page — and so\n * must `backchannel-logout`, which is a server's POST, not a page's, and is authenticated by the\n * signature on the token it carries. The other three are not: `signout` reached cross-site is a\n * logout anyone can cause, `refresh` is a rotation anyone can cause, and `session` is a person's\n * identity read from a page that is not ours. `same-site.ts` carries the check and the reason the\n * package rather than the product owes it.\n */\n\n/** The session endpoint answers about a person; no cache may ever hold that answer. */\nconst PRIVATE = { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } as const;\n\nexport interface RouteHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n}\n\n/** The last path segment, and everything before it. `/api/auth/signin` → `signin`, `/api/auth`. */\nfunction split(pathname: string): { readonly action: string; readonly base: string } {\n const cut = pathname.lastIndexOf(\"/\");\n return { action: pathname.slice(cut + 1), base: cut <= 0 ? \"\" : pathname.slice(0, cut) };\n}\n\n/**\n * A `returnTo` confined to this origin, or `undefined`.\n *\n * Without this the sign-in route is an open redirect: `?returnTo=https://evil.test` is carried\n * through the whole flow and spent on a browser that has just proved who it is, which is the most\n * valuable moment to hijack. The check is here, where the string arrives from a query parameter,\n * and deliberately *not* repeated on the callback — what comes back out at the callback was\n * sealed into a cookie by us, so re-checking it would only make it unclear which check is the\n * real one.\n */\nfunction sameOrigin(candidate: string | null, origin: string): string | undefined {\n if (candidate === null || candidate.length === 0) return undefined;\n try {\n const target = new URL(candidate, origin);\n return target.origin === origin ? target.href : undefined;\n } catch {\n return undefined; // not a URL at all: dropped like a foreign one, and the sign-in goes to `/`\n }\n}\n\n/**\n * A redirect that can carry cookies.\n *\n * `Response.redirect()` cannot: its headers are immutable, and a callback that cannot set a cookie\n * is a sign-in that never completes. And `append`, never `set` — the callback answers with **two**\n * `Set-Cookie` values, the session it just issued and the transaction it just spent, and `set`\n * would silently keep only the last of them.\n */\nfunction redirect(url: string, cookies: readonly string[]): Response {\n const headers = new Headers({ location: url, \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status: 302, headers });\n}\n\n/** No body, never cached: a status is the whole answer. */\nfunction bare(status: number, headers: Record<string, string> = {}): Response {\n return new Response(null, { status, headers: { \"cache-control\": \"no-store\", ...headers } });\n}\n\n/** The status for a party that did not answer, or `undefined` for a refusal. */\nexport function outage(code: AuthErrorCode): number | undefined {\n if (code === \"idp/silent\" || code === \"session/silent\") return 504;\n if (code === \"idp/unreachable\") return 502;\n if (code === \"session/unavailable\") return 503;\n return undefined;\n}\n\n/**\n * An `AuthError` as a status and a code a product can route on.\n *\n * Anything else is rethrown: a failure this module has no reading of is Next's to report, and\n * flattening it into a 400 here would hide a misconfiguration behind a message about credentials.\n */\nfunction failure(error: unknown): Response {\n if (!(error instanceof AuthError)) throw error;\n return coded(error.code, error.message, outage(error.code) ?? 400);\n}\n\nfunction coded(code: AuthErrorCode, message: string, status: number, cookies: readonly string[] = []): Response {\n const headers = new Headers(PRIVATE);\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(JSON.stringify({ error: code, message }), { status, headers });\n}\n\n/** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */\nexport function routes(instance: () => Promise<Bound>): RouteHandlers {\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n const url = new URL(request.url);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = bound.party;\n const redirectUri = bound.redirectUri ?? `${url.origin}${base}/callback`;\n\n /** An `AuthError` on a navigation, as the product's page; anything else rethrown, as in `failure`. */\n const problem = (error: unknown): Response => {\n if (!(error instanceof AuthError)) throw error;\n const page = new URL(bound.problemPage, url.origin);\n page.searchParams.set(\"code\", error.code);\n return redirect(page.href, []);\n };\n\n const crossable = action === \"callback\" || action === \"signin\" || action === \"backchannel-logout\";\n if (!crossable && !isSameSite(request)) return bare(403);\n\n switch (action) {\n case \"signin\": {\n try {\n const started = await auth.begin({\n redirectUri,\n returnTo: sameOrigin(url.searchParams.get(\"returnTo\"), url.origin) ?? \"/\",\n organization: url.searchParams.get(\"organization\") ?? undefined,\n });\n return redirect(started.url, started.cookies);\n } catch (error) {\n // `organization` arrives from a query parameter and `begin` refuses one that is not an\n // alias, because a space in it injects scopes; and the IdP may be down. Without this\n // `catch` either is a 500 on a link.\n return problem(error);\n }\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie, redirectUri });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"signout\": {\n const returnTo = sameOrigin(url.searchParams.get(\"returnTo\"), url.origin);\n try {\n const ended = await auth.end(cookie, { returnTo });\n return redirect(ended.url, ended.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"session\": {\n let session: Session | null;\n try {\n session = await auth.read(cookie);\n if (session !== null) {\n session = withTenant(\n session,\n await bound.tenant(tenantRequest(request)),\n );\n }\n } catch (error) {\n return failure(error);\n }\n // 401 and no body at all. `bffAuth` reads this status as \"nobody is signed in\" and stops;\n // a body would be parsed by something eventually, and an error shape arriving where a\n // `Session` is expected is the failure `readSession` exists to refuse.\n if (session === null) return bare(401);\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n case \"refresh\": {\n // `POST` only. The other routes are reached by navigation, where the verb is the browser's\n // to choose; this one spends a refresh token, and a `GET` that spends something is a link,\n // a prefetch and a preview pane away from spending it.\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n try {\n const renewed = await auth.refresh(cookie);\n // An ended session is a 401 and the cookie cleared in the same answer, so the browser\n // stops presenting a ticket whose row is gone. An outage is a 5xx, which `bffAuth` does\n // not read as an ended session.\n if (renewed.ended) {\n return coded(renewed.code, \"there is no session left to renew\", 401, renewed.cookies);\n }\n const headers = new Headers(PRIVATE);\n for (const value of renewed.cookies) headers.append(\"set-cookie\", value);\n return new Response(JSON.stringify(renewed.session), { status: 200, headers });\n } catch (error) {\n return failure(error);\n }\n }\n\n case \"backchannel-logout\": {\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n // §2.5: `application/x-www-form-urlencoded`, one parameter.\n const token = new URLSearchParams(await request.text()).get(\"logout_token\");\n if (token === null || token === \"\") return coded(\"token/refused\", \"no `logout_token`\", 400);\n try {\n await auth.logout(token);\n return bare(200);\n } catch (error) {\n if (error instanceof AuthError && error.code === \"session/irrevocable\") {\n return coded(error.code, error.message, 501);\n }\n return failure(error);\n }\n }\n\n default:\n return new Response(null, { status: 404 });\n }\n };\n\n // One handler behind both verbs. Every route here is reached by navigation or by `fetch`, and\n // which verb a product uses for sign-out — a link or a form — is its choice, not ours to\n // constrain with a second table that could drift from this one. `refresh` and\n // `backchannel-logout` are the exceptions and check their own method.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","bare","status","outage","code","failure","error","AuthError","coded","message","routes","instance","handle","request","bound","action","base","auth","redirectUri","problem","page","isSameSite","started","done","returnTo","ended","session","withTenant","tenantRequest","renewed","value","token"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { addressedUrl, tenantRequest, withTenant, type Bound } from \"./next-bound\";\nimport { isSameSite } from \"./same-site\";\nimport { AuthError, type AuthErrorCode, type Session } from \"./types\";\n\n/**\n * The six routes a Backend For Frontend needs, as one App Router catch-all: `kanzoAuth().routes`.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n * ```\n *\n * `relyingParty` already does the whole flow in strings — a URL and a `Cookie` header in, a URL and\n * `Set-Cookie` values out — so the only thing written here is the translation into `Request` and\n * `Response`, plus the two decisions that translation forces: what a route answers when there is\n * no session, and where it is willing to send a browser afterwards.\n *\n * **Nothing in this module imports `next`.** An App Router route handler is handed a standard\n * `Request` and may answer with a standard `Response`, so the framework's own types would buy\n * nothing and would make this half untestable without it. The door earns its subpath through\n * `next-auth.ts` and `next-gate.ts`, which genuinely cannot be written without `next`.\n *\n * ## The other end of the contract\n *\n * `bffAuth` in the root barrel is the browser half, and it is specific: it `GET`s\n * `${basePath}/session` and reads **401 as \"nobody is signed in\"**, not as a failure; it `POST`s\n * `${basePath}/refresh` when a request of its own comes back 401, and retries that request once if\n * the renewal worked, and signs in once if it was refused; it navigates to\n * `${basePath}/signin?returnTo=…&organization=…` and `${basePath}/signout?returnTo=…`. Those paths\n * and those status codes are the contract, and `next-routes.test.ts` drives a real `bffAuth`\n * against these handlers rather than trusting the two descriptions to agree.\n *\n * ## A failure on a navigation is a page, not a body\n *\n * `signin`, `callback` and `signout` are reached by the browser's address bar, so an `AuthError`\n * there answers with a redirect to the product's `problemPage`, the code in `?code=`, where the\n * product renders it in its own words. `session` and `refresh` are reached by `fetch`, and answer\n * with a status and `{ error, message }`.\n *\n * ## Back-channel logout\n *\n * `backchannel-logout` is where Keycloak posts a logout token when a session ends at the IdP — an\n * administrator's sign-out, a session that expired there, a sign-out from another application —\n * per OpenID Connect Back-Channel Logout 1.0. It answers 200 once the sessions it names are\n * dropped, 400 for a token that does not verify, and 501 when the store cannot end a session from\n * the server (`session/irrevocable`): the specification's answers, so Keycloak's admin console\n * reports the failure rather than believing a logout happened.\n *\n * ## Which routes a cross-site request may reach\n *\n * `callback` and `signin` must be reachable from anywhere — one *is* a navigation from the\n * identity provider, and the other is a link somebody is allowed to put on another page — and so\n * must `backchannel-logout`, which is a server's POST, not a page's, and is authenticated by the\n * signature on the token it carries. The other three are not: `signout` reached cross-site is a\n * logout anyone can cause, `refresh` is a rotation anyone can cause, and `session` is a person's\n * identity read from a page that is not ours. `same-site.ts` carries the check and the reason the\n * package rather than the product owes it.\n */\n\n/** The session endpoint answers about a person; no cache may ever hold that answer. */\nconst PRIVATE = { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } as const;\n\nexport interface RouteHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n}\n\n/** The last path segment, and everything before it. `/api/auth/signin` → `signin`, `/api/auth`. */\nfunction split(pathname: string): { readonly action: string; readonly base: string } {\n const cut = pathname.lastIndexOf(\"/\");\n return { action: pathname.slice(cut + 1), base: cut <= 0 ? \"\" : pathname.slice(0, cut) };\n}\n\n/**\n * A `returnTo` confined to this origin, or `undefined`.\n *\n * Without this the sign-in route is an open redirect: `?returnTo=https://evil.test` is carried\n * through the whole flow and spent on a browser that has just proved who it is, which is the most\n * valuable moment to hijack. The check is here, where the string arrives from a query parameter,\n * and deliberately *not* repeated on the callback — what comes back out at the callback was\n * sealed into a cookie by us, so re-checking it would only make it unclear which check is the\n * real one.\n */\nfunction sameOrigin(candidate: string | null, origin: string): string | undefined {\n if (candidate === null || candidate.length === 0) return undefined;\n try {\n const target = new URL(candidate, origin);\n return target.origin === origin ? target.href : undefined;\n } catch {\n return undefined; // not a URL at all: dropped like a foreign one, and the sign-in goes to `/`\n }\n}\n\n/**\n * A redirect that can carry cookies.\n *\n * `Response.redirect()` cannot: its headers are immutable, and a callback that cannot set a cookie\n * is a sign-in that never completes. And `append`, never `set` — the callback answers with **two**\n * `Set-Cookie` values, the session it just issued and the transaction it just spent, and `set`\n * would silently keep only the last of them.\n */\nfunction redirect(url: string, cookies: readonly string[]): Response {\n const headers = new Headers({ location: url, \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status: 302, headers });\n}\n\n/** No body, never cached: a status is the whole answer. */\nfunction bare(status: number, headers: Record<string, string> = {}): Response {\n return new Response(null, { status, headers: { \"cache-control\": \"no-store\", ...headers } });\n}\n\n/** The status for a party that did not answer, or `undefined` for a refusal. */\nexport function outage(code: AuthErrorCode): number | undefined {\n if (code === \"idp/silent\" || code === \"session/silent\") return 504;\n if (code === \"idp/unreachable\") return 502;\n if (code === \"session/unavailable\") return 503;\n return undefined;\n}\n\n/**\n * An `AuthError` as a status and a code a product can route on.\n *\n * Anything else is rethrown: a failure this module has no reading of is Next's to report, and\n * flattening it into a 400 here would hide a misconfiguration behind a message about credentials.\n */\nfunction failure(error: unknown): Response {\n if (!(error instanceof AuthError)) throw error;\n return coded(error.code, error.message, outage(error.code) ?? 400);\n}\n\nfunction coded(code: AuthErrorCode, message: string, status: number, cookies: readonly string[] = []): Response {\n const headers = new Headers(PRIVATE);\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(JSON.stringify({ error: code, message }), { status, headers });\n}\n\n/** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */\nexport function routes(instance: () => Promise<Bound>): RouteHandlers {\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n const url = addressedUrl(request);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = bound.party;\n const redirectUri = bound.redirectUri ?? `${url.origin}${base}/callback`;\n\n /** An `AuthError` on a navigation, as the product's page; anything else rethrown, as in `failure`. */\n const problem = (error: unknown): Response => {\n if (!(error instanceof AuthError)) throw error;\n const page = new URL(bound.problemPage, url.origin);\n page.searchParams.set(\"code\", error.code);\n return redirect(page.href, []);\n };\n\n const crossable = action === \"callback\" || action === \"signin\" || action === \"backchannel-logout\";\n if (!crossable && !isSameSite(request)) return bare(403);\n\n switch (action) {\n case \"signin\": {\n try {\n const started = await auth.begin({\n redirectUri,\n returnTo: sameOrigin(url.searchParams.get(\"returnTo\"), url.origin) ?? \"/\",\n organization: url.searchParams.get(\"organization\") ?? undefined,\n });\n return redirect(started.url, started.cookies);\n } catch (error) {\n // `organization` arrives from a query parameter and `begin` refuses one that is not an\n // alias, because a space in it injects scopes; and the IdP may be down. Without this\n // `catch` either is a 500 on a link.\n return problem(error);\n }\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie, redirectUri });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"signout\": {\n const returnTo = sameOrigin(url.searchParams.get(\"returnTo\"), url.origin);\n try {\n const ended = await auth.end(cookie, { returnTo });\n return redirect(ended.url, ended.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"session\": {\n let session: Session | null;\n try {\n session = await auth.read(cookie);\n if (session !== null) {\n session = withTenant(\n session,\n await bound.tenant(tenantRequest(request)),\n );\n }\n } catch (error) {\n return failure(error);\n }\n // 401 and no body at all. `bffAuth` reads this status as \"nobody is signed in\" and stops;\n // a body would be parsed by something eventually, and an error shape arriving where a\n // `Session` is expected is the failure `readSession` exists to refuse.\n if (session === null) return bare(401);\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n case \"refresh\": {\n // `POST` only. The other routes are reached by navigation, where the verb is the browser's\n // to choose; this one spends a refresh token, and a `GET` that spends something is a link,\n // a prefetch and a preview pane away from spending it.\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n try {\n const renewed = await auth.refresh(cookie);\n // An ended session is a 401 and the cookie cleared in the same answer, so the browser\n // stops presenting a ticket whose row is gone. An outage is a 5xx, which `bffAuth` does\n // not read as an ended session.\n if (renewed.ended) {\n return coded(renewed.code, \"there is no session left to renew\", 401, renewed.cookies);\n }\n const headers = new Headers(PRIVATE);\n for (const value of renewed.cookies) headers.append(\"set-cookie\", value);\n return new Response(JSON.stringify(renewed.session), { status: 200, headers });\n } catch (error) {\n return failure(error);\n }\n }\n\n case \"backchannel-logout\": {\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n // §2.5: `application/x-www-form-urlencoded`, one parameter.\n const token = new URLSearchParams(await request.text()).get(\"logout_token\");\n if (token === null || token === \"\") return coded(\"token/refused\", \"no `logout_token`\", 400);\n try {\n await auth.logout(token);\n return bare(200);\n } catch (error) {\n if (error instanceof AuthError && error.code === \"session/irrevocable\") {\n return coded(error.code, error.message, 501);\n }\n return failure(error);\n }\n }\n\n default:\n return new Response(null, { status: 404 });\n }\n };\n\n // One handler behind both verbs. Every route here is reached by navigation or by `fetch`, and\n // which verb a product uses for sign-out — a link or a form — is its choice, not ours to\n // constrain with a second table that could drift from this one. `refresh` and\n // `backchannel-logout` are the exceptions and check their own method.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","bare","status","outage","code","failure","error","AuthError","coded","message","routes","instance","handle","request","bound","addressedUrl","action","base","auth","redirectUri","problem","page","isSameSite","started","done","returnTo","ended","session","withTenant","tenantRequest","renewed","value","token"],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renew an access token with a minute left on it rather than after it dies.
|
|
3
|
+
*
|
|
4
|
+
* The window pays for two things at once: the flight time of the request we are about to send, and
|
|
5
|
+
* the clock skew between this server and the one that will validate the token. A minute covers
|
|
6
|
+
* both on every deployment anyone has run; going to zero means shipping tokens that expire in the
|
|
7
|
+
* air, and going large means renewing constantly on a realm with a five-minute token.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_RENEW_WITHIN = 60;
|
|
10
|
+
//# sourceMappingURL=renew-within.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew-within.d.ts","sourceRoot":"","sources":["../src/renew-within.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew-within.js","sources":["../src/renew-within.ts"],"sourcesContent":["/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nexport const DEFAULT_RENEW_WITHIN = 60;\n"],"names":["DEFAULT_RENEW_WITHIN"],"mappings":"AAQO,MAAMA,IAAuB;"}
|
package/dist/same-site.d.ts
CHANGED
|
@@ -1,29 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Did this request come from us?
|
|
3
|
-
*
|
|
4
|
-
* The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the
|
|
5
|
-
* cookie on the top-level navigation *back* from the identity provider, so the callback arrives
|
|
6
|
-
* without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that
|
|
7
|
-
* argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and
|
|
8
|
-
* what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**
|
|
9
|
-
* `<img src="https://app.example.test/api/auth/signout">` on any page anywhere signs the person
|
|
10
|
-
* out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.
|
|
11
|
-
*
|
|
12
|
-
* So the compensation belongs here rather than in every product: the package that chose `Lax` is
|
|
13
|
-
* the one that owes the check for what `Lax` lets through.
|
|
14
|
-
*
|
|
15
|
-
* ## Fetch Metadata first, `Origin` second, and then allow
|
|
16
|
-
*
|
|
17
|
-
* `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one
|
|
18
|
-
* header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it
|
|
19
|
-
* is absent the request came from something that is not a browser — curl, a server, a crawler — or
|
|
20
|
-
* from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to
|
|
21
|
-
* every unsafe request and which the same script cannot forge either.
|
|
22
|
-
*
|
|
23
|
-
* With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a
|
|
24
|
-
* request carrying no evidence of where it came from also carries no evidence that it came from a
|
|
25
|
-
* *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server
|
|
26
|
-
* call and every health probe, which is a real outage traded for no real attacker.
|
|
27
|
-
*/
|
|
28
1
|
export declare function isSameSite(request: Request): boolean;
|
|
29
2
|
//# sourceMappingURL=same-site.d.ts.map
|
package/dist/same-site.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"same-site.d.ts","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"same-site.d.ts","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"AAyCA,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAQpD"}
|
package/dist/same-site.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { addressedUrl as r } from "./next-bound.js";
|
|
1
2
|
const t = /* @__PURE__ */ new Set(["same-origin", "none"]);
|
|
2
|
-
function
|
|
3
|
+
function s(e) {
|
|
3
4
|
const n = e.headers.get("sec-fetch-site");
|
|
4
5
|
if (n !== null) return t.has(n);
|
|
5
6
|
const i = e.headers.get("origin");
|
|
6
|
-
return i !== null ? i ===
|
|
7
|
+
return i !== null ? i === r(e).origin : !0;
|
|
7
8
|
}
|
|
8
9
|
export {
|
|
9
|
-
|
|
10
|
+
s as isSameSite
|
|
10
11
|
};
|
|
11
12
|
//# sourceMappingURL=same-site.js.map
|
package/dist/same-site.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"same-site.js","sources":["../src/same-site.ts"],"sourcesContent":["/**\n * Did this request come from us?\n *\n * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the\n * cookie on the top-level navigation *back* from the identity provider, so the callback arrives\n * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that\n * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and\n * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**\n * `<img src=\"https://app.example.test/api/auth/signout\">` on any page anywhere signs the person\n * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.\n *\n * So the compensation belongs here rather than in every product: the package that chose `Lax` is\n * the one that owes the check for what `Lax` lets through.\n *\n * ## Fetch Metadata first, `Origin` second, and then allow\n *\n * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one\n * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it\n * is absent the request came from something that is not a browser — curl, a server, a crawler — or\n * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to\n * every unsafe request and which the same script cannot forge either.\n *\n * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a\n * request carrying no evidence of where it came from also carries no evidence that it came from a\n * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server\n * call and every health probe, which is a real outage traded for no real attacker.\n */\n\n/**\n * `same-origin` and `none` pass; `same-site` and `cross-site` do not.\n *\n * `none` is a person typing the URL or following a bookmark — user-initiated, which is the thing\n * being protected, not the thing being refused. `same-site` is a sibling subdomain, and it is\n * refused deliberately: the cookie is `__Host-` and therefore host-only, but host-only describes\n * where the cookie *lives*, not who may cause a request to it. Anything that can serve\n * `docs.example.test` could otherwise sign every reader of it out of `app.example.test`.\n */\nconst TRUSTED_SITES = new Set([\"same-origin\", \"none\"]);\n\nexport function isSameSite(request: Request): boolean {\n const site = request.headers.get(\"sec-fetch-site\");\n if (site !== null) return TRUSTED_SITES.has(site);\n\n const origin = request.headers.get(\"origin\");\n if (origin !== null) return origin ===
|
|
1
|
+
{"version":3,"file":"same-site.js","sources":["../src/same-site.ts"],"sourcesContent":["import { addressedUrl } from \"./next-bound\";\n\n/**\n * Did this request come from us?\n *\n * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the\n * cookie on the top-level navigation *back* from the identity provider, so the callback arrives\n * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that\n * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and\n * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**\n * `<img src=\"https://app.example.test/api/auth/signout\">` on any page anywhere signs the person\n * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.\n *\n * So the compensation belongs here rather than in every product: the package that chose `Lax` is\n * the one that owes the check for what `Lax` lets through.\n *\n * ## Fetch Metadata first, `Origin` second, and then allow\n *\n * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one\n * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it\n * is absent the request came from something that is not a browser — curl, a server, a crawler — or\n * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to\n * every unsafe request and which the same script cannot forge either.\n *\n * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a\n * request carrying no evidence of where it came from also carries no evidence that it came from a\n * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server\n * call and every health probe, which is a real outage traded for no real attacker.\n */\n\n/**\n * `same-origin` and `none` pass; `same-site` and `cross-site` do not.\n *\n * `none` is a person typing the URL or following a bookmark — user-initiated, which is the thing\n * being protected, not the thing being refused. `same-site` is a sibling subdomain, and it is\n * refused deliberately: the cookie is `__Host-` and therefore host-only, but host-only describes\n * where the cookie *lives*, not who may cause a request to it. Anything that can serve\n * `docs.example.test` could otherwise sign every reader of it out of `app.example.test`.\n */\nconst TRUSTED_SITES = new Set([\"same-origin\", \"none\"]);\n\nexport function isSameSite(request: Request): boolean {\n const site = request.headers.get(\"sec-fetch-site\");\n if (site !== null) return TRUSTED_SITES.has(site);\n\n const origin = request.headers.get(\"origin\");\n if (origin !== null) return origin === addressedUrl(request).origin;\n\n return true;\n}\n"],"names":["TRUSTED_SITES","isSameSite","request","site","origin","addressedUrl"],"mappings":";AAuCA,MAAMA,IAAgB,oBAAI,IAAI,CAAC,eAAe,MAAM,CAAC;AAE9C,SAASC,EAAWC,GAA2B;AACpD,QAAMC,IAAOD,EAAQ,QAAQ,IAAI,gBAAgB;AACjD,MAAIC,MAAS,KAAM,QAAOH,EAAc,IAAIG,CAAI;AAEhD,QAAMC,IAASF,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAIE,MAAW,OAAaA,MAAWC,EAAaH,CAAO,EAAE,SAEtD;AACT;"}
|
package/dist/server.d.ts
CHANGED
|
@@ -36,10 +36,30 @@ export interface SignedIn {
|
|
|
36
36
|
readonly cookies: readonly string[];
|
|
37
37
|
readonly returnTo: string;
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* What `token` answers for a live session: the credential one resource server takes, issued for
|
|
41
|
+
* it and for one organization, and what to set.
|
|
42
|
+
*/
|
|
40
43
|
export interface Token extends Renewed {
|
|
41
44
|
readonly accessToken: string;
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Who a token is for: one resource server, and the organization a call is made in. RFC 9700 §2.3
|
|
48
|
+
* restricts an access token to one resource server, and Keycloak's own advice for an exchange is
|
|
49
|
+
* *"ideally use a single audience"*.
|
|
50
|
+
*/
|
|
51
|
+
export interface Audience {
|
|
52
|
+
/**
|
|
53
|
+
* The API's client id in the realm: the `aud` it validates, and the client scope that puts it
|
|
54
|
+
* there (`services/auth/modules/api`), which the application lists in its `apis`.
|
|
55
|
+
*/
|
|
56
|
+
readonly audience: string;
|
|
57
|
+
/**
|
|
58
|
+
* The organization the call is in: `organization:<alias>`, so the token names that organization
|
|
59
|
+
* alone. Absent for an API no organization owns, and the token then names none.
|
|
60
|
+
*/
|
|
61
|
+
readonly organization?: string;
|
|
62
|
+
}
|
|
43
63
|
export interface RelyingPartyConfig extends IssuerConfig {
|
|
44
64
|
/** Seals the cookies. Any length; generate it. See `sealedCookie`. */
|
|
45
65
|
readonly secret: string | Uint8Array;
|
|
@@ -77,18 +97,34 @@ export interface RelyingParty {
|
|
|
77
97
|
/** The session a request carries, or `null`. One store read; nothing is renewed. */
|
|
78
98
|
read(cookie: string | null | undefined): Promise<Session | null>;
|
|
79
99
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
100
|
+
* A token for one resource server and one organization — or {@link Ended} when there is no live
|
|
101
|
+
* session.
|
|
82
102
|
*
|
|
83
103
|
* This is the *token-mediating backend*: the browser holds a cookie, the resource server is
|
|
84
|
-
* given a bearer token, and the two never meet.
|
|
85
|
-
*
|
|
104
|
+
* given a bearer token, and the two never meet. The session's own token never leaves this
|
|
105
|
+
* server: it names every organization the person belongs to and no API, and it is exchanged
|
|
106
|
+
* (OAuth 2.0 Token Exchange, RFC 8693, as Keycloak's standard token exchange implements it) for
|
|
107
|
+
* one whose `aud` is `audience` alone and whose organization is `organization` alone. The
|
|
108
|
+
* session is renewed first when it is within `renewWithin` seconds of expiry (default 60).
|
|
109
|
+
*
|
|
110
|
+
* Exchanged tokens are kept until they are within the same window of expiry, one per session,
|
|
111
|
+
* audience and organization, and an exchange is single-flight under that key: a page that fires
|
|
112
|
+
* eight requests costs the realm one exchange, not eight.
|
|
113
|
+
*
|
|
114
|
+
* Rejects with `organization/denied` when the realm grants the token without the organization —
|
|
115
|
+
* the person is not a member of it — and with `organization/invalid` for one that is not an alias.
|
|
86
116
|
*/
|
|
87
|
-
token(cookie: string | null | undefined, options
|
|
117
|
+
token(cookie: string | null | undefined, options: Audience & {
|
|
88
118
|
readonly renewWithin?: number;
|
|
89
119
|
}): Promise<Token | Ended>;
|
|
90
|
-
/**
|
|
91
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Renew the session: spend the refresh token now or, with `renewWithin`, only when its access
|
|
122
|
+
* token is within that many seconds of expiry. Single-flight per ticket, so a page that fires
|
|
123
|
+
* eight requests at an expiring session spends the refresh token once.
|
|
124
|
+
*/
|
|
125
|
+
refresh(cookie: string | null | undefined, options?: {
|
|
126
|
+
readonly renewWithin?: number;
|
|
127
|
+
}): Promise<Renewed | Ended>;
|
|
92
128
|
/** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */
|
|
93
129
|
end(cookie: string | null | undefined, options?: {
|
|
94
130
|
readonly returnTo?: string;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AAErD,OAAO,EAAsC,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AAChF,OAAO,EAAiC,KAAK,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAqN1F,4FAA4F;AAC5F,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED;;;GAGG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC;IACrB,uGAAuG;IACvG,QAAQ,CAAC,IAAI,EAAE,gBAAgB,GAAG,eAAe,CAAC;IAClD,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,6FAA6F;AAC7F,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,KAAM,SAAQ,OAAO;IACpC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAqBD,MAAM,WAAW,kBAAmB,SAAQ,YAAY;IACtD,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,iGAAiG;IACjG,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,gGAAgG;IAChG,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,KAAK,CAAC,OAAO,EAAE,aAAa,GAAG;QAAE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpF;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE;QAChB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;QAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;KAC9B,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACtB,oFAAoF;IACpF,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACjE;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,CACH,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,OAAO,EAAE,QAAQ,GAAG;QAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC;IAC1B;;;;OAIG;IACH,OAAO,CACL,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAC1C,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC,CAAC;IAC5B,gGAAgG;IAChG,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpG;;;;;;OAMG;IACH,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAkBD,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CA+arE;AAED,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACjF,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACzG,OAAO,EACL,cAAc,EACd,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,iBAAiB,GACvB,MAAM,SAAS,CAAC"}
|