@kanzo-tech/auth 0.30.0 → 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.
@@ -52,10 +52,15 @@ export interface KanzoAuthConfig extends RelyingPartyConfig {
52
52
  /**
53
53
  * Forward `mount` to a resource server at `target` with the access token as a bearer — the
54
54
  * token-mediating backend. The proxy leaves `mount` alone; `api` answers it.
55
+ *
56
+ * `target` may depend on the organization a request addresses: one BFF in front of a resource
57
+ * server per tenant, which is Keycloak's Organizations model — one client shared by every
58
+ * organization — with the data still kept apart. A request that addresses none — a bare host —
59
+ * has no server to reach, and `undefined` answers it 404.
55
60
  */
56
61
  readonly api?: {
57
62
  readonly mount: string;
58
- readonly target: string;
63
+ readonly target: string | ((organization: string | undefined) => string | undefined);
59
64
  };
60
65
  }
61
66
  /** What a `kanzoAuth` instance is once its config has been read: one relying party and its settings. */
@@ -67,12 +72,14 @@ export interface Bound {
67
72
  readonly renewWithin?: number;
68
73
  readonly api?: {
69
74
  readonly mount: string;
70
- readonly target: URL;
75
+ target(organization: string | undefined): URL | undefined;
71
76
  };
72
77
  /** Prefixes the proxy lets through: the routes, the forwarder, the problem page, and `public`. */
73
78
  readonly open: readonly string[];
74
79
  tenant(request: TenantRequest): Promise<string | undefined>;
75
80
  }
81
+ /** What a tenant resolver reads, from a request a route handler was given. */
82
+ export declare function tenantRequest(request: Request): TenantRequest;
76
83
  /** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */
77
84
  export declare const URL_HEADER = "x-kanzo-url";
78
85
  export declare function bind(config: KanzoAuthConfig): Bound;
