@kanzo-tech/auth 0.29.1 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +36 -23
  2. package/dist/bff-auth.d.ts +1 -2
  3. package/dist/bff-auth.d.ts.map +1 -1
  4. package/dist/bff-auth.js +82 -77
  5. package/dist/bff-auth.js.map +1 -1
  6. package/dist/can.d.ts +13 -9
  7. package/dist/can.d.ts.map +1 -1
  8. package/dist/can.js +6 -6
  9. package/dist/can.js.map +1 -1
  10. package/dist/gate.d.ts +4 -4
  11. package/dist/gate.d.ts.map +1 -1
  12. package/dist/gate.js +8 -9
  13. package/dist/gate.js.map +1 -1
  14. package/dist/index.d.ts +11 -19
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +17 -23
  17. package/dist/index.js.map +1 -1
  18. package/dist/issuer.d.ts +16 -0
  19. package/dist/issuer.d.ts.map +1 -1
  20. package/dist/issuer.js +43 -25
  21. package/dist/issuer.js.map +1 -1
  22. package/dist/next-auth.d.ts +66 -0
  23. package/dist/next-auth.d.ts.map +1 -0
  24. package/dist/next-auth.js +43 -0
  25. package/dist/next-auth.js.map +1 -0
  26. package/dist/next-bound.d.ts +89 -0
  27. package/dist/next-bound.d.ts.map +1 -0
  28. package/dist/next-bound.js +48 -0
  29. package/dist/next-bound.js.map +1 -0
  30. package/dist/next-gate.d.ts +4 -0
  31. package/dist/next-gate.d.ts.map +1 -0
  32. package/dist/next-gate.js +74 -0
  33. package/dist/next-gate.js.map +1 -0
  34. package/dist/next-proxy.d.ts +15 -24
  35. package/dist/next-proxy.d.ts.map +1 -1
  36. package/dist/next-proxy.js +47 -39
  37. package/dist/next-proxy.js.map +1 -1
  38. package/dist/next-routes.d.ts +4 -21
  39. package/dist/next-routes.d.ts.map +1 -1
  40. package/dist/next-routes.js +83 -64
  41. package/dist/next-routes.js.map +1 -1
  42. package/dist/next.d.ts +16 -41
  43. package/dist/next.d.ts.map +1 -1
  44. package/dist/next.js +2 -10
  45. package/dist/next.js.map +1 -1
  46. package/dist/server.d.ts +60 -38
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +183 -159
  49. package/dist/server.js.map +1 -1
  50. package/dist/store.d.ts +102 -20
  51. package/dist/store.d.ts.map +1 -1
  52. package/dist/store.js +56 -21
  53. package/dist/store.js.map +1 -1
  54. package/dist/types.d.ts +40 -23
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/use-session.d.ts +6 -1
  58. package/dist/use-session.d.ts.map +1 -1
  59. package/dist/use-session.js +9 -7
  60. package/dist/use-session.js.map +1 -1
  61. package/package.json +11 -30
  62. package/dist/auth-fetch.d.ts +0 -44
  63. package/dist/auth-fetch.d.ts.map +0 -1
  64. package/dist/auth-fetch.js +0 -21
  65. package/dist/auth-fetch.js.map +0 -1
  66. package/dist/browser.d.ts +0 -61
  67. package/dist/browser.d.ts.map +0 -1
  68. package/dist/browser.js +0 -130
  69. package/dist/browser.js.map +0 -1
  70. package/dist/host.d.ts +0 -22
  71. package/dist/host.d.ts.map +0 -1
  72. package/dist/host.js +0 -10
  73. package/dist/host.js.map +0 -1
  74. package/dist/next-middleware.d.ts +0 -23
  75. package/dist/next-middleware.d.ts.map +0 -1
  76. package/dist/next-middleware.js +0 -23
  77. package/dist/next-middleware.js.map +0 -1
  78. package/dist/next-session.d.ts +0 -44
  79. package/dist/next-session.d.ts.map +0 -1
  80. package/dist/next-session.js +0 -11
  81. package/dist/next-session.js.map +0 -1
  82. package/dist/next-token.d.ts +0 -56
  83. package/dist/next-token.d.ts.map +0 -1
  84. package/dist/next-token.js +0 -9
  85. package/dist/next-token.js.map +0 -1
  86. package/dist/use-organization.d.ts +0 -22
  87. package/dist/use-organization.d.ts.map +0 -1
  88. package/dist/use-organization.js +0 -20
  89. package/dist/use-organization.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next-gate.js","sources":["../src/next-gate.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\nimport { URL_HEADER, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport type { Ended, Token } from \"./server\";\nimport { AuthError } from \"./types\";\n\n/**\n * The proxy as the session authority: WorkOS AuthKit's `authkitProxy`, which renews and enforces\n * where the URL is known, over a Duende-style session that is updated in place.\n *\n * Next 16 runs `proxy.ts` on Node and only on Node, so the edge's reason for checking a cookie's\n * mere presence is gone. This reads the record — one store read per request — and acts on it\n * before any page renders:\n *\n * - **A live session within a minute of expiry is renewed.** Under a ticket store the tokens are\n * rewritten under the same ticket and the cookie does not change. Under the stateless store the\n * re-sealed cookie is set with `response.cookies.set`, which Next also merges into this same\n * request's `cookies()`, so the page renders with the session the browser will hold.\n * - **No live session** — none, forgotten by the store, or refused by the IdP, which is a realm\n * whose SSO session went idle — **clears the cookie and asks for a sign-in.** A navigation is\n * sent to `signin?returnTo=<path+search>`; anything else answers a bare 401, because a prefetch,\n * a server action or an RSC fetch cannot follow a sign-in and must not start one. The Next\n * router answers a 401 to its own fetch with a hard navigation, which comes back through here as\n * a navigation.\n * - **A store that does not answer** sends a navigation to the problem page with its code; **an\n * IdP that does not answer** is passed through untouched, because the session it could not renew\n * is still the person's, and a page that renders is better than a sign-in that cannot finish.\n *\n * ## Navigation or not, by Fetch Metadata\n *\n * `Sec-Fetch-Mode` (W3C Fetch Metadata) is set by the browser and cannot be set by script:\n * `navigate` is a document load, `cors`/`no-cors`/`same-origin` is everything else. Next strips its\n * own RSC headers before the proxy sees them, so this is the one signal that survives. An absent\n * header is a client that is not a browser, or one too old to send it, and is treated as a\n * navigation — a redirect it can follow is the more useful answer.\n *\n * ## The URL, forwarded\n *\n * Every request leaves with `x-kanzo-url` set to the URL it arrived at — AuthKit's `x-url` — and\n * with every incoming `x-kanzo-*` header deleted first, so a browser cannot choose what a server\n * component reads there. `session({ required: true })` signs in back to it, and the tenant\n * resolver reads the same URL the routes do.\n */\n\n/** A path whose last segment carries a dot: a static file, not a page. */\nfunction isFile(pathname: string): boolean {\n return pathname.slice(pathname.lastIndexOf(\"/\") + 1).includes(\".\");\n}\n\n/** On a segment boundary, so `/health` opens `/health/live` and not `/healthcare`. */\nfunction under(pathname: string, prefixes: readonly string[]): boolean {\n return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));\n}\n\n/**\n * A `Set-Cookie` value as the attributes `response.cookies.set` takes, which is the call that makes\n * Next merge the cookie into this request's `cookies()`. A raw `Set-Cookie` header reaches the\n * browser and not the page being rendered.\n */\nfunction asCookie(header: string) {\n const [pair = \"\", ...attributes] = header.split(\";\").map((part) => part.trim());\n const eq = pair.indexOf(\"=\");\n const cookie: {\n name: string;\n value: string;\n path?: string;\n httpOnly?: boolean;\n secure?: boolean;\n sameSite?: \"lax\" | \"strict\" | \"none\";\n maxAge?: number;\n } = { name: pair.slice(0, eq), value: pair.slice(eq + 1) };\n for (const attribute of attributes) {\n const [key = \"\", value = \"\"] = attribute.split(\"=\");\n switch (key.toLowerCase()) {\n case \"path\":\n cookie.path = value;\n break;\n case \"httponly\":\n cookie.httpOnly = true;\n break;\n case \"secure\":\n cookie.secure = true;\n break;\n case \"samesite\":\n cookie.sameSite = value.toLowerCase() as \"lax\" | \"strict\" | \"none\";\n break;\n case \"max-age\":\n cookie.maxAge = Number(value);\n break;\n }\n }\n return cookie;\n}\n\nexport async function gate(\n bound: Bound,\n request: NextRequest,\n extra?: HeadersInit,\n): Promise<NextResponse> {\n const { pathname, search } = request.nextUrl;\n\n const forwarded = new Headers(request.headers);\n for (const name of [...forwarded.keys()]) {\n if (name.startsWith(\"x-kanzo-\")) forwarded.delete(name);\n }\n forwarded.set(URL_HEADER, request.url);\n new Headers(extra).forEach((value, name) => forwarded.set(name, value));\n const through = () => NextResponse.next({ request: { headers: forwarded } });\n\n if (isFile(pathname) || under(pathname, bound.open)) return through();\n\n const mode = request.headers.get(\"sec-fetch-mode\");\n const navigation = mode === null || mode === \"navigate\";\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 if (!(error instanceof AuthError)) throw error;\n if (error.code === \"idp/unreachable\" || error.code === \"idp/silent\") return through();\n if (!navigation) {\n return new NextResponse(null, {\n status: outage(error.code) ?? 500,\n headers: { \"cache-control\": \"no-store\" },\n });\n }\n const problem = new URL(bound.problemPage, request.url);\n problem.searchParams.set(\"code\", error.code);\n return NextResponse.redirect(problem, 307);\n }\n\n if (held.ended) {\n let answer: NextResponse;\n if (navigation) {\n const signIn = new URL(`${bound.basePath}/signin`, request.url);\n // `routes` confines `returnTo` to this origin before sealing it; this relies on that check\n // rather than repeating it.\n signIn.searchParams.set(\"returnTo\", `${pathname}${search}`);\n answer = NextResponse.redirect(signIn, 307);\n } else {\n answer = new NextResponse(null, { status: 401, headers: { \"cache-control\": \"no-store\" } });\n }\n for (const cookie of held.cookies) answer.headers.append(\"set-cookie\", cookie);\n return answer;\n }\n\n const response = through();\n for (const cookie of held.cookies) response.cookies.set(asCookie(cookie));\n return response;\n}\n"],"names":["isFile","pathname","under","prefixes","prefix","asCookie","header","pair","attributes","part","eq","cookie","attribute","key","value","gate","bound","request","extra","search","forwarded","name","URL_HEADER","through","NextResponse","mode","navigation","held","error","AuthError","outage","problem","answer","signIn","response"],"mappings":"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"}
