@kanzo-tech/auth 0.30.0 → 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. */
@@ -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,25 @@ 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
+ /**
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;
92
+ /** What a tenant resolver reads, from a request a route handler was given. */
93
+ export declare function tenantRequest(request: Request): TenantRequest;
76
94
  /** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */
77
95
  export declare const URL_HEADER = "x-kanzo-url";
78
96
  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;;;;;;;;;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,27 +1,60 @@
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 c } from "./server.js";
2
+ import { cookieValue as p } from "./cookie-session.js";
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) {
16
+ const r = t.headers.get("cookie");
17
+ return {
18
+ url: u(t),
19
+ headers: t.headers,
20
+ cookies: {
21
+ get(o) {
22
+ const e = p(r, o);
23
+ return e === void 0 ? void 0 : { value: e };
24
+ }
25
+ }
26
+ };
27
+ }
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),
32
+ target: (a) => {
33
+ const i = typeof e.target == "string" ? e.target : e.target(a);
34
+ return i === void 0 ? void 0 : new URL(i);
35
+ }
36
+ }, s = t.organization;
6
37
  return {
7
- party: p(t),
38
+ party: c(t),
8
39
  basePath: r,
9
- problemPage: i,
40
+ problemPage: o,
10
41
  redirectUri: t.redirectUri,
11
42
  renewWithin: t.renewWithin,
12
- api: e,
13
- open: [r, i, ...e === void 0 ? [] : [e.mount], ...t.public ?? []].map(
14
- a
43
+ api: n,
44
+ open: [r, o, ...n === void 0 ? [] : [n.mount], ...t.public ?? []].map(
45
+ d
15
46
  ),
16
- tenant: async (o) => n === void 0 ? void 0 : n(o)
47
+ tenant: async (a) => s === void 0 ? void 0 : s(a)
17
48
  };
18
49
  }
19
- function l(t, r) {
50
+ function g(t, r) {
20
51
  return r === void 0 ? t : { ...t, organization: r };
21
52
  }
22
53
  export {
23
- d as URL_HEADER,
24
- h as bind,
25
- l as withTenant
54
+ b as URL_HEADER,
55
+ u as addressedUrl,
56
+ U as bind,
57
+ w as tenantRequest,
58
+ g as withTenant
26
59
  };
27
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 { 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 `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-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,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,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 { addressedUrl as T, withTenant as P, tenantRequest as R } from "./next-bound.js";
2
+ import { isSameSite as S } 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 O(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 v(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, v(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
40
  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) => {
41
+ const r = async (o) => {
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) => {
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") && !S(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 = P(
89
80
  e,
90
- await a.tenant({ url: s, headers: t.headers, cookies: O(u) })
81
+ await a.tenant(R(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,
118
+ v as outage,
128
119
  J 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":"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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.0",
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",
@@ -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": {