@kanzo-tech/auth 0.30.1 → 0.30.2

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.
@@ -41,10 +41,10 @@ export interface KanzoAuthConfig extends RelyingPartyConfig {
41
41
  */
42
42
  readonly public?: readonly string[];
43
43
  /**
44
- * Override the callback URL. Absent, it is derived from the request: its origin, the path the
45
- * routes sit on, and `/callback`. Deriving it trusts `Host`, which is bounded — Keycloak refuses
46
- * a `redirect_uri` it has not registered — but a deployment behind a proxy that rewrites the
47
- * host says the URL out loud here.
44
+ * Override the callback URL. Absent, it is derived from the request: its `addressedUrl` origin,
45
+ * the path the routes sit on, and `/callback`. Deriving it trusts `Host` and the proxy's
46
+ * `X-Forwarded-*`, which is bounded — Keycloak refuses a `redirect_uri` it has not registered —
47
+ * but a deployment whose proxy sets none of them says the URL out loud here.
48
48
  */
49
49
  readonly redirectUri?: string;
50
50
  /** Seconds of access-token lifetime below which a request renews it. Default 60. */
@@ -78,6 +78,17 @@ export interface Bound {
78
78
  readonly open: readonly string[];
79
79
  tenant(request: TenantRequest): Promise<string | undefined>;
80
80
  }
81
+ /**
82
+ * The URL the client addressed. Next builds `request.url` from the address the server listens on
83
+ * rather than the one the client asked for — under `next dev` 16 a request for
84
+ * `acme.localhost:3000` reads `http://localhost:3000` — so a deployment that answers on several
85
+ * hosts would resolve every tenant, callback and redirect to the same one, and one behind a proxy
86
+ * that terminates TLS would hand Keycloak an `http` callback. The authority is the proxy's
87
+ * `X-Forwarded-Host`, else `Host` (RFC 9110 §7.2); the scheme is `X-Forwarded-Proto`, else the
88
+ * request's. Auth.js reads the same headers. Trusting them is bounded the way `redirectUri` says:
89
+ * Keycloak refuses a callback it has not registered.
90
+ */
91
+ export declare function addressedUrl(request: Request): URL;
81
92
  /** What a tenant resolver reads, from a request a route handler was given. */
82
93
  export declare function tenantRequest(request: Request): TenantRequest;
83
94
  /** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */
@@ -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;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
+ {"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;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,GAAG,CAYlD;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,10 +1,21 @@
1
- import { relyingParty as s } from "./server.js";
1
+ import { relyingParty as c } from "./server.js";
2
2
  import { cookieValue as p } from "./cookie-session.js";
3
- import { withoutTrailingSlashes as i } from "./issuer.js";
4
- function c(t) {
3
+ import { withoutTrailingSlashes as d } from "./issuer.js";
4
+ function u(t) {
5
+ const r = new URL(t.url), o = (a) => {
6
+ var i, h;
7
+ return ((h = (i = t.headers.get(a)) == null ? void 0 : i.split(",")[0]) == null ? void 0 : h.trim()) || void 0;
8
+ }, e = o("x-forwarded-host") ?? o("host"), n = o("x-forwarded-proto"), s = n === "http" || n === "https" ? `${n}:` : r.protocol;
9
+ try {
10
+ return new URL(`${r.pathname}${r.search}`, `${s}//${e ?? r.host}`);
11
+ } catch {
12
+ return r;
13
+ }
14
+ }
15
+ function w(t) {
5
16
  const r = t.headers.get("cookie");
6
17
  return {
7
- url: new URL(t.url),
18
+ url: u(t),
8
19
  headers: t.headers,
9
20
  cookies: {
10
21
  get(o) {
@@ -14,35 +25,36 @@ function c(t) {
14
25
  }
15
26
  };
16
27
  }
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),
28
+ const b = "x-kanzo-url";
29
+ function U(t) {
30
+ const r = d(t.basePath ?? "/api/auth"), o = t.problemPage ?? "/auth/problem", e = t.api, n = e === void 0 ? void 0 : {
31
+ mount: d(e.mount),
21
32
  target: (a) => {
22
- const d = typeof e.target == "string" ? e.target : e.target(a);
23
- return d === void 0 ? void 0 : new URL(d);
33
+ const i = typeof e.target == "string" ? e.target : e.target(a);
34
+ return i === void 0 ? void 0 : new URL(i);
24
35
  }
25
- }, u = t.organization;
36
+ }, s = t.organization;
26
37
  return {
27
- party: s(t),
38
+ party: c(t),
28
39
  basePath: r,
29
40
  problemPage: o,
30
41
  redirectUri: t.redirectUri,
31
42
  renewWithin: t.renewWithin,
32
43
  api: n,
33
44
  open: [r, o, ...n === void 0 ? [] : [n.mount], ...t.public ?? []].map(
34
- i
45
+ d
35
46
  ),
36
- tenant: async (a) => u === void 0 ? void 0 : u(a)
47
+ tenant: async (a) => s === void 0 ? void 0 : s(a)
37
48
  };
38
49
  }
39
- function w(t, r) {
50
+ function g(t, r) {
40
51
  return r === void 0 ? t : { ...t, organization: r };
41
52
  }
42
53
  export {
43
- v as URL_HEADER,
44
- b as bind,
45
- c as tenantRequest,
46
- w as withTenant
54
+ b as URL_HEADER,
55
+ u as addressedUrl,
56
+ U as bind,
57
+ w as tenantRequest,
58
+ g as withTenant
47
59
  };
48
60
  //# 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 { 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
+ {"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 `addressedUrl` origin,\n * the path the routes sit on, and `/callback`. Deriving it trusts `Host` and the proxy's\n * `X-Forwarded-*`, which is bounded — Keycloak refuses a `redirect_uri` it has not registered —\n * but a deployment whose proxy sets none of them 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/**\n * The URL the client addressed. Next builds `request.url` from the address the server listens on\n * rather than the one the client asked for — under `next dev` 16 a request for\n * `acme.localhost:3000` reads `http://localhost:3000` — so a deployment that answers on several\n * hosts would resolve every tenant, callback and redirect to the same one, and one behind a proxy\n * that terminates TLS would hand Keycloak an `http` callback. The authority is the proxy's\n * `X-Forwarded-Host`, else `Host` (RFC 9110 §7.2); the scheme is `X-Forwarded-Proto`, else the\n * request's. Auth.js reads the same headers. Trusting them is bounded the way `redirectUri` says:\n * Keycloak refuses a callback it has not registered.\n */\nexport function addressedUrl(request: Request): URL {\n const url = new URL(request.url);\n const first = (name: string) => request.headers.get(name)?.split(\",\")[0]?.trim() || undefined;\n const host = first(\"x-forwarded-host\") ?? first(\"host\");\n const proto = first(\"x-forwarded-proto\");\n const scheme = proto === \"http\" || proto === \"https\" ? `${proto}:` : url.protocol;\n // Rebuilt rather than assigned: `url.host = \"acme.example.test\"` keeps the listening port.\n try {\n return new URL(`${url.pathname}${url.search}`, `${scheme}//${host ?? url.host}`);\n } catch {\n return url;\n }\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: addressedUrl(request),\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":["addressedUrl","request","url","first","name","_b","_a","host","proto","scheme","tenantRequest","header","value","cookieValue","URL_HEADER","bind","config","basePath","withoutTrailingSlashes","problemPage","forward","api","organization","target","resolve","relyingParty","withTenant","session"],"mappings":";;;AA0FO,SAASA,EAAaC,GAAuB;AAClD,QAAMC,IAAM,IAAI,IAAID,EAAQ,GAAG,GACzBE,IAAQ,CAACC,MAAA;;AAAiB,aAAAC,KAAAC,IAAAL,EAAQ,QAAQ,IAAIG,CAAI,MAAxB,gBAAAE,EAA2B,MAAM,KAAK,OAAtC,gBAAAD,EAA0C,WAAU;AAAA,KAC9EE,IAAOJ,EAAM,kBAAkB,KAAKA,EAAM,MAAM,GAChDK,IAAQL,EAAM,mBAAmB,GACjCM,IAASD,MAAU,UAAUA,MAAU,UAAU,GAAGA,CAAK,MAAMN,EAAI;AAEzE,MAAI;AACF,WAAO,IAAI,IAAI,GAAGA,EAAI,QAAQ,GAAGA,EAAI,MAAM,IAAI,GAAGO,CAAM,KAAKF,KAAQL,EAAI,IAAI,EAAE;AAAA,EACjF,QAAQ;AACN,WAAOA;AAAA,EACT;AACF;AAGO,SAASQ,EAAcT,GAAiC;AAC7D,QAAMU,IAASV,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAO;AAAA,IACL,KAAKD,EAAaC,CAAO;AAAA,IACzB,SAASA,EAAQ;AAAA,IACjB,SAAS;AAAA,MACP,IAAIG,GAAc;AAChB,cAAMQ,IAAQC,EAAYF,GAAQP,CAAI;AACtC,eAAOQ,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,OAAOjB,MAAauB,MAAY,SAAY,SAAYA,EAAQvB,CAAO;AAAA,EAAA;AAEnF;AAGO,SAASyB,EAAWC,GAAkBL,GAA2C;AACtF,SAAOA,MAAiB,SAAYK,IAAU,EAAE,GAAGA,GAAS,cAAAL,EAAA;AAC9D;"}
@@ -1 +1 @@
1
- {"version":3,"file":"next-gate.d.ts","sourceRoot":"","sources":["../src/next-gate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAc,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AA6FtD,wBAAsB,IAAI,CACxB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,WAAW,GAClB,OAAO,CAAC,YAAY,CAAC,CAmDvB"}
1
+ {"version":3,"file":"next-gate.d.ts","sourceRoot":"","sources":["../src/next-gate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA4B,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AA6FpE,wBAAsB,IAAI,CACxB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,WAAW,GAClB,OAAO,CAAC,YAAY,CAAC,CAqDvB"}
package/dist/next-gate.js CHANGED
@@ -1,74 +1,74 @@
1
1
  import { NextResponse as h } from "next/server";
2
- import { URL_HEADER as m } from "./next-bound.js";
3
- import { outage as p } from "./next-routes.js";
4
- import { AuthError as k } from "./types.js";
5
- function w(t) {
2
+ import { addressedUrl as p, URL_HEADER as k } from "./next-bound.js";
3
+ import { outage as w } from "./next-routes.js";
4
+ import { AuthError as g } from "./types.js";
5
+ function b(t) {
6
6
  return t.slice(t.lastIndexOf("/") + 1).includes(".");
7
7
  }
8
- function g(t, r) {
9
- return r.some((a) => t === a || t.startsWith(`${a}/`));
8
+ function x(t, r) {
9
+ return r.some((i) => t === i || t.startsWith(`${i}/`));
10
10
  }
11
- function b(t) {
12
- const [r = "", ...a] = t.split(";").map((o) => o.trim()), c = r.indexOf("="), n = { name: r.slice(0, c), value: r.slice(c + 1) };
13
- for (const o of a) {
14
- const [l = "", i = ""] = o.split("=");
15
- switch (l.toLowerCase()) {
11
+ function y(t) {
12
+ const [r = "", ...i] = t.split(";").map((n) => n.trim()), d = r.indexOf("="), s = { name: r.slice(0, d), value: r.slice(d + 1) };
13
+ for (const n of i) {
14
+ const [a = "", c = ""] = n.split("=");
15
+ switch (a.toLowerCase()) {
16
16
  case "path":
17
- n.path = i;
17
+ s.path = c;
18
18
  break;
19
19
  case "httponly":
20
- n.httpOnly = !0;
20
+ s.httpOnly = !0;
21
21
  break;
22
22
  case "secure":
23
- n.secure = !0;
23
+ s.secure = !0;
24
24
  break;
25
25
  case "samesite":
26
- n.sameSite = i.toLowerCase();
26
+ s.sameSite = c.toLowerCase();
27
27
  break;
28
28
  case "max-age":
29
- n.maxAge = Number(i);
29
+ s.maxAge = Number(c);
30
30
  break;
31
31
  }
32
32
  }
33
- return n;
33
+ return s;
34
34
  }
35
- async function v(t, r, a) {
36
- const { pathname: c, search: n } = r.nextUrl, o = new Headers(r.headers);
37
- for (const e of [...o.keys()])
38
- e.startsWith("x-kanzo-") && o.delete(e);
39
- o.set(m, r.url), new Headers(a).forEach((e, s) => o.set(s, e));
40
- const l = () => h.next({ request: { headers: o } });
41
- if (w(c) || g(c, t.open)) return l();
42
- const i = r.headers.get("sec-fetch-mode"), u = i === null || i === "navigate";
43
- let f;
35
+ async function E(t, r, i) {
36
+ const { pathname: d, search: s } = r.nextUrl, n = p(r), a = new Headers(r.headers);
37
+ for (const e of [...a.keys()])
38
+ e.startsWith("x-kanzo-") && a.delete(e);
39
+ a.set(k, n.href), new Headers(i).forEach((e, o) => a.set(o, e));
40
+ const c = () => h.next({ request: { headers: a } });
41
+ if (b(d) || x(d, t.open)) return c();
42
+ const f = r.headers.get("sec-fetch-mode"), u = f === null || f === "navigate";
43
+ let l;
44
44
  try {
45
- f = await t.party.token(r.headers.get("cookie"), { renewWithin: t.renewWithin });
45
+ l = await t.party.token(r.headers.get("cookie"), { renewWithin: t.renewWithin });
46
46
  } catch (e) {
47
- if (!(e instanceof k)) throw e;
48
- if (e.code === "idp/unreachable" || e.code === "idp/silent") return l();
47
+ if (!(e instanceof g)) throw e;
48
+ if (e.code === "idp/unreachable" || e.code === "idp/silent") return c();
49
49
  if (!u)
50
50
  return new h(null, {
51
- status: p(e.code) ?? 500,
51
+ status: w(e.code) ?? 500,
52
52
  headers: { "cache-control": "no-store" }
53
53
  });
54
- const s = new URL(t.problemPage, r.url);
55
- return s.searchParams.set("code", e.code), h.redirect(s, 307);
54
+ const o = new URL(t.problemPage, n);
55
+ return o.searchParams.set("code", e.code), h.redirect(o, 307);
56
56
  }
57
- if (f.ended) {
57
+ if (l.ended) {
58
58
  let e;
59
59
  if (u) {
60
- const s = new URL(`${t.basePath}/signin`, r.url);
61
- s.searchParams.set("returnTo", `${c}${n}`), e = h.redirect(s, 307);
60
+ const o = new URL(`${t.basePath}/signin`, n);
61
+ o.searchParams.set("returnTo", `${d}${s}`), e = h.redirect(o, 307);
62
62
  } else
63
63
  e = new h(null, { status: 401, headers: { "cache-control": "no-store" } });
64
- for (const s of f.cookies) e.headers.append("set-cookie", s);
64
+ for (const o of l.cookies) e.headers.append("set-cookie", o);
65
65
  return e;
66
66
  }
67
- const d = l();
68
- for (const e of f.cookies) d.cookies.set(b(e));
69
- return d;
67
+ const m = c();
68
+ for (const e of l.cookies) m.cookies.set(y(e));
69
+ return m;
70
70
  }
71
71
  export {
72
- v as gate
72
+ E as gate
73
73
  };
74
74
  //# sourceMappingURL=next-gate.js.map
@@ -1 +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
+ {"version":3,"file":"next-gate.js","sources":["../src/next-gate.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\nimport { addressedUrl, 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 addressed = addressedUrl(request);\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, addressed.href);\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, addressed);\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`, addressed);\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","addressed","addressedUrl","forwarded","name","URL_HEADER","through","NextResponse","mode","navigation","held","error","AuthError","outage","problem","answer","signIn","response"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,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
+ {"version":3,"file":"next-routes.d.ts","sourceRoot":"","sources":["../src/next-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2C,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAa,KAAK,aAAa,EAAgB,MAAM,SAAS,CAAC;AA4DtE,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC3C;AA+CD,gFAAgF;AAChF,wBAAgB,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAK9D;AAmBD,uGAAuG;AACvG,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CA2HpE"}
@@ -1,8 +1,8 @@
1
- import { withTenant as T, tenantRequest as P } from "./next-bound.js";
2
- import { isSameSite as R } from "./same-site.js";
1
+ import { addressedUrl as T, withTenant as P, tenantRequest as R } from "./next-bound.js";
2
+ import { isSameSite as S } from "./same-site.js";
3
3
  import { AuthError as w } from "./types.js";
4
4
  const p = { "content-type": "application/json", "cache-control": "no-store" };
5
- function S(n) {
5
+ function O(n) {
6
6
  const r = n.lastIndexOf("/");
7
7
  return { action: n.slice(r + 1), base: r <= 0 ? "" : n.slice(0, r) };
8
8
  }
@@ -23,28 +23,28 @@ function f(n, r) {
23
23
  function u(n, r = {}) {
24
24
  return new Response(null, { status: n, headers: { "cache-control": "no-store", ...r } });
25
25
  }
26
- function O(n) {
26
+ function v(n) {
27
27
  if (n === "idp/silent" || n === "session/silent") return 504;
28
28
  if (n === "idp/unreachable") return 502;
29
29
  if (n === "session/unavailable") return 503;
30
30
  }
31
31
  function g(n) {
32
32
  if (!(n instanceof w)) throw n;
33
- return d(n.code, n.message, O(n.code) ?? 400);
33
+ return d(n.code, n.message, v(n.code) ?? 400);
34
34
  }
35
35
  function d(n, r, o, a = []) {
36
36
  const s = new Headers(p);
37
37
  for (const c of a) s.append("set-cookie", c);
38
38
  return new Response(JSON.stringify({ error: n, message: r }), { status: o, headers: s });
39
39
  }
40
- function E(n) {
40
+ function J(n) {
41
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) => {
42
+ const a = await n(), s = T(o), { action: c, base: b } = O(s.pathname), l = o.headers.get("cookie"), i = a.party, k = a.redirectUri ?? `${s.origin}${b}/callback`, h = (e) => {
43
43
  if (!(e instanceof w)) throw e;
44
44
  const t = new URL(a.problemPage, s.origin);
45
45
  return t.searchParams.set("code", e.code), f(t.href, []);
46
46
  };
47
- if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !R(o)) return u(403);
47
+ if (!(c === "callback" || c === "signin" || c === "backchannel-logout") && !S(o)) return u(403);
48
48
  switch (c) {
49
49
  case "signin":
50
50
  try {
@@ -76,9 +76,9 @@ function E(n) {
76
76
  case "session": {
77
77
  let e;
78
78
  try {
79
- e = await i.read(l), e !== null && (e = T(
79
+ e = await i.read(l), e !== null && (e = P(
80
80
  e,
81
- await a.tenant(P(o))
81
+ await a.tenant(R(o))
82
82
  ));
83
83
  } catch (t) {
84
84
  return g(t);
@@ -115,7 +115,7 @@ function E(n) {
115
115
  return { GET: r, POST: r };
116
116
  }
117
117
  export {
118
- O as outage,
119
- E as routes
118
+ v as outage,
119
+ J as routes
120
120
  };
121
121
  //# sourceMappingURL=next-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { tenantRequest, withTenant, type Bound } from \"./next-bound\";\nimport { isSameSite } from \"./same-site\";\nimport { AuthError, type AuthErrorCode, type Session } from \"./types\";\n\n/**\n * The six routes a Backend For Frontend needs, as one App Router catch-all: `kanzoAuth().routes`.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n * ```\n *\n * `relyingParty` already does the whole flow in strings — a URL and a `Cookie` header in, a URL and\n * `Set-Cookie` values out — so the only thing written here is the translation into `Request` and\n * `Response`, plus the two decisions that translation forces: what a route answers when there is\n * no session, and where it is willing to send a browser afterwards.\n *\n * **Nothing in this module imports `next`.** An App Router route handler is handed a standard\n * `Request` and may answer with a standard `Response`, so the framework's own types would buy\n * nothing and would make this half untestable without it. The door earns its subpath through\n * `next-auth.ts` and `next-gate.ts`, which genuinely cannot be written without `next`.\n *\n * ## The other end of the contract\n *\n * `bffAuth` in the root barrel is the browser half, and it is specific: it `GET`s\n * `${basePath}/session` and reads **401 as \"nobody is signed in\"**, not as a failure; it `POST`s\n * `${basePath}/refresh` when a request of its own comes back 401, and retries that request once if\n * the renewal worked, and signs in once if it was refused; it navigates to\n * `${basePath}/signin?returnTo=…&organization=…` and `${basePath}/signout?returnTo=…`. Those paths\n * and those status codes are the contract, and `next-routes.test.ts` drives a real `bffAuth`\n * against these handlers rather than trusting the two descriptions to agree.\n *\n * ## A failure on a navigation is a page, not a body\n *\n * `signin`, `callback` and `signout` are reached by the browser's address bar, so an `AuthError`\n * there answers with a redirect to the product's `problemPage`, the code in `?code=`, where the\n * product renders it in its own words. `session` and `refresh` are reached by `fetch`, and answer\n * with a status and `{ error, message }`.\n *\n * ## Back-channel logout\n *\n * `backchannel-logout` is where Keycloak posts a logout token when a session ends at the IdP — an\n * administrator's sign-out, a session that expired there, a sign-out from another application —\n * per OpenID Connect Back-Channel Logout 1.0. It answers 200 once the sessions it names are\n * dropped, 400 for a token that does not verify, and 501 when the store cannot end a session from\n * the server (`session/irrevocable`): the specification's answers, so Keycloak's admin console\n * reports the failure rather than believing a logout happened.\n *\n * ## Which routes a cross-site request may reach\n *\n * `callback` and `signin` must be reachable from anywhere — one *is* a navigation from the\n * identity provider, and the other is a link somebody is allowed to put on another page — and so\n * must `backchannel-logout`, which is a server's POST, not a page's, and is authenticated by the\n * signature on the token it carries. The other three are not: `signout` reached cross-site is a\n * logout anyone can cause, `refresh` is a rotation anyone can cause, and `session` is a person's\n * identity read from a page that is not ours. `same-site.ts` carries the check and the reason the\n * package rather than the product owes it.\n */\n\n/** The session endpoint answers about a person; no cache may ever hold that answer. */\nconst PRIVATE = { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } as const;\n\nexport interface RouteHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n}\n\n/** The last path segment, and everything before it. `/api/auth/signin` → `signin`, `/api/auth`. */\nfunction split(pathname: string): { readonly action: string; readonly base: string } {\n const cut = pathname.lastIndexOf(\"/\");\n return { action: pathname.slice(cut + 1), base: cut <= 0 ? \"\" : pathname.slice(0, cut) };\n}\n\n/**\n * A `returnTo` confined to this origin, or `undefined`.\n *\n * Without this the sign-in route is an open redirect: `?returnTo=https://evil.test` is carried\n * through the whole flow and spent on a browser that has just proved who it is, which is the most\n * valuable moment to hijack. The check is here, where the string arrives from a query parameter,\n * and deliberately *not* repeated on the callback — what comes back out at the callback was\n * sealed into a cookie by us, so re-checking it would only make it unclear which check is the\n * real one.\n */\nfunction sameOrigin(candidate: string | null, origin: string): string | undefined {\n if (candidate === null || candidate.length === 0) return undefined;\n try {\n const target = new URL(candidate, origin);\n return target.origin === origin ? target.href : undefined;\n } catch {\n return undefined; // not a URL at all: dropped like a foreign one, and the sign-in goes to `/`\n }\n}\n\n/**\n * A redirect that can carry cookies.\n *\n * `Response.redirect()` cannot: its headers are immutable, and a callback that cannot set a cookie\n * is a sign-in that never completes. And `append`, never `set` — the callback answers with **two**\n * `Set-Cookie` values, the session it just issued and the transaction it just spent, and `set`\n * would silently keep only the last of them.\n */\nfunction redirect(url: string, cookies: readonly string[]): Response {\n const headers = new Headers({ location: url, \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status: 302, headers });\n}\n\n/** No body, never cached: a status is the whole answer. */\nfunction bare(status: number, headers: Record<string, string> = {}): Response {\n return new Response(null, { status, headers: { \"cache-control\": \"no-store\", ...headers } });\n}\n\n/** The status for a party that did not answer, or `undefined` for a refusal. */\nexport function outage(code: AuthErrorCode): number | undefined {\n if (code === \"idp/silent\" || code === \"session/silent\") return 504;\n if (code === \"idp/unreachable\") return 502;\n if (code === \"session/unavailable\") return 503;\n return undefined;\n}\n\n/**\n * An `AuthError` as a status and a code a product can route on.\n *\n * Anything else is rethrown: a failure this module has no reading of is Next's to report, and\n * flattening it into a 400 here would hide a misconfiguration behind a message about credentials.\n */\nfunction failure(error: unknown): Response {\n if (!(error instanceof AuthError)) throw error;\n return coded(error.code, error.message, outage(error.code) ?? 400);\n}\n\nfunction coded(code: AuthErrorCode, message: string, status: number, cookies: readonly string[] = []): Response {\n const headers = new Headers(PRIVATE);\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(JSON.stringify({ error: code, message }), { status, headers });\n}\n\n/** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */\nexport function routes(instance: () => Promise<Bound>): RouteHandlers {\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n const url = new URL(request.url);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = bound.party;\n const redirectUri = bound.redirectUri ?? `${url.origin}${base}/callback`;\n\n /** An `AuthError` on a navigation, as the product's page; anything else rethrown, as in `failure`. */\n const problem = (error: unknown): Response => {\n if (!(error instanceof AuthError)) throw error;\n const page = new URL(bound.problemPage, url.origin);\n page.searchParams.set(\"code\", error.code);\n return redirect(page.href, []);\n };\n\n const crossable = action === \"callback\" || action === \"signin\" || action === \"backchannel-logout\";\n if (!crossable && !isSameSite(request)) return bare(403);\n\n switch (action) {\n case \"signin\": {\n try {\n const started = await auth.begin({\n redirectUri,\n returnTo: sameOrigin(url.searchParams.get(\"returnTo\"), url.origin) ?? \"/\",\n organization: url.searchParams.get(\"organization\") ?? undefined,\n });\n return redirect(started.url, started.cookies);\n } catch (error) {\n // `organization` arrives from a query parameter and `begin` refuses one that is not an\n // alias, because a space in it injects scopes; and the IdP may be down. Without this\n // `catch` either is a 500 on a link.\n return problem(error);\n }\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie, redirectUri });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"signout\": {\n const returnTo = sameOrigin(url.searchParams.get(\"returnTo\"), url.origin);\n try {\n const ended = await auth.end(cookie, { returnTo });\n return redirect(ended.url, ended.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"session\": {\n let session: Session | null;\n try {\n session = await auth.read(cookie);\n if (session !== null) {\n session = withTenant(\n session,\n await bound.tenant(tenantRequest(request)),\n );\n }\n } catch (error) {\n return failure(error);\n }\n // 401 and no body at all. `bffAuth` reads this status as \"nobody is signed in\" and stops;\n // a body would be parsed by something eventually, and an error shape arriving where a\n // `Session` is expected is the failure `readSession` exists to refuse.\n if (session === null) return bare(401);\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n case \"refresh\": {\n // `POST` only. The other routes are reached by navigation, where the verb is the browser's\n // to choose; this one spends a refresh token, and a `GET` that spends something is a link,\n // a prefetch and a preview pane away from spending it.\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n try {\n const renewed = await auth.refresh(cookie);\n // An ended session is a 401 and the cookie cleared in the same answer, so the browser\n // stops presenting a ticket whose row is gone. An outage is a 5xx, which `bffAuth` does\n // not read as an ended session.\n if (renewed.ended) {\n return coded(renewed.code, \"there is no session left to renew\", 401, renewed.cookies);\n }\n const headers = new Headers(PRIVATE);\n for (const value of renewed.cookies) headers.append(\"set-cookie\", value);\n return new Response(JSON.stringify(renewed.session), { status: 200, headers });\n } catch (error) {\n return failure(error);\n }\n }\n\n case \"backchannel-logout\": {\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n // §2.5: `application/x-www-form-urlencoded`, one parameter.\n const token = new URLSearchParams(await request.text()).get(\"logout_token\");\n if (token === null || token === \"\") return coded(\"token/refused\", \"no `logout_token`\", 400);\n try {\n await auth.logout(token);\n return bare(200);\n } catch (error) {\n if (error instanceof AuthError && error.code === \"session/irrevocable\") {\n return coded(error.code, error.message, 501);\n }\n return failure(error);\n }\n }\n\n default:\n return new Response(null, { status: 404 });\n }\n };\n\n // One handler behind both verbs. Every route here is reached by navigation or by `fetch`, and\n // which verb a product uses for sign-out — a link or a form — is its choice, not ours to\n // constrain with a second table that could drift from this one. `refresh` and\n // `backchannel-logout` are the exceptions and check their own method.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","bare","status","outage","code","failure","error","AuthError","coded","message","routes","instance","handle","request","bound","action","base","auth","redirectUri","problem","page","isSameSite","started","done","returnTo","ended","session","withTenant","tenantRequest","renewed","value","token"],"mappings":"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"}
1
+ {"version":3,"file":"next-routes.js","sources":["../src/next-routes.ts"],"sourcesContent":["import { addressedUrl, tenantRequest, withTenant, type Bound } from \"./next-bound\";\nimport { isSameSite } from \"./same-site\";\nimport { AuthError, type AuthErrorCode, type Session } from \"./types\";\n\n/**\n * The six routes a Backend For Frontend needs, as one App Router catch-all: `kanzoAuth().routes`.\n *\n * ```ts\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n * ```\n *\n * `relyingParty` already does the whole flow in strings — a URL and a `Cookie` header in, a URL and\n * `Set-Cookie` values out — so the only thing written here is the translation into `Request` and\n * `Response`, plus the two decisions that translation forces: what a route answers when there is\n * no session, and where it is willing to send a browser afterwards.\n *\n * **Nothing in this module imports `next`.** An App Router route handler is handed a standard\n * `Request` and may answer with a standard `Response`, so the framework's own types would buy\n * nothing and would make this half untestable without it. The door earns its subpath through\n * `next-auth.ts` and `next-gate.ts`, which genuinely cannot be written without `next`.\n *\n * ## The other end of the contract\n *\n * `bffAuth` in the root barrel is the browser half, and it is specific: it `GET`s\n * `${basePath}/session` and reads **401 as \"nobody is signed in\"**, not as a failure; it `POST`s\n * `${basePath}/refresh` when a request of its own comes back 401, and retries that request once if\n * the renewal worked, and signs in once if it was refused; it navigates to\n * `${basePath}/signin?returnTo=…&organization=…` and `${basePath}/signout?returnTo=…`. Those paths\n * and those status codes are the contract, and `next-routes.test.ts` drives a real `bffAuth`\n * against these handlers rather than trusting the two descriptions to agree.\n *\n * ## A failure on a navigation is a page, not a body\n *\n * `signin`, `callback` and `signout` are reached by the browser's address bar, so an `AuthError`\n * there answers with a redirect to the product's `problemPage`, the code in `?code=`, where the\n * product renders it in its own words. `session` and `refresh` are reached by `fetch`, and answer\n * with a status and `{ error, message }`.\n *\n * ## Back-channel logout\n *\n * `backchannel-logout` is where Keycloak posts a logout token when a session ends at the IdP — an\n * administrator's sign-out, a session that expired there, a sign-out from another application —\n * per OpenID Connect Back-Channel Logout 1.0. It answers 200 once the sessions it names are\n * dropped, 400 for a token that does not verify, and 501 when the store cannot end a session from\n * the server (`session/irrevocable`): the specification's answers, so Keycloak's admin console\n * reports the failure rather than believing a logout happened.\n *\n * ## Which routes a cross-site request may reach\n *\n * `callback` and `signin` must be reachable from anywhere — one *is* a navigation from the\n * identity provider, and the other is a link somebody is allowed to put on another page — and so\n * must `backchannel-logout`, which is a server's POST, not a page's, and is authenticated by the\n * signature on the token it carries. The other three are not: `signout` reached cross-site is a\n * logout anyone can cause, `refresh` is a rotation anyone can cause, and `session` is a person's\n * identity read from a page that is not ours. `same-site.ts` carries the check and the reason the\n * package rather than the product owes it.\n */\n\n/** The session endpoint answers about a person; no cache may ever hold that answer. */\nconst PRIVATE = { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } as const;\n\nexport interface RouteHandlers {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n}\n\n/** The last path segment, and everything before it. `/api/auth/signin` → `signin`, `/api/auth`. */\nfunction split(pathname: string): { readonly action: string; readonly base: string } {\n const cut = pathname.lastIndexOf(\"/\");\n return { action: pathname.slice(cut + 1), base: cut <= 0 ? \"\" : pathname.slice(0, cut) };\n}\n\n/**\n * A `returnTo` confined to this origin, or `undefined`.\n *\n * Without this the sign-in route is an open redirect: `?returnTo=https://evil.test` is carried\n * through the whole flow and spent on a browser that has just proved who it is, which is the most\n * valuable moment to hijack. The check is here, where the string arrives from a query parameter,\n * and deliberately *not* repeated on the callback — what comes back out at the callback was\n * sealed into a cookie by us, so re-checking it would only make it unclear which check is the\n * real one.\n */\nfunction sameOrigin(candidate: string | null, origin: string): string | undefined {\n if (candidate === null || candidate.length === 0) return undefined;\n try {\n const target = new URL(candidate, origin);\n return target.origin === origin ? target.href : undefined;\n } catch {\n return undefined; // not a URL at all: dropped like a foreign one, and the sign-in goes to `/`\n }\n}\n\n/**\n * A redirect that can carry cookies.\n *\n * `Response.redirect()` cannot: its headers are immutable, and a callback that cannot set a cookie\n * is a sign-in that never completes. And `append`, never `set` — the callback answers with **two**\n * `Set-Cookie` values, the session it just issued and the transaction it just spent, and `set`\n * would silently keep only the last of them.\n */\nfunction redirect(url: string, cookies: readonly string[]): Response {\n const headers = new Headers({ location: url, \"cache-control\": \"no-store\" });\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(null, { status: 302, headers });\n}\n\n/** No body, never cached: a status is the whole answer. */\nfunction bare(status: number, headers: Record<string, string> = {}): Response {\n return new Response(null, { status, headers: { \"cache-control\": \"no-store\", ...headers } });\n}\n\n/** The status for a party that did not answer, or `undefined` for a refusal. */\nexport function outage(code: AuthErrorCode): number | undefined {\n if (code === \"idp/silent\" || code === \"session/silent\") return 504;\n if (code === \"idp/unreachable\") return 502;\n if (code === \"session/unavailable\") return 503;\n return undefined;\n}\n\n/**\n * An `AuthError` as a status and a code a product can route on.\n *\n * Anything else is rethrown: a failure this module has no reading of is Next's to report, and\n * flattening it into a 400 here would hide a misconfiguration behind a message about credentials.\n */\nfunction failure(error: unknown): Response {\n if (!(error instanceof AuthError)) throw error;\n return coded(error.code, error.message, outage(error.code) ?? 400);\n}\n\nfunction coded(code: AuthErrorCode, message: string, status: number, cookies: readonly string[] = []): Response {\n const headers = new Headers(PRIVATE);\n for (const cookie of cookies) headers.append(\"set-cookie\", cookie);\n return new Response(JSON.stringify({ error: code, message }), { status, headers });\n}\n\n/** A request's cookies as a tenant resolver reads them, from the `Cookie` header a route is handed. */\nexport function routes(instance: () => Promise<Bound>): RouteHandlers {\n const handle = async (request: Request): Promise<Response> => {\n const bound = await instance();\n const url = addressedUrl(request);\n const { action, base } = split(url.pathname);\n const cookie = request.headers.get(\"cookie\");\n const auth = bound.party;\n const redirectUri = bound.redirectUri ?? `${url.origin}${base}/callback`;\n\n /** An `AuthError` on a navigation, as the product's page; anything else rethrown, as in `failure`. */\n const problem = (error: unknown): Response => {\n if (!(error instanceof AuthError)) throw error;\n const page = new URL(bound.problemPage, url.origin);\n page.searchParams.set(\"code\", error.code);\n return redirect(page.href, []);\n };\n\n const crossable = action === \"callback\" || action === \"signin\" || action === \"backchannel-logout\";\n if (!crossable && !isSameSite(request)) return bare(403);\n\n switch (action) {\n case \"signin\": {\n try {\n const started = await auth.begin({\n redirectUri,\n returnTo: sameOrigin(url.searchParams.get(\"returnTo\"), url.origin) ?? \"/\",\n organization: url.searchParams.get(\"organization\") ?? undefined,\n });\n return redirect(started.url, started.cookies);\n } catch (error) {\n // `organization` arrives from a query parameter and `begin` refuses one that is not an\n // alias, because a space in it injects scopes; and the IdP may be down. Without this\n // `catch` either is a 500 on a link.\n return problem(error);\n }\n }\n\n case \"callback\": {\n try {\n const done = await auth.complete({ url, cookie, redirectUri });\n return redirect(done.returnTo, done.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"signout\": {\n const returnTo = sameOrigin(url.searchParams.get(\"returnTo\"), url.origin);\n try {\n const ended = await auth.end(cookie, { returnTo });\n return redirect(ended.url, ended.cookies);\n } catch (error) {\n return problem(error);\n }\n }\n\n case \"session\": {\n let session: Session | null;\n try {\n session = await auth.read(cookie);\n if (session !== null) {\n session = withTenant(\n session,\n await bound.tenant(tenantRequest(request)),\n );\n }\n } catch (error) {\n return failure(error);\n }\n // 401 and no body at all. `bffAuth` reads this status as \"nobody is signed in\" and stops;\n // a body would be parsed by something eventually, and an error shape arriving where a\n // `Session` is expected is the failure `readSession` exists to refuse.\n if (session === null) return bare(401);\n return new Response(JSON.stringify(session), { status: 200, headers: PRIVATE });\n }\n\n case \"refresh\": {\n // `POST` only. The other routes are reached by navigation, where the verb is the browser's\n // to choose; this one spends a refresh token, and a `GET` that spends something is a link,\n // a prefetch and a preview pane away from spending it.\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n try {\n const renewed = await auth.refresh(cookie);\n // An ended session is a 401 and the cookie cleared in the same answer, so the browser\n // stops presenting a ticket whose row is gone. An outage is a 5xx, which `bffAuth` does\n // not read as an ended session.\n if (renewed.ended) {\n return coded(renewed.code, \"there is no session left to renew\", 401, renewed.cookies);\n }\n const headers = new Headers(PRIVATE);\n for (const value of renewed.cookies) headers.append(\"set-cookie\", value);\n return new Response(JSON.stringify(renewed.session), { status: 200, headers });\n } catch (error) {\n return failure(error);\n }\n }\n\n case \"backchannel-logout\": {\n if (request.method !== \"POST\") return bare(405, { allow: \"POST\" });\n // §2.5: `application/x-www-form-urlencoded`, one parameter.\n const token = new URLSearchParams(await request.text()).get(\"logout_token\");\n if (token === null || token === \"\") return coded(\"token/refused\", \"no `logout_token`\", 400);\n try {\n await auth.logout(token);\n return bare(200);\n } catch (error) {\n if (error instanceof AuthError && error.code === \"session/irrevocable\") {\n return coded(error.code, error.message, 501);\n }\n return failure(error);\n }\n }\n\n default:\n return new Response(null, { status: 404 });\n }\n };\n\n // One handler behind both verbs. Every route here is reached by navigation or by `fetch`, and\n // which verb a product uses for sign-out — a link or a form — is its choice, not ours to\n // constrain with a second table that could drift from this one. `refresh` and\n // `backchannel-logout` are the exceptions and check their own method.\n return { GET: handle, POST: handle };\n}\n"],"names":["PRIVATE","split","pathname","cut","sameOrigin","candidate","origin","target","redirect","url","cookies","headers","cookie","bare","status","outage","code","failure","error","AuthError","coded","message","routes","instance","handle","request","bound","addressedUrl","action","base","auth","redirectUri","problem","page","isSameSite","started","done","returnTo","ended","session","withTenant","tenantRequest","renewed","value","token"],"mappings":"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"}
@@ -1,29 +1,2 @@
1
- /**
2
- * Did this request come from us?
3
- *
4
- * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the
5
- * cookie on the top-level navigation *back* from the identity provider, so the callback arrives
6
- * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that
7
- * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and
8
- * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**
9
- * `<img src="https://app.example.test/api/auth/signout">` on any page anywhere signs the person
10
- * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.
11
- *
12
- * So the compensation belongs here rather than in every product: the package that chose `Lax` is
13
- * the one that owes the check for what `Lax` lets through.
14
- *
15
- * ## Fetch Metadata first, `Origin` second, and then allow
16
- *
17
- * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one
18
- * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it
19
- * is absent the request came from something that is not a browser — curl, a server, a crawler — or
20
- * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to
21
- * every unsafe request and which the same script cannot forge either.
22
- *
23
- * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a
24
- * request carrying no evidence of where it came from also carries no evidence that it came from a
25
- * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server
26
- * call and every health probe, which is a real outage traded for no real attacker.
27
- */
28
1
  export declare function isSameSite(request: Request): boolean;
29
2
  //# sourceMappingURL=same-site.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"same-site.d.ts","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAaH,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAQpD"}
1
+ {"version":3,"file":"same-site.d.ts","sourceRoot":"","sources":["../src/same-site.ts"],"names":[],"mappings":"AAyCA,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAQpD"}
package/dist/same-site.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { addressedUrl as r } from "./next-bound.js";
1
2
  const t = /* @__PURE__ */ new Set(["same-origin", "none"]);
2
- function r(e) {
3
+ function s(e) {
3
4
  const n = e.headers.get("sec-fetch-site");
4
5
  if (n !== null) return t.has(n);
5
6
  const i = e.headers.get("origin");
6
- return i !== null ? i === new URL(e.url).origin : !0;
7
+ return i !== null ? i === r(e).origin : !0;
7
8
  }
8
9
  export {
9
- r as isSameSite
10
+ s as isSameSite
10
11
  };
11
12
  //# sourceMappingURL=same-site.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"same-site.js","sources":["../src/same-site.ts"],"sourcesContent":["/**\n * Did this request come from us?\n *\n * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the\n * cookie on the top-level navigation *back* from the identity provider, so the callback arrives\n * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that\n * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and\n * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**\n * `<img src=\"https://app.example.test/api/auth/signout\">` on any page anywhere signs the person\n * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.\n *\n * So the compensation belongs here rather than in every product: the package that chose `Lax` is\n * the one that owes the check for what `Lax` lets through.\n *\n * ## Fetch Metadata first, `Origin` second, and then allow\n *\n * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one\n * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it\n * is absent the request came from something that is not a browser — curl, a server, a crawler — or\n * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to\n * every unsafe request and which the same script cannot forge either.\n *\n * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a\n * request carrying no evidence of where it came from also carries no evidence that it came from a\n * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server\n * call and every health probe, which is a real outage traded for no real attacker.\n */\n\n/**\n * `same-origin` and `none` pass; `same-site` and `cross-site` do not.\n *\n * `none` is a person typing the URL or following a bookmark — user-initiated, which is the thing\n * being protected, not the thing being refused. `same-site` is a sibling subdomain, and it is\n * refused deliberately: the cookie is `__Host-` and therefore host-only, but host-only describes\n * where the cookie *lives*, not who may cause a request to it. Anything that can serve\n * `docs.example.test` could otherwise sign every reader of it out of `app.example.test`.\n */\nconst TRUSTED_SITES = new Set([\"same-origin\", \"none\"]);\n\nexport function isSameSite(request: Request): boolean {\n const site = request.headers.get(\"sec-fetch-site\");\n if (site !== null) return TRUSTED_SITES.has(site);\n\n const origin = request.headers.get(\"origin\");\n if (origin !== null) return origin === new URL(request.url).origin;\n\n return true;\n}\n"],"names":["TRUSTED_SITES","isSameSite","request","site","origin"],"mappings":"AAqCA,MAAMA,IAAgB,oBAAI,IAAI,CAAC,eAAe,MAAM,CAAC;AAE9C,SAASC,EAAWC,GAA2B;AACpD,QAAMC,IAAOD,EAAQ,QAAQ,IAAI,gBAAgB;AACjD,MAAIC,MAAS,KAAM,QAAOH,EAAc,IAAIG,CAAI;AAEhD,QAAMC,IAASF,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAIE,MAAW,OAAaA,MAAW,IAAI,IAAIF,EAAQ,GAAG,EAAE,SAErD;AACT;"}
1
+ {"version":3,"file":"same-site.js","sources":["../src/same-site.ts"],"sourcesContent":["import { addressedUrl } from \"./next-bound\";\n\n/**\n * Did this request come from us?\n *\n * The session cookie is `SameSite=Lax`, and that is not a preference: `Strict` withholds the\n * cookie on the top-level navigation *back* from the identity provider, so the callback arrives\n * without the transaction it needs and every sign-in fails. `cookie-session.ts` carries that\n * argument in full. **`Lax` is therefore a decision this package made on a consumer's behalf, and\n * what it costs is exactly one thing: a cross-site top-level navigation still sends the cookie.**\n * `<img src=\"https://app.example.test/api/auth/signout\">` on any page anywhere signs the person\n * out; `…/refresh` fired the same way spends a refresh token on somebody else's schedule.\n *\n * So the compensation belongs here rather than in every product: the package that chose `Lax` is\n * the one that owes the check for what `Lax` lets through.\n *\n * ## Fetch Metadata first, `Origin` second, and then allow\n *\n * `Sec-Fetch-Site` is set by the browser and cannot be set by script, which makes it the one\n * header here that is worth trusting. Every browser that has shipped since 2023 sends it. When it\n * is absent the request came from something that is not a browser — curl, a server, a crawler — or\n * from a browser old enough not to have it, and the fallback is `Origin`, which browsers attach to\n * every unsafe request and which the same script cannot forge either.\n *\n * With neither, this answers `true`. That is the honest end of the ladder rather than a hole: a\n * request carrying no evidence of where it came from also carries no evidence that it came from a\n * *page*, and the attack being stopped is a page. Refusing there would refuse every server-to-server\n * call and every health probe, which is a real outage traded for no real attacker.\n */\n\n/**\n * `same-origin` and `none` pass; `same-site` and `cross-site` do not.\n *\n * `none` is a person typing the URL or following a bookmark — user-initiated, which is the thing\n * being protected, not the thing being refused. `same-site` is a sibling subdomain, and it is\n * refused deliberately: the cookie is `__Host-` and therefore host-only, but host-only describes\n * where the cookie *lives*, not who may cause a request to it. Anything that can serve\n * `docs.example.test` could otherwise sign every reader of it out of `app.example.test`.\n */\nconst TRUSTED_SITES = new Set([\"same-origin\", \"none\"]);\n\nexport function isSameSite(request: Request): boolean {\n const site = request.headers.get(\"sec-fetch-site\");\n if (site !== null) return TRUSTED_SITES.has(site);\n\n const origin = request.headers.get(\"origin\");\n if (origin !== null) return origin === addressedUrl(request).origin;\n\n return true;\n}\n"],"names":["TRUSTED_SITES","isSameSite","request","site","origin","addressedUrl"],"mappings":";AAuCA,MAAMA,IAAgB,oBAAI,IAAI,CAAC,eAAe,MAAM,CAAC;AAE9C,SAASC,EAAWC,GAA2B;AACpD,QAAMC,IAAOD,EAAQ,QAAQ,IAAI,gBAAgB;AACjD,MAAIC,MAAS,KAAM,QAAOH,EAAc,IAAIG,CAAI;AAEhD,QAAMC,IAASF,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAIE,MAAW,OAAaA,MAAWC,EAAaH,CAAO,EAAE,SAEtD;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
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",