@@ -1,15 +1,12 @@
1
- import { AuthSessionConfig } from './next-session';
1
+ import { Bound } from './next-bound';
2
2
  /**
3
3
  * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a
4
- * bearer token, and the cookie that got it here stops at this line.
4
+ * bearer token, and the cookie that got it here stops at this line. `kanzoAuth().api`.
5
5
  *
6
6
  * ```ts
7
+ * // lib/auth.ts: kanzoAuth(() => ({ …, api: { mount: "/api/data", target: "https://reports.internal/v1" } }))
7
8
  * // app/api/data/[...path]/route.ts
8
- * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = authProxy({
9
- * issuer, clientId, clientSecret, secret,
10
- * basePath: "/api/data",
11
- * target: "https://reports.internal/v1",
12
- * });
9
+ * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;
13
10
  * ```
14
11
  *
15
12
  * ## Why this is in the package and not in the product
@@ -25,25 +22,19 @@ import { AuthSessionConfig } from './next-session';
25
22
  *
26
23
  * ## What it answers without asking upstream
27
24
  *
28
- * - **401** when there is no session, or when the renewal was refused. There is nothing to
29
- * forward: a request with no credential is not the resource server's to refuse.
25
+ * - **401** when there is no session, or when the renewal was refused — with the cookie cleared in
26
+ * the same answer. There is nothing to forward: a request with no credential is not the resource
27
+ * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is
28
+ * refused too.
30
29
  * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.
30
+ * - **404** when `kanzoAuth` was given no `api`.
31
31
  *
32
- * ## Renewal happens here, because here is somewhere a cookie can be set
32
+ * ## Renewal happens here too
33
33
  *
34
- * `authToken` renews when the access token is within a minute of expiry and hands back the
35
- * `Set-Cookie` that carries the rotated session; this attaches it to the proxied response. That is
36
- * the whole of the answer to *"the token lives an hour and the cookie lives eight"* — the seven
37
- * hours in between stop being seven hours of 401s that nothing recovers from.
34
+ * The proxy renews before a page renders, and a client that stays on one page for longer than an
35
+ * access token lives renews here: within a minute of expiry, in place, single-flight per ticket
36
+ * with every other renewal of that session in the process.
38
37
  */