@@ -1 +1 @@
1
- {"version":3,"file":"next-bound.d.ts","sourceRoot":"","sources":["../src/next-bound.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC;;;;GAIG;AAEH;;;;GAIG;AACH,KAAK,aAAa,GAAG;IACnB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAA;KAAE,CAAC;CACjF,CAAC;AAEF,MAAM,WAAW,eAAgB,SAAQ,kBAAkB;IACzD;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACrG,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACpE;AAED,wGAAwG;AACxG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAA;KAAE,CAAC;IAChE,kGAAkG;IAClG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC7D;AAED,6GAA6G;AAC7G,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAGxC,wBAAgB,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,KAAK,CAoBnD;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAEtF"}
1
+ {"version":3,"file":"next-bound.d.ts","sourceRoot":"","sources":["../src/next-bound.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIvC;;;;GAIG;AAEH;;;;GAIG;AACH,KAAK,aAAa,GAAG;IACnB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAA;KAAE,CAAC;CACjF,CAAC;AAEF,MAAM,WAAW,eAAgB,SAAQ,kBAAkB;IACzD;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACrG,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;OAQG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE;QACb,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;KACtF,CAAC;CACH;AAED,wGAAwG;AACxG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,CAAA;KAAE,CAAC;IACrG,kGAAkG;IAClG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC7D;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,CAY7D;AAED,6GAA6G;AAC7G,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAGxC,wBAAgB,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,KAAK,CA2BnD;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAEtF"}
@@ -1,27 +1,48 @@
1
- import { relyingParty as p } from "./server.js";
2
- import { withoutTrailingSlashes as a } from "./issuer.js";
3
- const d = "x-kanzo-url";
4
- function h(t) {
5
- const r = a(t.basePath ?? "/api/auth"), i = t.problemPage ?? "/auth/problem", e = t.api === void 0 ? void 0 : { mount: a(t.api.mount), target: new URL(t.api.target) }, n = t.organization;
1
+ import { relyingParty as s } from "./server.js";
2
+ import { cookieValue as p } from "./cookie-session.js";
3
+ import { withoutTrailingSlashes as i } from "./issuer.js";
4
+ function c(t) {
5
+ const r = t.headers.get("cookie");
6
6
  return {
7
- party: p(t),
7
+ url: new URL(t.url),
8
+ headers: t.headers,
9
+ cookies: {
10
+ get(o) {
11
+ const e = p(r, o);
12
+ return e === void 0 ? void 0 : { value: e };
13
+ }
14
+ }
15
+ };
16
+ }
17
+ const v = "x-kanzo-url";
18
+ function b(t) {
19
+ const r = i(t.basePath ?? "/api/auth"), o = t.problemPage ?? "/auth/problem", e = t.api, n = e === void 0 ? void 0 : {
20
+ mount: i(e.mount),
21
+ target: (a) => {
22
+ const d = typeof e.target == "string" ? e.target : e.target(a);
23
+ return d === void 0 ? void 0 : new URL(d);
24
+ }
25
+ }, u = t.organization;
26
+ return {
27
+ party: s(t),
8
28
  basePath: r,
9
- problemPage: i,
29
+ problemPage: o,
10
30
  redirectUri: t.redirectUri,
11
31
  renewWithin: t.renewWithin,
12
- api: e,
13
- open: [r, i, ...e === void 0 ? [] : [e.mount], ...t.public ?? []].map(
14
- a
32
+ api: n,
33
+ open: [r, o, ...n === void 0 ? [] : [n.mount], ...t.public ?? []].map(
34
+ i
15
35
  ),
16
- tenant: async (o) => n === void 0 ? void 0 : n(o)
36
+ tenant: async (a) => u === void 0 ? void 0 : u(a)
17
37
  };
18
38
  }
19
- function l(t, r) {
39
+ function w(t, r) {
20
40
  return r === void 0 ? t : { ...t, organization: r };
21
41
  }
22
42
  export {
23
- d as URL_HEADER,
24
- h as bind,
25
- l as withTenant
43
+ v as URL_HEADER,
44
+ b as bind,
45
+ c as tenantRequest,
46
+ w as withTenant
26
47
  };
27
48
  //# sourceMappingURL=next-bound.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-bound.js","sources":["../src/next-bound.ts"],"sourcesContent":["import { relyingParty, type RelyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\nimport { withoutTrailingSlashes } from \"./issuer\";\n\n/**\n * A `kanzoAuth` config once read: one relying party, and the settings the proxy, the routes and\n * the forwarder share. In a module of its own so those three can take it without importing the\n * door that builds them.\n */\n\n/**\n * What a tenant resolver is handed. The proxy, the routes and a server component each build it\n * from the request in front of them — a server component from the `x-kanzo-url` the proxy\n * forwarded — so the same function answers the same way in all three.\n */\ntype TenantRequest = {\n readonly url: URL;\n readonly headers: Headers;\n readonly cookies: { get(name: string): { readonly value: string } | undefined };\n};\n\nexport interface KanzoAuthConfig extends RelyingPartyConfig {\n /**\n * The organization a request addresses: a hostname's first label, a path segment, a cookie, or\n * one fixed alias for a deployment per tenant. Its answer is `Session.organization`, and the\n * organization `can` asks inside by default. Absent, sessions carry no current tenant.\n */\n readonly organization?: (request: TenantRequest) => string | undefined | Promise<string | undefined>;\n /** Where `routes` is mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /**\n * The product's page for a failed sign-in, callback or sign-out, and for a session store that\n * does not answer, reached as `?code=<AuthErrorCode>`. Default `/auth/problem`. Always public:\n * whoever is sent there has no session, and sending them to sign in instead is a loop when\n * signing in is what failed.\n */\n readonly problemPage?: string;\n /**\n * Path prefixes the proxy lets through without a session — a health probe, a legal notice.\n * Matched on segment boundaries: `/health` opens `/health/live` and not `/healthcare`.\n */\n readonly public?: readonly string[];\n /**\n * Override the callback URL. Absent, it is derived from the request: its origin, the path the\n * routes sit on, and `/callback`. Deriving it trusts `Host`, which is bounded — Keycloak refuses\n * a `redirect_uri` it has not registered — but a deployment behind a proxy that rewrites the\n * host says the URL out loud here.\n */\n readonly redirectUri?: string;\n /** Seconds of access-token lifetime below which a request renews it. Default 60. */\n readonly renewWithin?: number;\n /**\n * Forward `mount` to a resource server at `target` with the access token as a bearer — the\n * token-mediating backend. The proxy leaves `mount` alone; `api` answers it.\n */\n readonly api?: { readonly mount: string; readonly target: string };\n}\n\n/** What a `kanzoAuth` instance is once its config has been read: one relying party and its settings. */\nexport interface Bound {\n readonly party: RelyingParty;\n readonly basePath: string;\n readonly problemPage: string;\n readonly redirectUri?: string;\n readonly renewWithin?: number;\n readonly api?: { readonly mount: string; readonly target: URL };\n /** Prefixes the proxy lets through: the routes, the forwarder, the problem page, and `public`. */\n readonly open: readonly string[];\n tenant(request: TenantRequest): Promise<string | undefined>;\n}\n\n/** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */\nexport const URL_HEADER = \"x-kanzo-url\";\n\n\nexport function bind(config: KanzoAuthConfig): Bound {\n const basePath = withoutTrailingSlashes(config.basePath ?? \"/api/auth\");\n const problemPage = config.problemPage ?? \"/auth/problem\";\n const api =\n config.api === undefined\n ? undefined\n : { mount: withoutTrailingSlashes(config.api.mount), target: new URL(config.api.target) };\n const resolve = config.organization;\n return {\n party: relyingParty(config),\n basePath,\n problemPage,\n redirectUri: config.redirectUri,\n renewWithin: config.renewWithin,\n api,\n open: [basePath, problemPage, ...(api === undefined ? [] : [api.mount]), ...(config.public ?? [])].map(\n withoutTrailingSlashes,\n ),\n tenant: async (request) => (resolve === undefined ? undefined : resolve(request)),\n };\n}\n\n/** The current tenant on a session, when there is one. */\nexport function withTenant(session: Session, organization: string | undefined): Session {\n return organization === undefined ? session : { ...session, organization };\n}\n\n"],"names":["URL_HEADER","bind","config","basePath","withoutTrailingSlashes","problemPage","api","resolve","relyingParty","request","withTenant","session","organization"],"mappings":";;AAwEO,MAAMA,IAAa;AAGnB,SAASC,EAAKC,GAAgC;AACnD,QAAMC,IAAWC,EAAuBF,EAAO,YAAY,WAAW,GAChEG,IAAcH,EAAO,eAAe,iBACpCI,IACJJ,EAAO,QAAQ,SACX,SACA,EAAE,OAAOE,EAAuBF,EAAO,IAAI,KAAK,GAAG,QAAQ,IAAI,IAAIA,EAAO,IAAI,MAAM,EAAA,GACpFK,IAAUL,EAAO;AACvB,SAAO;AAAA,IACL,OAAOM,EAAaN,CAAM;AAAA,IAC1B,UAAAC;AAAA,IACA,aAAAE;AAAA,IACA,aAAaH,EAAO;AAAA,IACpB,aAAaA,EAAO;AAAA,IACpB,KAAAI;AAAA,IACA,MAAM,CAACH,GAAUE,GAAa,GAAIC,MAAQ,SAAY,CAAA,IAAK,CAACA,EAAI,KAAK,GAAI,GAAIJ,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,MACjGE;AAAA,IAAA;AAAA,IAEF,QAAQ,OAAOK,MAAaF,MAAY,SAAY,SAAYA,EAAQE,CAAO;AAAA,EAAA;AAEnF;AAGO,SAASC,EAAWC,GAAkBC,GAA2C;AACtF,SAAOA,MAAiB,SAAYD,IAAU,EAAE,GAAGA,GAAS,cAAAC,EAAA;AAC9D;"}
1
+ {"version":3,"file":"next-bound.js","sources":["../src/next-bound.ts"],"sourcesContent":["import { relyingParty, type RelyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\nimport { cookieValue } from \"./cookie-session\";\nimport { withoutTrailingSlashes } from \"./issuer\";\n\n/**\n * A `kanzoAuth` config once read: one relying party, and the settings the proxy, the routes and\n * the forwarder share. In a module of its own so those three can take it without importing the\n * door that builds them.\n */\n\n/**\n * What a tenant resolver is handed. The proxy, the routes and a server component each build it\n * from the request in front of them — a server component from the `x-kanzo-url` the proxy\n * forwarded — so the same function answers the same way in all three.\n */\ntype TenantRequest = {\n readonly url: URL;\n readonly headers: Headers;\n readonly cookies: { get(name: string): { readonly value: string } | undefined };\n};\n\nexport interface KanzoAuthConfig extends RelyingPartyConfig {\n /**\n * The organization a request addresses: a hostname's first label, a path segment, a cookie, or\n * one fixed alias for a deployment per tenant. Its answer is `Session.organization`, and the\n * organization `can` asks inside by default. Absent, sessions carry no current tenant.\n */\n readonly organization?: (request: TenantRequest) => string | undefined | Promise<string | undefined>;\n /** Where `routes` is mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /**\n * The product's page for a failed sign-in, callback or sign-out, and for a session store that\n * does not answer, reached as `?code=<AuthErrorCode>`. Default `/auth/problem`. Always public:\n * whoever is sent there has no session, and sending them to sign in instead is a loop when\n * signing in is what failed.\n */\n readonly problemPage?: string;\n /**\n * Path prefixes the proxy lets through without a session — a health probe, a legal notice.\n * Matched on segment boundaries: `/health` opens `/health/live` and not `/healthcare`.\n */\n readonly public?: readonly string[];\n /**\n * Override the callback URL. Absent, it is derived from the request: its origin, the path the\n * routes sit on, and `/callback`. Deriving it trusts `Host`, which is bounded — Keycloak refuses\n * a `redirect_uri` it has not registered — but a deployment behind a proxy that rewrites the\n * host says the URL out loud here.\n */\n readonly redirectUri?: string;\n /** Seconds of access-token lifetime below which a request renews it. Default 60. */\n readonly renewWithin?: number;\n /**\n * Forward `mount` to a resource server at `target` with the access token as a bearer — the\n * token-mediating backend. The proxy leaves `mount` alone; `api` answers it.\n *\n * `target` may depend on the organization a request addresses: one BFF in front of a resource\n * server per tenant, which is Keycloak's Organizations model — one client shared by every\n * organization — with the data still kept apart. A request that addresses none — a bare host —\n * has no server to reach, and `undefined` answers it 404.\n */\n readonly api?: {\n readonly mount: string;\n readonly target: string | ((organization: string | undefined) => string | undefined);\n };\n}\n\n/** What a `kanzoAuth` instance is once its config has been read: one relying party and its settings. */\nexport interface Bound {\n readonly party: RelyingParty;\n readonly basePath: string;\n readonly problemPage: string;\n readonly redirectUri?: string;\n readonly renewWithin?: number;\n readonly api?: { readonly mount: string; target(organization: string | undefined): URL | undefined };\n /** Prefixes the proxy lets through: the routes, the forwarder, the problem page, and `public`. */\n readonly open: readonly string[];\n tenant(request: TenantRequest): Promise<string | undefined>;\n}\n\n/** What a tenant resolver reads, from a request a route handler was given. */\nexport function tenantRequest(request: Request): TenantRequest {\n const header = request.headers.get(\"cookie\");\n return {\n url: new URL(request.url),\n headers: request.headers,\n cookies: {\n get(name: string) {\n const value = cookieValue(header, name);\n return value === undefined ? undefined : { value };\n },\n },\n };\n}\n\n/** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */\nexport const URL_HEADER = \"x-kanzo-url\";\n\n\nexport function bind(config: KanzoAuthConfig): Bound {\n const basePath = withoutTrailingSlashes(config.basePath ?? \"/api/auth\");\n const problemPage = config.problemPage ?? \"/auth/problem\";\n const forward = config.api;\n const api =\n forward === undefined\n ? undefined\n : {\n mount: withoutTrailingSlashes(forward.mount),\n target: (organization: string | undefined) => {\n const target = typeof forward.target === \"string\" ? forward.target : forward.target(organization);\n return target === undefined ? undefined : new URL(target);\n },\n };\n const resolve = config.organization;\n return {\n party: relyingParty(config),\n basePath,\n problemPage,\n redirectUri: config.redirectUri,\n renewWithin: config.renewWithin,\n api,\n open: [basePath, problemPage, ...(api === undefined ? [] : [api.mount]), ...(config.public ?? [])].map(\n withoutTrailingSlashes,\n ),\n tenant: async (request) => (resolve === undefined ? undefined : resolve(request)),\n };\n}\n\n/** The current tenant on a session, when there is one. */\nexport function withTenant(session: Session, organization: string | undefined): Session {\n return organization === undefined ? session : { ...session, organization };\n}\n\n"],"names":["tenantRequest","request","header","name","value","cookieValue","URL_HEADER","bind","config","basePath","withoutTrailingSlashes","problemPage","forward","api","organization","target","resolve","relyingParty","withTenant","session"],"mappings":";;;AAiFO,SAASA,EAAcC,GAAiC;AAC7D,QAAMC,IAASD,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAO;AAAA,IACL,KAAK,IAAI,IAAIA,EAAQ,GAAG;AAAA,IACxB,SAASA,EAAQ;AAAA,IACjB,SAAS;AAAA,MACP,IAAIE,GAAc;AAChB,cAAMC,IAAQC,EAAYH,GAAQC,CAAI;AACtC,eAAOC,MAAU,SAAY,SAAY,EAAE,OAAAA,EAAA;AAAA,MAC7C;AAAA,IAAA;AAAA,EACF;AAEJ;AAGO,MAAME,IAAa;AAGnB,SAASC,EAAKC,GAAgC;AACnD,QAAMC,IAAWC,EAAuBF,EAAO,YAAY,WAAW,GAChEG,IAAcH,EAAO,eAAe,iBACpCI,IAAUJ,EAAO,KACjBK,IACJD,MAAY,SACR,SACA;AAAA,IACE,OAAOF,EAAuBE,EAAQ,KAAK;AAAA,IAC3C,QAAQ,CAACE,MAAqC;AAC5C,YAAMC,IAAS,OAAOH,EAAQ,UAAW,WAAWA,EAAQ,SAASA,EAAQ,OAAOE,CAAY;AAChG,aAAOC,MAAW,SAAY,SAAY,IAAI,IAAIA,CAAM;AAAA,IAC1D;AAAA,EAAA,GAEFC,IAAUR,EAAO;AACvB,SAAO;AAAA,IACL,OAAOS,EAAaT,CAAM;AAAA,IAC1B,UAAAC;AAAA,IACA,aAAAE;AAAA,IACA,aAAaH,EAAO;AAAA,IACpB,aAAaA,EAAO;AAAA,IACpB,KAAAK;AAAA,IACA,MAAM,CAACJ,GAAUE,GAAa,GAAIE,MAAQ,SAAY,CAAA,IAAK,CAACA,EAAI,KAAK,GAAI,GAAIL,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,MACjGE;AAAA,IAAA;AAAA,IAEF,QAAQ,OAAOT,MAAae,MAAY,SAAY,SAAYA,EAAQf,CAAO;AAAA,EAAA;AAEnF;AAGO,SAASiB,EAAWC,GAAkBL,GAA2C;AACtF,SAAOA,MAAiB,SAAYK,IAAU,EAAE,GAAGA,GAAS,cAAAL,EAAA;AAC9D;"}
@@ -1 +1 @@
1
- {"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAM1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,CAgFnE"}
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,6 +1,7 @@
1
- import { outage as k } from "./next-routes.js";
2
- import { isSameSite as E } from "./same-site.js";
3
- import { AuthError as y } from "./types.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
+ import { AuthError as R } from "./types.js";
4
5
  const T = [
5
6
  "connection",
6
7
  "keep-alive",
@@ -12,59 +13,61 @@ const T = [
12
13
  "upgrade"
13
14
  ], H = [...T, "cookie", "authorization", "host", "content-length", "accept-encoding"], O = [...T, "set-cookie", "content-encoding", "content-length"];
14
15
  function w(h, d) {
15
- const o = new Headers(h);
16
- for (const t of d) o.delete(t);
17
- return o;
16
+ const t = new Headers(h);
17
+ for (const o of d) t.delete(o);
18
+ return t;
18
19
  }
19
- function D(h) {
20
- const d = (...e) => globalThis.fetch(...e), o = (e, n = []) => {
20
+ function A(h) {
21
+ const d = (...e) => globalThis.fetch(...e), t = (e, n = []) => {
21
22
  const r = new Headers({ "cache-control": "no-store" });
22
- for (const c of n) r.append("set-cookie", c);
23
+ for (const a of n) r.append("set-cookie", a);
23
24
  return new Response(null, { status: e, headers: r });
24
- }, t = async (e) => {
25
+ }, o = async (e) => {
25
26
  const n = await h();
26
- if (n.api === void 0) return o(404);
27
- if (!E(e)) return o(403);
28
- const r = n.api.mount, c = n.api.target, g = c.pathname.replace(/\/$/, ""), p = new URL(e.url);
29
- if (!p.pathname.startsWith(r)) return o(400);
30
- const u = new URL(c.href);
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);
31
34
  u.pathname = `${g}${p.pathname.slice(r.length)}`, u.search = p.search;
32
- let a;
35
+ let s;
33
36
  try {
34
- a = await n.party.token(e.headers.get("cookie"), { renewWithin: n.renewWithin });
35
- } catch (s) {
36
- if (!(s instanceof y)) throw s;
37
- return o(k(s.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);
38
41
  }
39
- if (a.ended) return o(401, a.cookies);
40
- const l = w(e.headers, H);
41
- l.set("authorization", `Bearer ${a.accessToken}`);
42
- const f = e.method === "GET" || e.method === "HEAD" ? null : e.body, i = await d(u, {
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, {
43
46
  method: e.method,
44
- headers: l,
45
- body: f,
47
+ headers: f,
48
+ body: l,
46
49
  // The body is a stream and is forwarded as one: an upload is not read into this server's
47
50
  // memory on its way past, and a server-sent event stream is not buffered until it ends —
48
51
  // which for an SSE endpoint means never. `duplex` is what Node requires to allow it.
49
- ...f === null ? {} : { duplex: "half" },
52
+ ...l === null ? {} : { duplex: "half" },
50
53
  // A 302 from the upstream is the upstream's answer and belongs to the caller. Following it
51
54
  // here would send the bearer token to whatever host the `Location` names.
52
55
  redirect: "manual"
53
56
  }), m = w(i.headers, O);
54
- for (const s of a.cookies) m.append("set-cookie", s);
57
+ for (const c of s.cookies) m.append("set-cookie", c);
55
58
  return new Response(i.body, { status: i.status, statusText: i.statusText, headers: m });
56
59
  };
57
60
  return {
58
- GET: t,
59
- POST: t,
60
- PUT: t,
61
- PATCH: t,
62
- DELETE: t,
63
- HEAD: t,
64
- OPTIONS: t
61
+ GET: o,
62
+ POST: o,
63
+ PUT: o,
64
+ PATCH: o,
65
+ DELETE: o,
66
+ HEAD: o,
67
+ OPTIONS: o
65
68
  };
66
69
  }
67
70
  export {
68
- D as forward
71
+ A as forward
69
72
  };
70
73
  //# sourceMappingURL=next-proxy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-proxy.js","sources":["../src/next-proxy.ts"],"sourcesContent":["import 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;\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","prefix","url","upstream","held","error","AuthError","outage","body","answer","out"],"mappings":";;;AAqEA,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,EAAQC,GAA6C;AACnE,QAAMC,IAAO,IAAIC,MAA8C,WAAW,MAAM,GAAGA,CAAI,GAEjFC,IAAS,CAACC,GAAgBC,IAA6B,CAAA,MAAO;AAClE,UAAMR,IAAU,IAAI,QAAQ,EAAE,iBAAiB,YAAY;AAC3D,eAAWS,KAAUD,EAAS,CAAAR,EAAQ,OAAO,cAAcS,CAAM;AACjE,WAAO,IAAI,SAAS,MAAM,EAAE,QAAAF,GAAQ,SAAAP,GAAS;AAAA,EAC/C,GAEMU,IAAS,OAAOC,MAAwC;AAC5D,UAAMC,IAAQ,MAAMT,EAAA;AACpB,QAAIS,EAAM,QAAQ,OAAW,QAAON,EAAO,GAAG;AAC9C,QAAI,CAACO,EAAWF,CAAO,EAAG,QAAOL,EAAO,GAAG;AAE3C,UAAMQ,IAAOF,EAAM,IAAI,OACjBG,IAASH,EAAM,IAAI,QAEnBI,IAASD,EAAO,SAAS,QAAQ,OAAO,EAAE,GAE1CE,IAAM,IAAI,IAAIN,EAAQ,GAAG;AAM/B,QAAI,CAACM,EAAI,SAAS,WAAWH,CAAI,EAAG,QAAOR,EAAO,GAAG;AAKrD,UAAMY,IAAW,IAAI,IAAIH,EAAO,IAAI;AACpC,IAAAG,EAAS,WAAW,GAAGF,CAAM,GAAGC,EAAI,SAAS,MAAMH,EAAK,MAAM,CAAC,IAC/DI,EAAS,SAASD,EAAI;AAEtB,QAAIE;AACJ,QAAI;AACF,MAAAA,IAAO,MAAMP,EAAM,MAAM,MAAMD,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaC,EAAM,aAAa;AAAA,IAClG,SAASQ,GAAO;AAKd,UAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,aAAOd,EAAOgB,EAAOF,EAAM,IAAI,KAAK,GAAG;AAAA,IACzC;AACA,QAAID,EAAK,MAAO,QAAOb,EAAO,KAAKa,EAAK,OAAO;AAE/C,UAAMnB,IAAUH,EAAYc,EAAQ,SAAShB,CAAa;AAC1D,IAAAK,EAAQ,IAAI,iBAAiB,UAAUmB,EAAK,WAAW,EAAE;AAEzD,UAAMI,IAAOZ,EAAQ,WAAW,SAASA,EAAQ,WAAW,SAAS,OAAOA,EAAQ,MAC9Ea,IAAS,MAAMpB,EAAKc,GAAU;AAAA,MAClC,QAAQP,EAAQ;AAAA,MAChB,SAAAX;AAAA,MACA,MAAAuB;AAAA;AAAA;AAAA;AAAA,MAIA,GAAIA,MAAS,OAAO,CAAA,IAAK,EAAE,QAAQ,OAAA;AAAA;AAAA;AAAA,MAGnC,UAAU;AAAA,IAAA,CACI,GAEVE,IAAM5B,EAAY2B,EAAO,SAAS5B,CAAY;AAEpD,eAAWa,KAAUU,EAAK,QAAS,CAAAM,EAAI,OAAO,cAAchB,CAAM;AAElE,WAAO,IAAI,SAASe,EAAO,MAAM,EAAE,QAAQA,EAAO,QAAQ,YAAYA,EAAO,YAAY,SAASC,GAAK;AAAA,EACzG;AAEA,SAAO;AAAA,IACL,KAAKf;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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"}
@@ -6,5 +6,6 @@ export interface RouteHandlers {
6
6
  }
7
7
  /** The status for a party that did not answer, or `undefined` for a refusal. */
8
8
  export declare function outage(code: AuthErrorCode): number | undefined;
9
+ /** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */
9
10
  export declare function routes(instance: () => Promise<Bound>): RouteHandlers;
10
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,EAAc,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAEtD,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;AA6BD,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CA2HpE"}
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,59 +1,50 @@
1
- import { cookieValue as T } from "./cookie-session.js";
2
- import { withTenant as P } from "./next-bound.js";
3
- import { isSameSite as v } from "./same-site.js";
1
+ import { withTenant as T, tenantRequest as P } from "./next-bound.js";
2
+ import { isSameSite as R } from "./same-site.js";
4
3
  import { AuthError as w } from "./types.js";
5
4
  const p = { "content-type": "application/json", "cache-control": "no-store" };
6
- function R(n) {
7
- const o = n.lastIndexOf("/");
8
- return { action: n.slice(o + 1), base: o <= 0 ? "" : n.slice(0, o) };
5
+ function S(n) {
6
+ const r = n.lastIndexOf("/");
7
+ return { action: n.slice(r + 1), base: r <= 0 ? "" : n.slice(0, r) };
9
8
  }
10
- function m(n, o) {
9
+ function m(n, r) {
11
10
  if (!(n === null || n.length === 0))
12
11
  try {
13
- const t = new URL(n, o);
14
- return t.origin === o ? t.href : void 0;
12
+ const o = new URL(n, r);
13
+ return o.origin === r ? o.href : void 0;
15
14
  } catch {
16
15
  return;
17
16
  }
18
17
  }
19
- function f(n, o) {
20
- const t = new Headers({ location: n, "cache-control": "no-store" });
21
- for (const a of o) t.append("set-cookie", a);
22
- return new Response(null, { status: 302, headers: t });
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 });
23
22
  }
24
- function l(n, o = {}) {
25
- return new Response(null, { status: n, headers: { "cache-control": "no-store", ...o } });
23
+ function u(n, r = {}) {
24
+ return new Response(null, { status: n, headers: { "cache-control": "no-store", ...r } });
26
25
  }
27
- function S(n) {
26
+ function O(n) {
28
27
  if (n === "idp/silent" || n === "session/silent") return 504;
29
28
  if (n === "idp/unreachable") return 502;
30
29
  if (n === "session/unavailable") return 503;
31
30
  }
32
31
  function g(n) {
33
32
  if (!(n instanceof w)) throw n;
34
- return d(n.code, n.message, S(n.code) ?? 400);
33
+ return d(n.code, n.message, O(n.code) ?? 400);
35
34
  }
36
- function d(n, o, t, a = []) {
35
+ function d(n, r, o, a = []) {
37
36
  const s = new Headers(p);
38
37
  for (const c of a) s.append("set-cookie", c);
39
- return new Response(JSON.stringify({ error: n, message: o }), { status: t, headers: s });
40
- }
41
- function O(n) {
42
- return {
43
- get(o) {
44
- const t = T(n, o);
45
- return t === void 0 ? void 0 : { value: t };
46
- }
47
- };
38
+ return new Response(JSON.stringify({ error: n, message: r }), { status: o, headers: s });
48
39
  }
49
- function J(n) {
50
- const o = async (t) => {
51
- const a = await n(), s = new URL(t.url), { action: c, base: b } = R(s.pathname), u = t.headers.get("cookie"), i = a.party, k = a.redirectUri ?? `${s.origin}${b}/callback`, h = (e) => {
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) => {
52
43
  if (!(e instanceof w)) throw e;
53
- const r = new URL(a.problemPage, s.origin);
54
- return r.searchParams.set("code", e.code), f(r.href, []);
44
+ const t = new URL(a.problemPage, s.origin);
45
+ return t.searchParams.set("code", e.code), f(t.href, []);
55
46
  };
56
- if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !v(t)) return l(403);
47
+ if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !R(o)) return u(403);
57
48
  switch (c) {
58
49
  case "signin":
59
50
  try {
@@ -68,7 +59,7 @@ function J(n) {
68
59
  }
69
60
  case "callback":
70
61
  try {
71
- const e = await i.complete({ url: s, cookie: u, redirectUri: k });
62
+ const e = await i.complete({ url: s, cookie: l, redirectUri: k });
72
63
  return f(e.returnTo, e.cookies);
73
64
  } catch (e) {
74
65
  return h(e);
@@ -76,55 +67,55 @@ function J(n) {
76
67
  case "signout": {
77
68
  const e = m(s.searchParams.get("returnTo"), s.origin);
78
69
  try {
79
- const r = await i.end(u, { returnTo: e });
80
- return f(r.url, r.cookies);
81
- } catch (r) {
82
- return h(r);
70
+ const t = await i.end(l, { returnTo: e });
71
+ return f(t.url, t.cookies);
72
+ } catch (t) {
73
+ return h(t);
83
74
  }
84
75
  }
85
76
  case "session": {
86
77
  let e;
87
78
  try {
88
- e = await i.read(u), e !== null && (e = P(
79
+ e = await i.read(l), e !== null && (e = T(
89
80
  e,
90
- await a.tenant({ url: s, headers: t.headers, cookies: O(u) })
81
+ await a.tenant(P(o))
91
82
  ));
92
- } catch (r) {
93
- return g(r);
83
+ } catch (t) {
84
+ return g(t);
94
85
  }
95
- return e === null ? l(401) : new Response(JSON.stringify(e), { status: 200, headers: p });
86
+ return e === null ? u(401) : new Response(JSON.stringify(e), { status: 200, headers: p });
96
87
  }
97
88
  case "refresh": {
98
- if (t.method !== "POST") return l(405, { allow: "POST" });
89
+ if (o.method !== "POST") return u(405, { allow: "POST" });
99
90
  try {
100
- const e = await i.refresh(u);
91
+ const e = await i.refresh(l);
101
92
  if (e.ended)
102
93
  return d(e.code, "there is no session left to renew", 401, e.cookies);
103
- const r = new Headers(p);
104
- for (const y of e.cookies) r.append("set-cookie", y);
105
- return new Response(JSON.stringify(e.session), { status: 200, headers: r });
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 });
106
97
  } catch (e) {
107
98
  return g(e);
108
99
  }
109
100
  }
110
101
  case "backchannel-logout": {
111
- if (t.method !== "POST") return l(405, { allow: "POST" });
112
- const e = new URLSearchParams(await t.text()).get("logout_token");
102
+ if (o.method !== "POST") return u(405, { allow: "POST" });
103
+ const e = new URLSearchParams(await o.text()).get("logout_token");
113
104
  if (e === null || e === "") return d("token/refused", "no `logout_token`", 400);
114
105
  try {
115
- return await i.logout(e), l(200);
116
- } catch (r) {
117
- return r instanceof w && r.code === "session/irrevocable" ? d(r.code, r.message, 501) : g(r);
106
+ return await i.logout(e), u(200);
107
+ } catch (t) {
108
+ return t instanceof w && t.code === "session/irrevocable" ? d(t.code, t.message, 501) : g(t);
118
109
  }
119
110
  }
120
111
  default:
121
112
  return new Response(null, { status: 404 });
122
113
  }
123
114
  };
124
- return { GET: o, POST: o };
115
+ return { GET: r, POST: r };
125
116
  }
126
117
  export {
127
- S as outage,
128
- J as routes
118
+ O as outage,
119
+ E as routes
129
120
  };
130
121
  //# sourceMappingURL=next-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { cookieValue } from \"./cookie-session\";\nimport { 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. */\nfunction jar(header: string | null) {\n return {\n get(name: string) {\n const value = cookieValue(header, name);\n return value === undefined ? undefined : { value };\n },\n };\n}\n\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({ url, headers: request.headers, cookies: jar(cookie) }),\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","jar","header","name","value","cookieValue","routes","instance","handle","request","bound","action","base","auth","redirectUri","problem","page","isSameSite","started","done","returnTo","ended","session","withTenant","renewed","token"],"mappings":";;;;AA6DA,MAAMA,IAAU,EAAE,gBAAgB,oBAAoB,iBAAiB,WAAA;AAQvE,SAASC,EAAMC,GAAsE;AACnF,QAAMC,IAAMD,EAAS,YAAY,GAAG;AACpC,SAAO,EAAE,QAAQA,EAAS,MAAMC,IAAM,CAAC,GAAG,MAAMA,KAAO,IAAI,KAAKD,EAAS,MAAM,GAAGC,CAAG,EAAA;AACvF;AAYA,SAASC,EAAWC,GAA0BC,GAAoC;AAChF,MAAI,EAAAD,MAAc,QAAQA,EAAU,WAAW;AAC/C,QAAI;AACF,YAAME,IAAS,IAAI,IAAIF,GAAWC,CAAM;AACxC,aAAOC,EAAO,WAAWD,IAASC,EAAO,OAAO;AAAA,IAClD,QAAQ;AACN;AAAA,IACF;AACF;AAUA,SAASC,EAASC,GAAaC,GAAsC;AACnE,QAAMC,IAAU,IAAI,QAAQ,EAAE,UAAUF,GAAK,iBAAiB,YAAY;AAC1E,aAAWG,KAAUF,EAAS,CAAAC,EAAQ,OAAO,cAAcC,CAAM;AACjE,SAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,SAAAD,GAAS;AACpD;AAGA,SAASE,EAAKC,GAAgBH,IAAkC,IAAc;AAC5E,SAAO,IAAI,SAAS,MAAM,EAAE,QAAAG,GAAQ,SAAS,EAAE,iBAAiB,YAAY,GAAGH,EAAA,GAAW;AAC5F;AAGO,SAASI,EAAOC,GAAyC;AAC9D,MAAIA,MAAS,gBAAgBA,MAAS,iBAAkB,QAAO;AAC/D,MAAIA,MAAS,kBAAmB,QAAO;AACvC,MAAIA,MAAS,sBAAuB,QAAO;AAE7C;AAQA,SAASC,EAAQC,GAA0B;AACzC,MAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,SAAOE,EAAMF,EAAM,MAAMA,EAAM,SAASH,EAAOG,EAAM,IAAI,KAAK,GAAG;AACnE;AAEA,SAASE,EAAMJ,GAAqBK,GAAiBP,GAAgBJ,IAA6B,CAAA,GAAc;AAC9G,QAAMC,IAAU,IAAI,QAAQX,CAAO;AACnC,aAAWY,KAAUF,EAAS,CAAAC,EAAQ,OAAO,cAAcC,CAAM;AACjE,SAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAOI,GAAM,SAAAK,GAAS,GAAG,EAAE,QAAAP,GAAQ,SAAAH,GAAS;AACnF;AAGA,SAASW,EAAIC,GAAuB;AAClC,SAAO;AAAA,IACL,IAAIC,GAAc;AAChB,YAAMC,IAAQC,EAAYH,GAAQC,CAAI;AACtC,aAAOC,MAAU,SAAY,SAAY,EAAE,OAAAA,EAAA;AAAA,IAC7C;AAAA,EAAA;AAEJ;AAEO,SAASE,EAAOC,GAA+C;AACpE,QAAMC,IAAS,OAAOC,MAAwC;AAC5D,UAAMC,IAAQ,MAAMH,EAAA,GACdnB,IAAM,IAAI,IAAIqB,EAAQ,GAAG,GACzB,EAAE,QAAAE,GAAQ,MAAAC,EAAA,IAAShC,EAAMQ,EAAI,QAAQ,GACrCG,IAASkB,EAAQ,QAAQ,IAAI,QAAQ,GACrCI,IAAOH,EAAM,OACbI,IAAcJ,EAAM,eAAe,GAAGtB,EAAI,MAAM,GAAGwB,CAAI,aAGvDG,IAAU,CAAClB,MAA6B;AAC5C,UAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,YAAMmB,IAAO,IAAI,IAAIN,EAAM,aAAatB,EAAI,MAAM;AAClD,aAAA4B,EAAK,aAAa,IAAI,QAAQnB,EAAM,IAAI,GACjCV,EAAS6B,EAAK,MAAM,EAAE;AAAA,IAC/B;AAGA,QAAI,EADcL,MAAW,cAAcA,MAAW,YAAYA,MAAW,yBAC3D,CAACM,EAAWR,CAAO,EAAG,QAAOjB,EAAK,GAAG;AAEvD,YAAQmB,GAAA;AAAA,MACN,KAAK;AACH,YAAI;AACF,gBAAMO,IAAU,MAAML,EAAK,MAAM;AAAA,YAC/B,aAAAC;AAAA,YACA,UAAU/B,EAAWK,EAAI,aAAa,IAAI,UAAU,GAAGA,EAAI,MAAM,KAAK;AAAA,YACtE,cAAcA,EAAI,aAAa,IAAI,cAAc,KAAK;AAAA,UAAA,CACvD;AACD,iBAAOD,EAAS+B,EAAQ,KAAKA,EAAQ,OAAO;AAAA,QAC9C,SAASrB,GAAO;AAId,iBAAOkB,EAAQlB,CAAK;AAAA,QACtB;AAAA,MAGF,KAAK;AACH,YAAI;AACF,gBAAMsB,IAAO,MAAMN,EAAK,SAAS,EAAE,KAAAzB,GAAK,QAAAG,GAAQ,aAAAuB,GAAa;AAC7D,iBAAO3B,EAASgC,EAAK,UAAUA,EAAK,OAAO;AAAA,QAC7C,SAAStB,GAAO;AACd,iBAAOkB,EAAQlB,CAAK;AAAA,QACtB;AAAA,MAGF,KAAK,WAAW;AACd,cAAMuB,IAAWrC,EAAWK,EAAI,aAAa,IAAI,UAAU,GAAGA,EAAI,MAAM;AACxE,YAAI;AACF,gBAAMiC,IAAQ,MAAMR,EAAK,IAAItB,GAAQ,EAAE,UAAA6B,GAAU;AACjD,iBAAOjC,EAASkC,EAAM,KAAKA,EAAM,OAAO;AAAA,QAC1C,SAASxB,GAAO;AACd,iBAAOkB,EAAQlB,CAAK;AAAA,QACtB;AAAA,MACF;AAAA,MAEA,KAAK,WAAW;AACd,YAAIyB;AACJ,YAAI;AACF,UAAAA,IAAU,MAAMT,EAAK,KAAKtB,CAAM,GAC5B+B,MAAY,SACdA,IAAUC;AAAA,YACRD;AAAA,YACA,MAAMZ,EAAM,OAAO,EAAE,KAAAtB,GAAK,SAASqB,EAAQ,SAAS,SAASR,EAAIV,CAAM,EAAA,CAAG;AAAA,UAAA;AAAA,QAGhF,SAASM,GAAO;AACd,iBAAOD,EAAQC,CAAK;AAAA,QACtB;AAIA,eAAIyB,MAAY,OAAa9B,EAAK,GAAG,IAC9B,IAAI,SAAS,KAAK,UAAU8B,CAAO,GAAG,EAAE,QAAQ,KAAK,SAAS3C,GAAS;AAAA,MAChF;AAAA,MAEA,KAAK,WAAW;AAId,YAAI8B,EAAQ,WAAW,OAAQ,QAAOjB,EAAK,KAAK,EAAE,OAAO,QAAQ;AACjE,YAAI;AACF,gBAAMgC,IAAU,MAAMX,EAAK,QAAQtB,CAAM;AAIzC,cAAIiC,EAAQ;AACV,mBAAOzB,EAAMyB,EAAQ,MAAM,qCAAqC,KAAKA,EAAQ,OAAO;AAEtF,gBAAMlC,IAAU,IAAI,QAAQX,CAAO;AACnC,qBAAWyB,KAASoB,EAAQ,QAAS,CAAAlC,EAAQ,OAAO,cAAcc,CAAK;AACvE,iBAAO,IAAI,SAAS,KAAK,UAAUoB,EAAQ,OAAO,GAAG,EAAE,QAAQ,KAAK,SAAAlC,GAAS;AAAA,QAC/E,SAASO,GAAO;AACd,iBAAOD,EAAQC,CAAK;AAAA,QACtB;AAAA,MACF;AAAA,MAEA,KAAK,sBAAsB;AACzB,YAAIY,EAAQ,WAAW,OAAQ,QAAOjB,EAAK,KAAK,EAAE,OAAO,QAAQ;AAEjE,cAAMiC,IAAQ,IAAI,gBAAgB,MAAMhB,EAAQ,MAAM,EAAE,IAAI,cAAc;AAC1E,YAAIgB,MAAU,QAAQA,MAAU,WAAW1B,EAAM,iBAAiB,qBAAqB,GAAG;AAC1F,YAAI;AACF,uBAAMc,EAAK,OAAOY,CAAK,GAChBjC,EAAK,GAAG;AAAA,QACjB,SAASK,GAAO;AACd,iBAAIA,aAAiBC,KAAaD,EAAM,SAAS,wBACxCE,EAAMF,EAAM,MAAMA,EAAM,SAAS,GAAG,IAEtCD,EAAQC,CAAK;AAAA,QACtB;AAAA,MACF;AAAA,MAEA;AACE,eAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK;AAAA,IAAA;AAAA,EAE/C;AAMA,SAAO,EAAE,KAAKW,GAAQ,MAAMA,EAAA;AAC9B;"}
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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "Kanzo authentication over Keycloak — a Backend For Frontend for Next (kanzoAuth), the claim vocabulary read into one Session, and the role evaluation that knows about organizations. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude auth from the generic vocabulary by name.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,12 +26,12 @@
26
26
  },
27
27
  "./package.json": "./package.json"
28
28
  },
29
- "//peers": "react is REQUIRED: the root barrel exports a provider and hooks, and two copies of React are two context trees. Everything else is optional and lives behind a subpath: openid-client and jose are the confidential client behind `./server`, and next (16 or later, whose proxy runs on Node and can therefore read and renew a session) is behind `./next`. The browser half, `bffAuth`, needs no engine at all. The public browser client and its oidc-client-ts peer were removed: RFC 10017 recommends the BFF for business applications and nothing consumed the other. Deliberately absent: tailwind-variants, because nothing here draws — which is also what keeps this package out of the appearance guards, whose corpus in packages/ui/src/guard-corpus.ts selects on exactly that dependency. The day this package grows a screen the dependency arrives with it and the guards pick it up unasked; no list anywhere needs editing.",
29
+ "//peers": "react is REQUIRED: the root barrel exports a provider and hooks, and two copies of React are two context trees. Everything else is optional and lives behind a subpath: openid-client and jose are the confidential client behind `./server`, and next (16, whose proxy runs on Node and can therefore read and renew a session) is behind `./next`. The browser half, `bffAuth`, needs no engine at all. The public browser client and its oidc-client-ts peer were removed: RFC 10017 recommends the BFF for business applications and nothing consumed the other. Deliberately absent: tailwind-variants, because nothing here draws — which is also what keeps this package out of the appearance guards, whose corpus in packages/ui/src/guard-corpus.ts selects on exactly that dependency. The day this package grows a screen the dependency arrives with it and the guards pick it up unasked; no list anywhere needs editing.",
30
30
  "peerDependencies": {
31
31
  "jose": "^6",
32
- "next": ">=16",
32
+ "next": "^16.0.0",
33
33
  "openid-client": "^6",
34
- "react": ">=19"
34
+ "react": "^19.0.0"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "jose": {