39
- export interface AuthProxyConfig extends AuthSessionConfig {
40
- /** Where the upstream lives. A path here is a prefix: `https://reports.internal/v1`. */
41
- readonly target: string;
42
- /** Where this route file is mounted. Stripped from the path before the rest is appended to `target`. */
43
- readonly basePath: string;
44
- /** Seconds of remaining lifetime below which the access token is renewed. Default 60. */
45
- readonly renewWithin?: number;
46
- }
47
38
  /**
48
39
  * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`
49
40
  * one, which is a distinction worth a sentence because it is one field away from being invisible.
@@ -52,7 +43,7 @@ export interface AuthProxyConfig extends AuthSessionConfig {
52
43
  * traffic going the same way would have every right to be surprised, so there is one name for one
53
44
  * transport and the other one is the platform's.
54
45
  */
55
- export interface AuthProxyHandlers {
46
+ export interface ApiHandlers {
56
47
  GET(request: Request): Promise<Response>;
57
48
  POST(request: Request): Promise<Response>;
58
49
  PUT(request: Request): Promise<Response>;
@@ -61,5 +52,5 @@ export interface AuthProxyHandlers {
61
52
  HEAD(request: Request): Promise<Response>;
62
53
  OPTIONS(request: Request): Promise<Response>;
63
54
  }
64
- export declare function authProxy(config: AuthProxyConfig): AuthProxyHandlers;
55
+ export declare function forward(instance: () => Promise<Bound>): ApiHandlers;
65
56
  //# sourceMappingURL=next-proxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAKxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,MAAM,WAAW,eAAgB,SAAQ,iBAAiB;IACxD,wFAAwF;IACxF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wGAAwG;IACxG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;GAOG;AAEH,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1C,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC3C,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1C,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9C;AAmDD,wBAAgB,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,iBAAiB,CA2EpE"}
1
+ {"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAMzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;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;AAmDD,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,CAiFnE"}
@@ -1,8 +1,8 @@
1
- import { authToken as k } from "./next-token.js";
2
- import { isSameSite as g } from "./same-site.js";
3
- import { outage as y } from "./next-routes.js";
1
+ import { tenantRequest as k } from "./next-bound.js";
2
+ import { outage as E } from "./next-routes.js";
3
+ import { isSameSite as y } from "./same-site.js";
4
4
  import { AuthError as R } from "./types.js";
5
- const w = [
5
+ const T = [
6
6
  "connection",
7
7
  "keep-alive",
8
8
  "proxy-authenticate",
@@ -11,55 +11,63 @@ const w = [
11
11
  "trailer",
12
12
  "transfer-encoding",
13
13
  "upgrade"
14
- ], x = [...w, "cookie", "authorization", "host", "content-length", "accept-encoding"], O = [...w, "set-cookie", "content-encoding", "content-length"];
15
- function T(o, i) {
16
- const r = new Headers(o);
17
- for (const a of i) r.delete(a);
18
- return r;
14
+ ], H = [...T, "cookie", "authorization", "host", "content-length", "accept-encoding"], O = [...T, "set-cookie", "content-encoding", "content-length"];
15
+ function w(h, d) {
16
+ const t = new Headers(h);
17
+ for (const o of d) t.delete(o);
18
+ return t;
19
19
  }
20
- function A(o) {
21
- const i = k(o), r = (...e) => globalThis.fetch(...e), a = o.basePath.replace(/\/$/, ""), u = new URL(o.target), E = u.pathname.replace(/\/$/, ""), s = (e) => new Response(null, { status: e, headers: { "cache-control": "no-store" } }), t = async (e) => {
22
- if (!g(e)) return s(403);
23
- const d = new URL(e.url);
24
- if (!d.pathname.startsWith(a)) return s(400);
25
- const l = new URL(u.href);
26
- l.pathname = `${E}${d.pathname.slice(a.length)}`, l.search = d.search;
27
- let c;
20
+ function A(h) {
21
+ const d = (...e) => globalThis.fetch(...e), t = (e, n = []) => {
22
+ const r = new Headers({ "cache-control": "no-store" });
23
+ for (const a of n) r.append("set-cookie", a);
24
+ return new Response(null, { status: e, headers: r });
25
+ }, o = async (e) => {
26
+ const n = await h();
27
+ if (n.api === void 0) return t(404);
28
+ if (!y(e)) return t(403);
29
+ const r = n.api.mount, a = n.api.target(await n.tenant(k(e)));
30
+ if (a === void 0) return t(404);
31
+ const g = a.pathname.replace(/\/$/, ""), p = new URL(e.url);
32
+ if (!p.pathname.startsWith(r)) return t(400);
33
+ const u = new URL(a.href);
34
+ u.pathname = `${g}${p.pathname.slice(r.length)}`, u.search = p.search;
35
+ let s;
28
36
  try {
29
- c = await i(e.headers.get("cookie"), { renewWithin: o.renewWithin });
30
- } catch (n) {
31
- if (!(n instanceof R)) throw n;
32
- return s(y(n.code) ?? 401);
37
+ s = await n.party.token(e.headers.get("cookie"), { renewWithin: n.renewWithin });
38
+ } catch (c) {
39
+ if (!(c instanceof R)) throw c;
40
+ return t(E(c.code) ?? 401);
33
41
  }
34
- if (c === null) return s(401);
35
- const p = T(e.headers, x);
36
- p.set("authorization", `Bearer ${c.accessToken}`);
37
- const m = e.method === "GET" || e.method === "HEAD" ? null : e.body, h = await r(l, {
42
+ if (s.ended) return t(401, s.cookies);
43
+ const f = w(e.headers, H);
44
+ f.set("authorization", `Bearer ${s.accessToken}`);
45
+ const l = e.method === "GET" || e.method === "HEAD" ? null : e.body, i = await d(u, {
38
46
  method: e.method,
39
- headers: p,
40
- body: m,
47
+ headers: f,
48
+ body: l,
41
49
  // The body is a stream and is forwarded as one: an upload is not read into this server's
42
50
  // memory on its way past, and a server-sent event stream is not buffered until it ends —
43
51
  // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.
44
- ...m === null ? {} : { duplex: "half" },
52
+ ...l === null ? {} : { duplex: "half" },
45
53
  // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it
46
54
  // here would send the bearer token to whatever host the `Location` names.
47
55
  redirect: "manual"
48
- }), f = T(h.headers, O);
49
- for (const n of c.cookies) f.append("set-cookie", n);
50
- return new Response(h.body, { status: h.status, statusText: h.statusText, headers: f });
56
+ }), m = w(i.headers, O);
57
+ for (const c of s.cookies) m.append("set-cookie", c);
58
+ return new Response(i.body, { status: i.status, statusText: i.statusText, headers: m });
51
59
  };
52
60
  return {
53
- GET: t,
54
- POST: t,
55
- PUT: t,
56
- PATCH: t,
57
- DELETE: t,
58
- HEAD: t,
59
- OPTIONS: t
61
+ GET: o,
62
+ POST: o,
63
+ PUT: o,
64
+ PATCH: o,
65
+ DELETE: o,
66
+ HEAD: o,
67
+ OPTIONS: o
60
68
  };
61
69
  }
62
70
  export {
63
- A as authProxy
71
+ A as forward
64
72
  };
65
73
  //# sourceMappingURL=next-proxy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import { authToken } from \"./next-token\";\nimport type { AuthSessionConfig } from \"./next-session\";\nimport { isSameSite } from \"./same-site\";\nimport { outage } from \"./next-routes\";\nimport { AuthError } from \"./types\";\n\n/**\n * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a\n * bearer token, and the cookie that got it here stops at this line.\n *\n * ```ts\n * // app/api/data/[...path]/route.ts\n * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = authProxy({\n * issuer, clientId, clientSecret, secret,\n * basePath: \"/api/data\",\n * target: \"https://reports.internal/v1\",\n * });\n * ```\n *\n * ## Why this is in the package and not in the product\n *\n * Because it is the same ninety lines every time, and one of them is load-bearing in a way that\n * does not look it. **The `cookie` header must not be forwarded.** Leave it on and the resource\n * server receives a second credential — the session cookie — alongside the bearer token it asked\n * for, which is precisely the confusion the BFF pattern exists to remove: from then on a bug at\n * the far end can act as the person rather than as the token, and the token's scope and lifetime\n * stop being the boundary. Every other line here — the hop-by-hop headers, `duplex: \"half\"`,\n * `redirect: \"manual\"`, not buffering the body — is the sort of thing that is either right or\n * produces a symptom three layers away, and none of it is a product's idea of its own domain.\n *\n * ## What it answers without asking upstream\n *\n * - **401** when there is no session, or when the renewal was refused. There is nothing to\n * forward: a request with no credential is not the resource server's to refuse.\n * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.\n *\n * ## Renewal happens here, because here is somewhere a cookie can be set\n *\n * `authToken` renews when the access token is within a minute of expiry and hands back the\n * `Set-Cookie` that carries the rotated session; this attaches it to the proxied response. That is\n * the whole of the answer to *\"the token lives an hour and the cookie lives eight\"* — the seven\n * hours in between stop being seven hours of 401s that nothing recovers from.\n */\n\nexport interface AuthProxyConfig extends AuthSessionConfig {\n /** Where the upstream lives. A path here is a prefix: `https://reports.internal/v1`. */\n readonly target: string;\n /** Where this route file is mounted. Stripped from the path before the rest is appended to `target`. */\n readonly basePath: string;\n /** Seconds of remaining lifetime below which the access token is renewed. Default 60. */\n readonly renewWithin?: number;\n}\n\n/**\n * The upstream is reached with the global `fetch`, and **not** with the inherited `IssuerConfig`\n * one, which is a distinction worth a sentence because it is one field away from being invisible.\n * That `fetch` exists to reach the *identity provider* — it is the seam `internalOrigin` uses to\n * come at Keycloak from inside a cluster. A deployment that set it and found its resource-server\n * traffic going the same way would have every right to be surprised, so there is one name for one\n * transport and the other one is the platform's.\n */\n\nexport interface AuthProxyHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n PUT(request: Request): Promise<Response>;\n PATCH(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n OPTIONS(request: Request): Promise<Response>;\n}\n\n/**\n * Headers that describe **this** connection and not the message, per RFC 9110 §7.6.1.\n *\n * Forwarding them is how a proxy promises an upstream a connection it does not have. `te` and\n * `trailer` are here for the same reason the others are, and `upgrade` matters most: a forwarded\n * `Upgrade: websocket` invites an answer this handler has no way to complete.\n */\nconst HOP_BY_HOP = [\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n];\n\n/**\n * What is stripped from the browser's request on the way out, beyond the hop-by-hop set.\n *\n * - `cookie` — the whole point, above.\n * - `authorization` — ours is the only one that may be on this request; a caller's own would\n * otherwise decide which of the two the upstream reads.\n * - `host` — it names this server, not the upstream's, and `fetch` sets the right one.\n * - `content-length` — the body is re-streamed, and a length that survived a re-encode would be a\n * lie the transport has to discover.\n * - `accept-encoding` — the client's compression negotiation is with *us*; `fetch` runs its own\n * with the upstream and hands back a decoded body. Passing this on is how a response comes back\n * labelled `gzip` and already decompressed, which no browser recovers from.\n */\nconst NOT_FORWARDED = [...HOP_BY_HOP, \"cookie\", \"authorization\", \"host\", \"content-length\", \"accept-encoding\"];\n\n/**\n * What is stripped from the upstream's answer.\n *\n * `set-cookie` is the one worth the sentence: under this pattern the browser's cookie relationship\n * is with the BFF alone, and a resource server that could set a cookie on this origin could set\n * one named like ours. `content-encoding` and `content-length` go because `fetch` already decoded\n * the body, so both now describe a representation that no longer exists.\n */\nconst NOT_RETURNED = [...HOP_BY_HOP, \"set-cookie\", \"content-encoding\", \"content-length\"];\n\nfunction copyHeaders(from: Headers, without: readonly string[]): Headers {\n const headers = new Headers(from);\n for (const name of without) headers.delete(name);\n return headers;\n}\n\nexport function authProxy(config: AuthProxyConfig): AuthProxyHandlers {\n const token = authToken(config);\n const send = (...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args);\n const base = config.basePath.replace(/\\/$/, \"\");\n const target = new URL(config.target);\n /** `https://api.test` has pathname `/`, and a prefix of `/` would double every separator. */\n const prefix = target.pathname.replace(/\\/$/, \"\");\n\n const refuse = (status: number) =>\n new Response(null, { status, headers: { \"cache-control\": \"no-store\" } });\n\n const handle = async (request: Request): Promise<Response> => {\n if (!isSameSite(request)) return refuse(403);\n\n const url = new URL(request.url);\n\n // A path outside the mount is not one this handler was mounted for, and refusing it *is* the\n // traversal check: the URL parser has already resolved every dot segment — `..` and its\n // percent-encoded spellings alike, which is the parser's job and not a thing to re-implement\n // — so a path that tried to climb has already fallen out of the prefix by the time it is read.\n if (!url.pathname.startsWith(base)) return refuse(400);\n\n // Assigning `pathname` rather than composing a string: a path beginning `//` parsed as a *URL*\n // is protocol-relative and names another host, and `//evil.test/x` is a path a browser will\n // happily send. Set as a component it cannot reach the origin at all.\n const upstream = new URL(target.href);\n upstream.pathname = `${prefix}${url.pathname.slice(base.length)}`;\n upstream.search = url.search;\n\n let held: Awaited<ReturnType<typeof token>>;\n try {\n held = await token(request.headers.get(\"cookie\"), { renewWithin: config.renewWithin });\n } catch (error) {\n // A refused renewal is the end of the session and not an upstream failure. An IdP or a store\n // that did not answer is an outage, and anything else is a fault this module has no reading\n // of: hiding either behind a 401 would send a person to sign in again over something that\n // will still be there when they get back.\n if (!(error instanceof AuthError)) throw error;\n return refuse(outage(error.code) ?? 401);\n }\n if (held === null) return refuse(401);\n\n const headers = copyHeaders(request.headers, NOT_FORWARDED);\n headers.set(\"authorization\", `Bearer ${held.accessToken}`);\n\n const body = request.method === \"GET\" || request.method === \"HEAD\" ? null : request.body;\n const answer = await send(upstream, {\n method: request.method,\n headers,\n body,\n // The body is a stream and is forwarded as one: an upload is not read into this server's\n // memory on its way past, and a server-sent event stream is not buffered until it ends —\n // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.\n ...(body === null ? {} : { duplex: \"half\" }),\n // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it\n // here would send the bearer token to whatever host the `Location` names.\n redirect: \"manual\",\n } as RequestInit);\n\n const out = copyHeaders(answer.headers, NOT_RETURNED);\n // Ours last, so a renewal is never lost to an upstream that had opinions about cookies.\n for (const cookie of held.cookies) out.append(\"set-cookie\", cookie);\n\n return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });\n };\n\n return {\n GET: handle,\n POST: handle,\n PUT: handle,\n PATCH: handle,\n DELETE: handle,\n HEAD: handle,\n OPTIONS: handle,\n };\n}\n"],"names":["HOP_BY_HOP","NOT_FORWARDED","NOT_RETURNED","copyHeaders","from","without","headers","name","authProxy","config","token","authToken","send","args","base","target","prefix","refuse","status","handle","request","isSameSite","url","upstream","held","error","AuthError","outage","body","answer","out","cookie"],"mappings":";;;;AA+EA,MAAMA,IAAa;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAeMC,IAAgB,CAAC,GAAGD,GAAY,UAAU,iBAAiB,QAAQ,kBAAkB,iBAAiB,GAUtGE,IAAe,CAAC,GAAGF,GAAY,cAAc,oBAAoB,gBAAgB;AAEvF,SAASG,EAAYC,GAAeC,GAAqC;AACvE,QAAMC,IAAU,IAAI,QAAQF,CAAI;AAChC,aAAWG,KAAQF,EAAS,CAAAC,EAAQ,OAAOC,CAAI;AAC/C,SAAOD;AACT;AAEO,SAASE,EAAUC,GAA4C;AACpE,QAAMC,IAAQC,EAAUF,CAAM,GACxBG,IAAO,IAAIC,MAA8C,WAAW,MAAM,GAAGA,CAAI,GACjFC,IAAOL,EAAO,SAAS,QAAQ,OAAO,EAAE,GACxCM,IAAS,IAAI,IAAIN,EAAO,MAAM,GAE9BO,IAASD,EAAO,SAAS,QAAQ,OAAO,EAAE,GAE1CE,IAAS,CAACC,MACd,IAAI,SAAS,MAAM,EAAE,QAAAA,GAAQ,SAAS,EAAE,iBAAiB,WAAA,GAAc,GAEnEC,IAAS,OAAOC,MAAwC;AAC5D,QAAI,CAACC,EAAWD,CAAO,EAAG,QAAOH,EAAO,GAAG;AAE3C,UAAMK,IAAM,IAAI,IAAIF,EAAQ,GAAG;AAM/B,QAAI,CAACE,EAAI,SAAS,WAAWR,CAAI,EAAG,QAAOG,EAAO,GAAG;AAKrD,UAAMM,IAAW,IAAI,IAAIR,EAAO,IAAI;AACpC,IAAAQ,EAAS,WAAW,GAAGP,CAAM,GAAGM,EAAI,SAAS,MAAMR,EAAK,MAAM,CAAC,IAC/DS,EAAS,SAASD,EAAI;AAEtB,QAAIE;AACJ,QAAI;AACF,MAAAA,IAAO,MAAMd,EAAMU,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaX,EAAO,YAAA,CAAa;AAAA,IACvF,SAASgB,GAAO;AAKd,UAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,aAAOR,EAAOU,EAAOF,EAAM,IAAI,KAAK,GAAG;AAAA,IACzC;AACA,QAAID,MAAS,KAAM,QAAOP,EAAO,GAAG;AAEpC,UAAMX,IAAUH,EAAYiB,EAAQ,SAASnB,CAAa;AAC1D,IAAAK,EAAQ,IAAI,iBAAiB,UAAUkB,EAAK,WAAW,EAAE;AAEzD,UAAMI,IAAOR,EAAQ,WAAW,SAASA,EAAQ,WAAW,SAAS,OAAOA,EAAQ,MAC9ES,IAAS,MAAMjB,EAAKW,GAAU;AAAA,MAClC,QAAQH,EAAQ;AAAA,MAChB,SAAAd;AAAA,MACA,MAAAsB;AAAA;AAAA;AAAA;AAAA,MAIA,GAAIA,MAAS,OAAO,CAAA,IAAK,EAAE,QAAQ,OAAA;AAAA;AAAA;AAAA,MAGnC,UAAU;AAAA,IAAA,CACI,GAEVE,IAAM3B,EAAY0B,EAAO,SAAS3B,CAAY;AAEpD,eAAW6B,KAAUP,EAAK,QAAS,CAAAM,EAAI,OAAO,cAAcC,CAAM;AAElE,WAAO,IAAI,SAASF,EAAO,MAAM,EAAE,QAAQA,EAAO,QAAQ,YAAYA,EAAO,YAAY,SAASC,GAAK;AAAA,EACzG;AAEA,SAAO;AAAA,IACL,KAAKX;AAAA,IACL,MAAMA;AAAA,IACN,KAAKA;AAAA,IACL,OAAOA;AAAA,IACP,QAAQA;AAAA,IACR,MAAMA;AAAA,IACN,SAASA;AAAA,EAAA;AAEb;"}
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,28 +1,11 @@
1
- import { AuthSessionConfig } from './next-session';
1
+ import { Bound } from './next-bound';
2
2
  import { AuthErrorCode } from './types';
3
- export interface AuthRoutesConfig extends AuthSessionConfig {
4
- /**
5
- * Override the callback URL. Absent, it is derived from the incoming request: the origin it
6
- * arrived at, the path this route file sits on, and `/callback`.
7
- *
8
- * Deriving it trusts the `Host` header, which is chosen by whoever made the request. That is a
9
- * bounded trust — a forged host produces a `redirect_uri` Keycloak has not registered, and
10
- * Keycloak refuses it — but a deployment behind a proxy that rewrites the host should say the
11
- * URL out loud here rather than discover this.
12
- */
13
- readonly redirectUri?: string;
14
- /**
15
- * The product's page that renders a failed sign-in, sign-in callback or sign-out, reached as
16
- * `?code=<AuthErrorCode>`. Default `/auth/problem`. `authMiddleware` keeps the same default
17
- * public: whoever lands here has, by definition, no session.
18
- */
19
- readonly problemPage?: string;
20
- }
21
- export interface AuthRouteHandlers {
3
+ export interface RouteHandlers {
22
4
  GET(request: Request): Promise<Response>;
23
5
  POST(request: Request): Promise<Response>;
24
6
  }
25
7
  /** The status for a party that did not answer, or `undefined` for a refusal. */
26
8
  export declare function outage(code: AuthErrorCode): number | undefined;
27
- export declare function authRoutes(config: AuthRoutesConfig): AuthRouteHandlers;
9
+ /** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */
10
+ export declare function routes(instance: () => Promise<Bound>): RouteHandlers;
28
11
  //# sourceMappingURL=next-routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,OAAO,EAAa,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAkDxD,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAKD,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC3C;AA0CD,gFAAgF;AAChF,wBAAgB,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAK9D;AAgBD,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,iBAAiB,CAmHtE"}
1
+ {"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAErE,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"}
@@ -1,102 +1,121 @@
1
- import { isSameSite as y } from "./same-site.js";
2
- import { relyingParty as b } from "./server.js";
1
+ import { withTenant as T, tenantRequest as P } from "./next-bound.js";
2
+ import { isSameSite as R } from "./same-site.js";
3
3
  import { AuthError as w } from "./types.js";
4
- const f = { "content-type": "application/json", "cache-control": "no-store" }, k = "/auth/problem";
5
- function P(e) {
6
- const n = e.lastIndexOf("/");
7
- return { action: e.slice(n + 1), base: n <= 0 ? "" : e.slice(0, n) };
4
+ const p = { "content-type": "application/json", "cache-control": "no-store" };
5
+ function S(n) {
6
+ const r = n.lastIndexOf("/");
7
+ return { action: n.slice(r + 1), base: r <= 0 ? "" : n.slice(0, r) };
8
8
  }
9
- function g(e, n) {
10
- if (!(e === null || e.length === 0))
9
+ function m(n, r) {
10
+ if (!(n === null || n.length === 0))
11
11
  try {
12
- const a = new URL(e, n);
13
- return a.origin === n ? a.href : void 0;
12
+ const o = new URL(n, r);
13
+ return o.origin === r ? o.href : void 0;
14
14
  } catch {
15
15
  return;
16
16
  }
17
17
  }
18
- function h(e, n) {
19
- const a = new Headers({ location: e, "cache-control": "no-store" });
20
- for (const c of n) a.append("set-cookie", c);
21
- return new Response(null, { status: 302, headers: a });
18
+ function f(n, r) {
19
+ const o = new Headers({ location: n, "cache-control": "no-store" });
20
+ for (const a of r) o.append("set-cookie", a);
21
+ return new Response(null, { status: 302, headers: o });
22
22
  }
23
- function T(e) {
24
- if (e === "idp/silent" || e === "session/silent") return 504;
25
- if (e === "idp/unreachable") return 502;
26
- if (e === "session/unavailable") return 503;
23
+ function u(n, r = {}) {
24
+ return new Response(null, { status: n, headers: { "cache-control": "no-store", ...r } });
27
25
  }
28
- function p(e) {
29
- if (!(e instanceof w)) throw e;
30
- return new Response(JSON.stringify({ error: e.code, message: e.message }), {
31
- status: T(e.code) ?? (e.code === "session/absent" ? 401 : 400),
32
- headers: f
33
- });
26
+ function O(n) {
27
+ if (n === "idp/silent" || n === "session/silent") return 504;
28
+ if (n === "idp/unreachable") return 502;
29
+ if (n === "session/unavailable") return 503;
34
30
  }
35
- function E(e) {
36
- let n;
37
- const a = (s) => ((n == null ? void 0 : n.redirectUri) !== s && (n = { redirectUri: s, auth: b({ ...e, redirectUri: s }) }), n.auth), c = (s, r) => {
38
- if (!(s instanceof w)) throw s;
39
- const i = new URL(e.problemPage ?? k, r);
40
- return i.searchParams.set("code", s.code), h(i.href, []);
41
- }, d = async (s) => {
42
- const r = new URL(s.url), { action: i, base: m } = P(r.pathname), l = s.headers.get("cookie"), u = a(e.redirectUri ?? `${r.origin}${m}/callback`);
43
- if (i !== "callback" && i !== "signin" && !y(s))
44
- return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
45
- switch (i) {
31
+ function g(n) {
32
+ if (!(n instanceof w)) throw n;
33
+ return d(n.code, n.message, O(n.code) ?? 400);
34
+ }
35
+ function d(n, r, o, a = []) {
36
+ const s = new Headers(p);
37
+ for (const c of a) s.append("set-cookie", c);
38
+ return new Response(JSON.stringify({ error: n, message: r }), { status: o, headers: s });
39
+ }
40
+ function E(n) {
41
+ const r = async (o) => {
42
+ const a = await n(), s = new URL(o.url), { action: c, base: b } = S(s.pathname), l = o.headers.get("cookie"), i = a.party, k = a.redirectUri ?? `${s.origin}${b}/callback`, h = (e) => {
43
+ if (!(e instanceof w)) throw e;
44
+ const t = new URL(a.problemPage, s.origin);
45
+ return t.searchParams.set("code", e.code), f(t.href, []);
46
+ };
47
+ if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !R(o)) return u(403);
48
+ switch (c) {
46
49
  case "signin":
47
50
  try {
48
- const t = await u.begin({
49
- returnTo: g(r.searchParams.get("returnTo"), r.origin) ?? "/",
50
- organization: r.searchParams.get("organization") ?? void 0
51
+ const e = await i.begin({
52
+ redirectUri: k,
53
+ returnTo: m(s.searchParams.get("returnTo"), s.origin) ?? "/",
54
+ organization: s.searchParams.get("organization") ?? void 0
51
55
  });
52
- return h(t.url, t.cookies);
53
- } catch (t) {
54
- return c(t, r.origin);
56
+ return f(e.url, e.cookies);
57
+ } catch (e) {
58
+ return h(e);
55
59
  }
56
60
  case "callback":
57
61
  try {
58
- const t = await u.complete({ url: r, cookie: l });
59
- return h(t.returnTo, t.cookies);
60
- } catch (t) {
61
- return c(t, r.origin);
62
+ const e = await i.complete({ url: s, cookie: l, redirectUri: k });
63
+ return f(e.returnTo, e.cookies);
64
+ } catch (e) {
65
+ return h(e);
62
66
  }
63
67
  case "signout": {
64
- const t = g(r.searchParams.get("returnTo"), r.origin);
68
+ const e = m(s.searchParams.get("returnTo"), s.origin);
65
69
  try {
66
- const o = await u.end(l, { returnTo: t });
67
- return h(o.url, o.cookies);
68
- } catch (o) {
69
- return c(o, r.origin);
70
+ const t = await i.end(l, { returnTo: e });
71
+ return f(t.url, t.cookies);
72
+ } catch (t) {
73
+ return h(t);
70
74
  }
71
75
  }
72
76
  case "session": {
73
- let t;
77
+ let e;
74
78
  try {
75
- t = await u.read(l);
76
- } catch (o) {
77
- return p(o);
79
+ e = await i.read(l), e !== null && (e = T(
80
+ e,
81
+ await a.tenant(P(o))
82
+ ));
83
+ } catch (t) {
84
+ return g(t);
78
85
  }
79
- return t === null ? new Response(null, { status: 401, headers: { "cache-control": "no-store" } }) : new Response(JSON.stringify(t), { status: 200, headers: f });
86
+ return e === null ? u(401) : new Response(JSON.stringify(e), { status: 200, headers: p });
80
87
  }
81
88
  case "refresh": {
82
- if (s.method !== "POST")
83
- return new Response(null, { status: 405, headers: { allow: "POST" } });
89
+ if (o.method !== "POST") return u(405, { allow: "POST" });
90
+ try {
91
+ const e = await i.refresh(l);
92
+ if (e.ended)
93
+ return d(e.code, "there is no session left to renew", 401, e.cookies);
94
+ const t = new Headers(p);
95
+ for (const y of e.cookies) t.append("set-cookie", y);
96
+ return new Response(JSON.stringify(e.session), { status: 200, headers: t });
97
+ } catch (e) {
98
+ return g(e);
99
+ }
100
+ }
101
+ case "backchannel-logout": {
102
+ if (o.method !== "POST") return u(405, { allow: "POST" });
103
+ const e = new URLSearchParams(await o.text()).get("logout_token");
104
+ if (e === null || e === "") return d("token/refused", "no `logout_token`", 400);
84
105
  try {
85
- const t = await u.refresh(l), o = new Headers(f);
86
- for (const R of t.cookies) o.append("set-cookie", R);
87
- return new Response(JSON.stringify(t.session), { status: 200, headers: o });
106
+ return await i.logout(e), u(200);
88
107
  } catch (t) {
89
- return p(t);
108
+ return t instanceof w && t.code === "session/irrevocable" ? d(t.code, t.message, 501) : g(t);
90
109
  }
91
110
  }
92
111
  default:
93
112
  return new Response(null, { status: 404 });
94
113
  }
95
114
  };
96
- return { GET: d, POST: d };
115
+ return { GET: r, POST: r };
97
116
  }
98
117
  export {
99
- E as authRoutes,
100
- T as outage
118
+ O as outage,
119
+ E as routes
101
120
  };
102
121
  //# sourceMappingURL=next-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { isSameSite } from \"./same-site\";\nimport type { AuthSessionConfig } from \"./next-session\";\nimport { relyingParty, type RelyingParty } from \"./server\";\nimport { AuthError, type AuthErrorCode } from \"./types\";\n\n/**\n * The five routes a Backend For Frontend needs, as one App Router catch-all.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = authRoutes({ issuer, clientId, clientSecret, secret });\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-session.ts` and `next-middleware.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; it navigates to `${basePath}/signin?returnTo=…&organization=…` and\n * `${basePath}/signout?returnTo=…`. Those five paths and those status codes are the contract, and\n * `next-routes.test.ts` drives a real `bffAuth` against these handlers rather than trusting the\n * 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 {@link AuthRoutesConfig.problemPage}, the code in\n * `?code=`, where the product renders it in its own words. `session` and `refresh` are reached by\n * `fetch`, and answer with a status and `{ error, message }`.\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. The other\n * three are not: `signout` reached cross-site is a logout anyone can cause, `refresh` is a\n * rotation anyone can cause, and `session` is a person's identity read from a page that is not\n * ours. `same-site.ts` carries the check and the reason the package rather than the product owes\n * 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 AuthRoutesConfig extends AuthSessionConfig {\n /**\n * Override the callback URL. Absent, it is derived from the incoming request: the origin it\n * arrived at, the path this route file sits on, and `/callback`.\n *\n * Deriving it trusts the `Host` header, which is chosen by whoever made the request. That is a\n * bounded trust — a forged host produces a `redirect_uri` Keycloak has not registered, and\n * Keycloak refuses it — but a deployment behind a proxy that rewrites the host should say the\n * URL out loud here rather than discover this.\n */\n readonly redirectUri?: string;\n /**\n * The product's page that renders a failed sign-in, sign-in callback or sign-out, reached as\n * `?code=<AuthErrorCode>`. Default `/auth/problem`. `authMiddleware` keeps the same default\n * public: whoever lands here has, by definition, no session.\n */\n readonly problemPage?: string;\n}\n\n/** Where a failed navigation is sent; `authMiddleware` holds the same default. */\nconst PROBLEM_PAGE = \"/auth/problem\";\n\nexport interface AuthRouteHandlers {\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/** 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 new Response(JSON.stringify({ error: error.code, message: error.message }), {\n status: outage(error.code) ?? (error.code === \"session/absent\" ? 401 : 400),\n headers: PRIVATE,\n });\n}\n\nexport function authRoutes(config: AuthRoutesConfig): AuthRouteHandlers {\n // One instance, rebuilt only when the callback URL it was built for changes. A `Map` keyed by\n // origin would grow without bound on a stream of forged `Host` headers; a single slot cannot,\n // and it self-heals, because the next genuine request derives its own URL again. What it costs\n // in that case is a re-discovery, which is the right price for a request that lied.\n let current: { readonly redirectUri: string; readonly auth: RelyingParty } | undefined;\n const authFor = (redirectUri: string): RelyingParty => {\n if (current?.redirectUri !== redirectUri) {\n current = { redirectUri, auth: relyingParty({ ...config, redirectUri }) };\n }\n return current.auth;\n };\n\n /** An `AuthError` on a navigation, as the product's page; anything else rethrown, as in `failure`. */\n const problem = (error: unknown, origin: string): Response => {\n if (!(error instanceof AuthError)) throw error;\n const page = new URL(config.problemPage ?? PROBLEM_PAGE, origin);\n page.searchParams.set(\"code\", error.code);\n return redirect(page.href, []);\n };\n\n const handle = async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = authFor(config.redirectUri ?? `${url.origin}${base}/callback`);\n\n if (action !== \"callback\" && action !== \"signin\" && !isSameSite(request)) {\n return new Response(null, { status: 403, headers: { \"cache-control\": \"no-store\" } });\n }\n\n switch (action) {\n case \"signin\": {\n try {\n const started = await auth.begin({\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, url.origin);\n }\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return problem(error, url.origin);\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, url.origin);\n }\n }\n\n case \"session\": {\n let session: Awaited<ReturnType<RelyingParty[\"read\"]>>;\n try {\n session = await auth.read(cookie);\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) {\n return new Response(null, { status: 401, headers: { \"cache-control\": \"no-store\" } });\n }\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n case \"refresh\": {\n // `POST` only, and it is the one route here that constrains its verb. The others are\n // reached by navigation, where the verb is the browser's to choose; this one spends a\n // refresh token, and a `GET` that spends something is a link, a prefetch and a preview\n // pane away from spending it. The 405 says so rather than answering 404 for a route that\n // is plainly there.\n if (request.method !== \"POST\") {\n return new Response(null, { status: 405, headers: { allow: \"POST\" } });\n }\n try {\n const renewed = await auth.refresh(cookie);\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 // A refused refresh is the end of the session, and `failure` already answers 401 for\n // `session/absent`. `token/exchange-failed` is a 400 and means the same thing to the\n // browser: there is nothing left to renew, go and sign in. An outage is a 5xx, which\n // `bffAuth` does not read as an ended session.\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` is the exception and\n // checks its own method, because it is the only one that spends something.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","PROBLEM_PAGE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","outage","code","failure","error","AuthError","authRoutes","config","current","authFor","redirectUri","relyingParty","problem","page","handle","request","action","base","auth","isSameSite","started","done","returnTo","ended","session","renewed","value"],"mappings":"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"}
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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"}