@kanzo-tech/auth 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,7 @@ export interface KanzoAuth {
53
53
  }): Promise<NextResponse>;
54
54
  /** `signin`, `callback`, `signout`, `session`, `refresh` and `backchannel-logout`, for `[...auth]/route.ts`. */
55
55
  readonly routes: RouteHandlers;
56
- /** Every verb a route file exports, forwarding to `api.target`. 404 without `api`. */
56
+ /** Every verb a route file exports, forwarding each mount of `apis` to its `target`. 404 without `apis`. */
57
57
  readonly api: ApiHandlers;
58
58
  /** The session this request carries, read once per request, or `null`. */
59
59
  session(): Promise<Session | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"next-auth.d.ts","sourceRoot":"","sources":["../src/next-auth.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,EAA4C,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAE9F,OAAO,EAAW,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,SAAS;IACxB;;;;;;;OAOG;IACH,KAAK,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACjG,gHAAgH;IAChH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,sFAAsF;IACtF,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,0EAA0E;IAC1E,OAAO,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACnC,0EAA0E;IAC1E,OAAO,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACjE;AAmBD,wBAAgB,SAAS,CACvB,MAAM,EAAE,eAAe,GAAG,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC,GAC3E,SAAS,CAyCX"}
1
+ {"version":3,"file":"next-auth.d.ts","sourceRoot":"","sources":["../src/next-auth.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,EAA4C,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAE9F,OAAO,EAAW,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,SAAS;IACxB;;;;;;;OAOG;IACH,KAAK,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACjG,gHAAgH;IAChH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,4GAA4G;IAC5G,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,0EAA0E;IAC1E,OAAO,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACnC,0EAA0E;IAC1E,OAAO,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACjE;AAmBD,wBAAgB,SAAS,CACvB,MAAM,EAAE,eAAe,GAAG,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC,GAC3E,SAAS,CAyCX"}
@@ -1 +1 @@
1
- {"version":3,"file":"next-auth.js","sources":["../src/next-auth.ts"],"sourcesContent":["import { cookies, headers } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport type { NextRequest, NextResponse } from \"next/server\";\nimport { cache } from \"react\";\nimport { bind, URL_HEADER, withTenant, type Bound, type KanzoAuthConfig } from \"./next-bound\";\nimport { gate } from \"./next-gate\";\nimport { forward, type ApiHandlers } from \"./next-proxy\";\nimport { routes, type RouteHandlers } from \"./next-routes\";\nimport { singleFlight } from \"./single-flight\";\nimport type { Session } from \"./types\";\n\nexport type { KanzoAuthConfig } from \"./next-bound\";\n\n/**\n * `kanzoAuth` — the whole Backend For Frontend as one object: a proxy that is the session\n * authority, the auth routes, the resource-server forwarder and the session a server component\n * reads.\n *\n * ```ts\n * // lib/auth.ts\n * export const auth = kanzoAuth(async () => ({ issuer, clientId, clientSecret, secret, store }));\n *\n * // proxy.ts\n * export const proxy = (request: NextRequest) => auth.proxy(request);\n *\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n *\n * // any server component\n * const session = await auth.session({ required: true });\n * ```\n *\n * ## One object, because one relying party\n *\n * Auth0's v4 SDK is one `Auth0Client` for the same reason. Every piece here talks to the same\n * issuer with the same secret and the same store, and a renewal in the proxy must be the renewal\n * the forwarder joins. Five factories each holding their own relying party meant five discovery\n * caches and five configs that had to agree by hand; one instance means they cannot disagree.\n *\n * ## Bound on first request, not at import\n *\n * `next build` imports every route module with no secrets in the environment, so the config is a\n * function — possibly async, for a secret read from a file or a vault — called the first time a\n * request needs it. The built instance is remembered; a failure is not, so a deployment that\n * started before its IdP or its secret was ready recovers on the next request instead of\n * remembering the outage. `routes` and `api` are plain handler objects that exist at import, which\n * is what a route file's `export const { GET, POST } = …` needs.\n */\n\nexport interface KanzoAuth {\n /**\n * Next's `proxy.ts`: the session authority for every page.\n *\n * It renews an access token that is about to expire, ends a session the IdP refused to renew,\n * and sends a browser with no live session to sign in carrying the URL it asked for — or, for a\n * request that is not a navigation, answers 401. `headers` are added to the request the page\n * sees, for a CSP nonce.\n */\n proxy(request: NextRequest, options?: { readonly headers?: HeadersInit }): Promise<NextResponse>;\n /** `signin`, `callback`, `signout`, `session`, `refresh` and `backchannel-logout`, for `[...auth]/route.ts`. */\n readonly routes: RouteHandlers;\n /** Every verb a route file exports, forwarding to `api.target`. 404 without `api`. */\n readonly api: ApiHandlers;\n /** The session this request carries, read once per request, or `null`. */\n session(): Promise<Session | null>;\n /** The session, or a redirect to sign in that comes back to this page. */\n session(options: { readonly required: true }): Promise<Session>;\n}\n\n/**\n * The URL a server component is rendering, from the header the proxy set — which it deletes from\n * every incoming request first, so a browser cannot choose it. A page the proxy does not run on\n * has only its host.\n */\nfunction renderedUrl(incoming: Headers): URL {\n const forwarded = incoming.get(URL_HEADER);\n if (forwarded !== null) {\n try {\n return new URL(forwarded);\n } catch {\n /* not a URL: read as absent */\n }\n }\n return new URL(`https://${incoming.get(\"host\") ?? \"localhost\"}/`);\n}\n\nexport function kanzoAuth(\n config: KanzoAuthConfig | (() => KanzoAuthConfig | Promise<KanzoAuthConfig>),\n): KanzoAuth {\n // The instance is kept and the attempt is not: concurrent first requests share one build, and a\n // build that threw leaves nothing behind, so the next request builds again — `issuer`'s shape.\n let bound: Bound | undefined;\n const build = singleFlight(async () => {\n bound = bind(typeof config === \"function\" ? await config() : config);\n return bound;\n });\n const instance = async (): Promise<Bound> => bound ?? build();\n\n // React's `cache` scopes the read to one server request, so a layout, a breadcrumb and a menu\n // asking in one render unseal the cookie and read the store once.\n const current = cache(async (): Promise<{ readonly session: Session | null; readonly url: URL }> => {\n const bound = await instance();\n const [jar, incoming] = await Promise.all([cookies(), headers()]);\n const url = renderedUrl(incoming);\n const found = await bound.party.read(jar.toString());\n if (found === null) return { session: null, url };\n return {\n session: withTenant(found, await bound.tenant({ url, headers: incoming, cookies: jar })),\n url,\n };\n });\n\n async function session(): Promise<Session | null>;\n async function session(options: { readonly required: true }): Promise<Session>;\n async function session(options?: { readonly required: true }): Promise<Session | null> {\n const { session: found, url } = await current();\n if (found !== null || options?.required !== true) return found;\n // AuthKit's `ensureSignedIn`: the proxy normally sends this browser to sign in before the page\n // renders, so this is the page the proxy does not run on, or a session that ended mid-render.\n const signIn = `${(await instance()).basePath}/signin`;\n redirect(`${signIn}?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);\n }\n\n return {\n proxy: async (request, options) => gate(await instance(), request, options?.headers),\n routes: routes(instance),\n api: forward(instance),\n session,\n };\n}\n"],"names":["renderedUrl","incoming","forwarded","URL_HEADER","kanzoAuth","config","bound","build","singleFlight","bind","instance","current","cache","jar","cookies","headers","url","found","withTenant","session","options","signIn","redirect","request","gate","routes","forward"],"mappings":";;;;;;;;AA0EA,SAASA,EAAYC,GAAwB;AAC3C,QAAMC,IAAYD,EAAS,IAAIE,CAAU;AACzC,MAAID,MAAc;AAChB,QAAI;AACF,aAAO,IAAI,IAAIA,CAAS;AAAA,IAC1B,QAAQ;AAAA,IAER;AAEF,SAAO,IAAI,IAAI,WAAWD,EAAS,IAAI,MAAM,KAAK,WAAW,GAAG;AAClE;AAEO,SAASG,EACdC,GACW;AAGX,MAAIC;AACJ,QAAMC,IAAQC,EAAa,aACzBF,IAAQG,EAAK,OAAOJ,KAAW,aAAa,MAAMA,EAAA,IAAWA,CAAM,GAC5DC,EACR,GACKI,IAAW,YAA4BJ,KAASC,EAAA,GAIhDI,IAAUC,EAAM,YAA8E;AAClG,UAAMN,IAAQ,MAAMI,EAAA,GACd,CAACG,GAAKZ,CAAQ,IAAI,MAAM,QAAQ,IAAI,CAACa,EAAA,GAAWC,EAAA,CAAS,CAAC,GAC1DC,IAAMhB,EAAYC,CAAQ,GAC1BgB,IAAQ,MAAMX,EAAM,MAAM,KAAKO,EAAI,UAAU;AACnD,WAAII,MAAU,OAAa,EAAE,SAAS,MAAM,KAAAD,EAAA,IACrC;AAAA,MACL,SAASE,EAAWD,GAAO,MAAMX,EAAM,OAAO,EAAE,KAAAU,GAAK,SAASf,GAAU,SAASY,EAAA,CAAK,CAAC;AAAA,MACvF,KAAAG;AAAA,IAAA;AAAA,EAEJ,CAAC;AAID,iBAAeG,EAAQC,GAAgE;AACrF,UAAM,EAAE,SAASH,GAAO,KAAAD,EAAA,IAAQ,MAAML,EAAA;AACtC,QAAIM,MAAU,SAAQG,KAAA,gBAAAA,EAAS,cAAa,GAAM,QAAOH;AAGzD,UAAMI,IAAS,IAAI,MAAMX,EAAA,GAAY,QAAQ;AAC7C,IAAAY,EAAS,GAAGD,CAAM,aAAa,mBAAmB,GAAGL,EAAI,QAAQ,GAAGA,EAAI,MAAM,EAAE,CAAC,EAAE;AAAA,EACrF;AAEA,SAAO;AAAA,IACL,OAAO,OAAOO,GAASH,MAAYI,EAAK,MAAMd,KAAYa,GAASH,KAAA,gBAAAA,EAAS,OAAO;AAAA,IACnF,QAAQK,EAAOf,CAAQ;AAAA,IACvB,KAAKgB,EAAQhB,CAAQ;AAAA,IACrB,SAAAS;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"next-auth.js","sources":["../src/next-auth.ts"],"sourcesContent":["import { cookies, headers } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport type { NextRequest, NextResponse } from \"next/server\";\nimport { cache } from \"react\";\nimport { bind, URL_HEADER, withTenant, type Bound, type KanzoAuthConfig } from \"./next-bound\";\nimport { gate } from \"./next-gate\";\nimport { forward, type ApiHandlers } from \"./next-proxy\";\nimport { routes, type RouteHandlers } from \"./next-routes\";\nimport { singleFlight } from \"./single-flight\";\nimport type { Session } from \"./types\";\n\nexport type { KanzoAuthConfig } from \"./next-bound\";\n\n/**\n * `kanzoAuth` — the whole Backend For Frontend as one object: a proxy that is the session\n * authority, the auth routes, the resource-server forwarder and the session a server component\n * reads.\n *\n * ```ts\n * // lib/auth.ts\n * export const auth = kanzoAuth(async () => ({ issuer, clientId, clientSecret, secret, store }));\n *\n * // proxy.ts\n * export const proxy = (request: NextRequest) => auth.proxy(request);\n *\n * // app/api/auth/[...auth]/route.ts\n * export const { GET, POST } = auth.routes;\n *\n * // any server component\n * const session = await auth.session({ required: true });\n * ```\n *\n * ## One object, because one relying party\n *\n * Auth0's v4 SDK is one `Auth0Client` for the same reason. Every piece here talks to the same\n * issuer with the same secret and the same store, and a renewal in the proxy must be the renewal\n * the forwarder joins. Five factories each holding their own relying party meant five discovery\n * caches and five configs that had to agree by hand; one instance means they cannot disagree.\n *\n * ## Bound on first request, not at import\n *\n * `next build` imports every route module with no secrets in the environment, so the config is a\n * function — possibly async, for a secret read from a file or a vault — called the first time a\n * request needs it. The built instance is remembered; a failure is not, so a deployment that\n * started before its IdP or its secret was ready recovers on the next request instead of\n * remembering the outage. `routes` and `api` are plain handler objects that exist at import, which\n * is what a route file's `export const { GET, POST } = …` needs.\n */\n\nexport interface KanzoAuth {\n /**\n * Next's `proxy.ts`: the session authority for every page.\n *\n * It renews an access token that is about to expire, ends a session the IdP refused to renew,\n * and sends a browser with no live session to sign in carrying the URL it asked for — or, for a\n * request that is not a navigation, answers 401. `headers` are added to the request the page\n * sees, for a CSP nonce.\n */\n proxy(request: NextRequest, options?: { readonly headers?: HeadersInit }): Promise<NextResponse>;\n /** `signin`, `callback`, `signout`, `session`, `refresh` and `backchannel-logout`, for `[...auth]/route.ts`. */\n readonly routes: RouteHandlers;\n /** Every verb a route file exports, forwarding each mount of `apis` to its `target`. 404 without `apis`. */\n readonly api: ApiHandlers;\n /** The session this request carries, read once per request, or `null`. */\n session(): Promise<Session | null>;\n /** The session, or a redirect to sign in that comes back to this page. */\n session(options: { readonly required: true }): Promise<Session>;\n}\n\n/**\n * The URL a server component is rendering, from the header the proxy set — which it deletes from\n * every incoming request first, so a browser cannot choose it. A page the proxy does not run on\n * has only its host.\n */\nfunction renderedUrl(incoming: Headers): URL {\n const forwarded = incoming.get(URL_HEADER);\n if (forwarded !== null) {\n try {\n return new URL(forwarded);\n } catch {\n /* not a URL: read as absent */\n }\n }\n return new URL(`https://${incoming.get(\"host\") ?? \"localhost\"}/`);\n}\n\nexport function kanzoAuth(\n config: KanzoAuthConfig | (() => KanzoAuthConfig | Promise<KanzoAuthConfig>),\n): KanzoAuth {\n // The instance is kept and the attempt is not: concurrent first requests share one build, and a\n // build that threw leaves nothing behind, so the next request builds again — `issuer`'s shape.\n let bound: Bound | undefined;\n const build = singleFlight(async () => {\n bound = bind(typeof config === \"function\" ? await config() : config);\n return bound;\n });\n const instance = async (): Promise<Bound> => bound ?? build();\n\n // React's `cache` scopes the read to one server request, so a layout, a breadcrumb and a menu\n // asking in one render unseal the cookie and read the store once.\n const current = cache(async (): Promise<{ readonly session: Session | null; readonly url: URL }> => {\n const bound = await instance();\n const [jar, incoming] = await Promise.all([cookies(), headers()]);\n const url = renderedUrl(incoming);\n const found = await bound.party.read(jar.toString());\n if (found === null) return { session: null, url };\n return {\n session: withTenant(found, await bound.tenant({ url, headers: incoming, cookies: jar })),\n url,\n };\n });\n\n async function session(): Promise<Session | null>;\n async function session(options: { readonly required: true }): Promise<Session>;\n async function session(options?: { readonly required: true }): Promise<Session | null> {\n const { session: found, url } = await current();\n if (found !== null || options?.required !== true) return found;\n // AuthKit's `ensureSignedIn`: the proxy normally sends this browser to sign in before the page\n // renders, so this is the page the proxy does not run on, or a session that ended mid-render.\n const signIn = `${(await instance()).basePath}/signin`;\n redirect(`${signIn}?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);\n }\n\n return {\n proxy: async (request, options) => gate(await instance(), request, options?.headers),\n routes: routes(instance),\n api: forward(instance),\n session,\n };\n}\n"],"names":["renderedUrl","incoming","forwarded","URL_HEADER","kanzoAuth","config","bound","build","singleFlight","bind","instance","current","cache","jar","cookies","headers","url","found","withTenant","session","options","signIn","redirect","request","gate","routes","forward"],"mappings":";;;;;;;;AA0EA,SAASA,EAAYC,GAAwB;AAC3C,QAAMC,IAAYD,EAAS,IAAIE,CAAU;AACzC,MAAID,MAAc;AAChB,QAAI;AACF,aAAO,IAAI,IAAIA,CAAS;AAAA,IAC1B,QAAQ;AAAA,IAER;AAEF,SAAO,IAAI,IAAI,WAAWD,EAAS,IAAI,MAAM,KAAK,WAAW,GAAG;AAClE;AAEO,SAASG,EACdC,GACW;AAGX,MAAIC;AACJ,QAAMC,IAAQC,EAAa,aACzBF,IAAQG,EAAK,OAAOJ,KAAW,aAAa,MAAMA,EAAA,IAAWA,CAAM,GAC5DC,EACR,GACKI,IAAW,YAA4BJ,KAASC,EAAA,GAIhDI,IAAUC,EAAM,YAA8E;AAClG,UAAMN,IAAQ,MAAMI,EAAA,GACd,CAACG,GAAKZ,CAAQ,IAAI,MAAM,QAAQ,IAAI,CAACa,EAAA,GAAWC,EAAA,CAAS,CAAC,GAC1DC,IAAMhB,EAAYC,CAAQ,GAC1BgB,IAAQ,MAAMX,EAAM,MAAM,KAAKO,EAAI,UAAU;AACnD,WAAII,MAAU,OAAa,EAAE,SAAS,MAAM,KAAAD,EAAA,IACrC;AAAA,MACL,SAASE,EAAWD,GAAO,MAAMX,EAAM,OAAO,EAAE,KAAAU,GAAK,SAASf,GAAU,SAASY,EAAA,CAAK,CAAC;AAAA,MACvF,KAAAG;AAAA,IAAA;AAAA,EAEJ,CAAC;AAID,iBAAeG,EAAQC,GAAgE;AACrF,UAAM,EAAE,SAASH,GAAO,KAAAD,EAAA,IAAQ,MAAML,EAAA;AACtC,QAAIM,MAAU,SAAQG,KAAA,gBAAAA,EAAS,cAAa,GAAM,QAAOH;AAGzD,UAAMI,IAAS,IAAI,MAAMX,EAAA,GAAY,QAAQ;AAC7C,IAAAY,EAAS,GAAGD,CAAM,aAAa,mBAAmB,GAAGL,EAAI,QAAQ,GAAGA,EAAI,MAAM,EAAE,CAAC,EAAE;AAAA,EACrF;AAEA,SAAO;AAAA,IACL,OAAO,OAAOO,GAASH,MAAYI,EAAK,MAAMd,KAAYa,GAASH,KAAA,gBAAAA,EAAS,OAAO;AAAA,IACnF,QAAQK,EAAOf,CAAQ;AAAA,IACvB,KAAKgB,EAAQhB,CAAQ;AAAA,IACrB,SAAAS;AAAA,EAAA;AAEJ;"}
@@ -41,27 +41,29 @@ 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. */
51
51
  readonly renewWithin?: number;
52
52
  /**
53
- * Forward `mount` to a resource server at `target` with the access token as a bearer — the
54
- * token-mediating backend. The proxy leaves `mount` alone; `api` answers it.
53
+ * The resource servers this application calls, by the path each is mounted on: a request under
54
+ * the mount is forwarded to `target` with a bearer token for `audience` and the organization the
55
+ * request addresses — the token-mediating backend. The proxy leaves every mount alone; `api`
56
+ * answers them, the longest mount first.
55
57
  *
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.
58
+ * `audience` is the API's client id in the realm (`services/auth/modules/api`), listed in the
59
+ * application's `apis` there. `target` may depend on the organization: one BFF in front of a
60
+ * resource server per tenant, which is Keycloak's Organizations model — one client shared by
61
+ * every organization — with the data still kept apart. `undefined` answers 404.
60
62
  */
61
- readonly api?: {
62
- readonly mount: string;
63
+ readonly apis?: Readonly<Record<string, {
64
+ readonly audience: string;
63
65
  readonly target: string | ((organization: string | undefined) => string | undefined);
64
- };
66
+ }>>;
65
67
  }
66
68
  /** What a `kanzoAuth` instance is once its config has been read: one relying party and its settings. */
67
69
  export interface Bound {
@@ -69,17 +71,34 @@ export interface Bound {
69
71
  readonly basePath: string;
70
72
  readonly problemPage: string;
71
73
  readonly redirectUri?: string;
72
- readonly renewWithin?: number;
73
- readonly api?: {
74
- readonly mount: string;
75
- target(organization: string | undefined): URL | undefined;
76
- };
74
+ readonly renewWithin: number;
75
+ /** Every mount, the longest first, so a request is forwarded by the most specific one. */
76
+ readonly apis: readonly Api[];
77
77
  /** Prefixes the proxy lets through: the routes, the forwarder, the problem page, and `public`. */
78
78
  readonly open: readonly string[];
79
79
  tenant(request: TenantRequest): Promise<string | undefined>;
80
80
  }
81
+ /** One mounted resource server, once read. */
82
+ export interface Api {
83
+ readonly mount: string;
84
+ readonly audience: string;
85
+ target(organization: string | undefined): URL | undefined;
86
+ }
87
+ /**
88
+ * The URL the client addressed. Next builds `request.url` from the address the server listens on
89
+ * rather than the one the client asked for — under `next dev` 16 a request for
90
+ * `acme.localhost:3000` reads `http://localhost:3000` — so a deployment that answers on several
91
+ * hosts would resolve every tenant, callback and redirect to the same one, and one behind a proxy
92
+ * that terminates TLS would hand Keycloak an `http` callback. The authority is the proxy's
93
+ * `X-Forwarded-Host`, else `Host` (RFC 9110 §7.2); the scheme is `X-Forwarded-Proto`, else the
94
+ * request's. Auth.js reads the same headers. Trusting them is bounded the way `redirectUri` says:
95
+ * Keycloak refuses a callback it has not registered.
96
+ */
97
+ export declare function addressedUrl(request: Request): URL;
81
98
  /** What a tenant resolver reads, from a request a route handler was given. */
82
99
  export declare function tenantRequest(request: Request): TenantRequest;
100
+ /** On a segment boundary, so `/health` opens `/health/live` and not `/healthcare`. */
101
+ export declare function under(pathname: string, prefixes: readonly string[]): boolean;
83
102
  /** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */
84
103
  export declare const URL_HEADER = "x-kanzo-url";
85
104
  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;AAIvC;;;;GAIG;AAEH;;;;GAIG;AACH,KAAK,aAAa,GAAG;IACnB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAA;KAAE,CAAC;CACjF,CAAC;AAEF,MAAM,WAAW,eAAgB,SAAQ,kBAAkB;IACzD;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACrG,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;OAQG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE;QACb,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;KACtF,CAAC;CACH;AAED,wGAAwG;AACxG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,GAAG,GAAG,SAAS,CAAA;KAAE,CAAC;IACrG,kGAAkG;IAClG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC7D;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,CAY7D;AAED,6GAA6G;AAC7G,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAGxC,wBAAgB,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,KAAK,CA2BnD;AAED,0DAA0D;AAC1D,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAEtF"}
1
+ {"version":3,"file":"next-bound.d.ts","sourceRoot":"","sources":["../src/next-bound.ts"],"names":[],"mappings":"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"}
@@ -1,48 +1,66 @@
1
- import { relyingParty as s } from "./server.js";
2
- import { cookieValue as p } from "./cookie-session.js";
3
- import { withoutTrailingSlashes as i } from "./issuer.js";
4
- function c(t) {
5
- const r = t.headers.get("cookie");
1
+ import { DEFAULT_RENEW_WITHIN as d } from "./renew-within.js";
2
+ import { relyingParty as h } from "./server.js";
3
+ import { cookieValue as m } from "./cookie-session.js";
4
+ import { withoutTrailingSlashes as c } from "./issuer.js";
5
+ function p(t) {
6
+ const e = new URL(t.url), r = (n) => {
7
+ var u, i;
8
+ return ((i = (u = t.headers.get(n)) == null ? void 0 : u.split(",")[0]) == null ? void 0 : i.trim()) || void 0;
9
+ }, a = r("x-forwarded-host") ?? r("host"), s = r("x-forwarded-proto"), o = s === "http" || s === "https" ? `${s}:` : e.protocol;
10
+ try {
11
+ return new URL(`${e.pathname}${e.search}`, `${o}//${a ?? e.host}`);
12
+ } catch {
13
+ return e;
14
+ }
15
+ }
16
+ function g(t) {
17
+ const e = t.headers.get("cookie");
6
18
  return {
7
- url: new URL(t.url),
19
+ url: p(t),
8
20
  headers: t.headers,
9
21
  cookies: {
10
- get(o) {
11
- const e = p(r, o);
12
- return e === void 0 ? void 0 : { value: e };
22
+ get(r) {
23
+ const a = m(e, r);
24
+ return a === void 0 ? void 0 : { value: a };
13
25
  }
14
26
  }
15
27
  };
16
28
  }
17
- const v = "x-kanzo-url";
18
- function b(t) {
19
- const r = i(t.basePath ?? "/api/auth"), o = t.problemPage ?? "/auth/problem", e = t.api, n = e === void 0 ? void 0 : {
20
- mount: i(e.mount),
21
- target: (a) => {
22
- const d = typeof e.target == "string" ? e.target : e.target(a);
23
- return d === void 0 ? void 0 : new URL(d);
29
+ function U(t, e) {
30
+ return e.some((r) => t === r || t.startsWith(`${r}/`));
31
+ }
32
+ const R = "x-kanzo-url";
33
+ function y(t) {
34
+ const e = c(t.basePath ?? "/api/auth"), r = t.problemPage ?? "/auth/problem", a = Object.entries(t.apis ?? {}).map(([o, n]) => ({
35
+ mount: c(o),
36
+ audience: n.audience,
37
+ target: (u) => {
38
+ const i = typeof n.target == "string" ? n.target : n.target(u);
39
+ return i === void 0 ? void 0 : new URL(i);
24
40
  }
25
- }, u = t.organization;
41
+ })).sort((o, n) => n.mount.length - o.mount.length), s = t.organization;
26
42
  return {
27
- party: s(t),
28
- basePath: r,
29
- problemPage: o,
43
+ party: h(t),
44
+ basePath: e,
45
+ problemPage: r,
30
46
  redirectUri: t.redirectUri,
31
- renewWithin: t.renewWithin,
32
- api: n,
33
- open: [r, o, ...n === void 0 ? [] : [n.mount], ...t.public ?? []].map(
34
- i
47
+ renewWithin: t.renewWithin ?? d,
48
+ apis: a,
49
+ open: [e, r, ...a.map((o) => o.mount), ...t.public ?? []].map(
50
+ c
35
51
  ),
36
- tenant: async (a) => u === void 0 ? void 0 : u(a)
52
+ tenant: async (o) => s === void 0 ? void 0 : s(o)
37
53
  };
38
54
  }
39
- function w(t, r) {
40
- return r === void 0 ? t : { ...t, organization: r };
55
+ function $(t, e) {
56
+ return e === void 0 ? t : { ...t, organization: e };
41
57
  }
42
58
  export {
43
- v as URL_HEADER,
44
- b as bind,
45
- c as tenantRequest,
46
- w as withTenant
59
+ R as URL_HEADER,
60
+ p as addressedUrl,
61
+ y as bind,
62
+ g as tenantRequest,
63
+ U as under,
64
+ $ as withTenant
47
65
  };
48
66
  //# sourceMappingURL=next-bound.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"next-bound.js","sources":["../src/next-bound.ts"],"sourcesContent":["import { relyingParty, type RelyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\nimport { cookieValue } from \"./cookie-session\";\nimport { withoutTrailingSlashes } from \"./issuer\";\n\n/**\n * A `kanzoAuth` config once read: one relying party, and the settings the proxy, the routes and\n * the forwarder share. In a module of its own so those three can take it without importing the\n * door that builds them.\n */\n\n/**\n * What a tenant resolver is handed. The proxy, the routes and a server component each build it\n * from the request in front of them — a server component from the `x-kanzo-url` the proxy\n * forwarded — so the same function answers the same way in all three.\n */\ntype TenantRequest = {\n readonly url: URL;\n readonly headers: Headers;\n readonly cookies: { get(name: string): { readonly value: string } | undefined };\n};\n\nexport interface KanzoAuthConfig extends RelyingPartyConfig {\n /**\n * The organization a request addresses: a hostname's first label, a path segment, a cookie, or\n * one fixed alias for a deployment per tenant. Its answer is `Session.organization`, and the\n * organization `can` asks inside by default. Absent, sessions carry no current tenant.\n */\n readonly organization?: (request: TenantRequest) => string | undefined | Promise<string | undefined>;\n /** Where `routes` is mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /**\n * The product's page for a failed sign-in, callback or sign-out, and for a session store that\n * does not answer, reached as `?code=<AuthErrorCode>`. Default `/auth/problem`. Always public:\n * whoever is sent there has no session, and sending them to sign in instead is a loop when\n * signing in is what failed.\n */\n readonly problemPage?: string;\n /**\n * Path prefixes the proxy lets through without a session — a health probe, a legal notice.\n * Matched on segment boundaries: `/health` opens `/health/live` and not `/healthcare`.\n */\n readonly public?: readonly string[];\n /**\n * Override the callback URL. Absent, it is derived from the request: its origin, the path the\n * routes sit on, and `/callback`. Deriving it trusts `Host`, which is bounded — Keycloak refuses\n * a `redirect_uri` it has not registered — but a deployment behind a proxy that rewrites the\n * host says the URL out loud here.\n */\n readonly redirectUri?: string;\n /** Seconds of access-token lifetime below which a request renews it. Default 60. */\n readonly renewWithin?: number;\n /**\n * Forward `mount` to a resource server at `target` with the access token as a bearer — the\n * token-mediating backend. The proxy leaves `mount` alone; `api` answers it.\n *\n * `target` may depend on the organization a request addresses: one BFF in front of a resource\n * server per tenant, which is Keycloak's Organizations model — one client shared by every\n * organization — with the data still kept apart. A request that addresses none — a bare host —\n * has no server to reach, and `undefined` answers it 404.\n */\n readonly api?: {\n readonly mount: string;\n readonly target: string | ((organization: string | undefined) => string | undefined);\n };\n}\n\n/** What a `kanzoAuth` instance is once its config has been read: one relying party and its settings. */\nexport interface Bound {\n readonly party: RelyingParty;\n readonly basePath: string;\n readonly problemPage: string;\n readonly redirectUri?: string;\n readonly renewWithin?: number;\n readonly api?: { readonly mount: string; target(organization: string | undefined): URL | undefined };\n /** Prefixes the proxy lets through: the routes, the forwarder, the problem page, and `public`. */\n readonly open: readonly string[];\n tenant(request: TenantRequest): Promise<string | undefined>;\n}\n\n/** What a tenant resolver reads, from a request a route handler was given. */\nexport function tenantRequest(request: Request): TenantRequest {\n const header = request.headers.get(\"cookie\");\n return {\n url: new URL(request.url),\n headers: request.headers,\n cookies: {\n get(name: string) {\n const value = cookieValue(header, name);\n return value === undefined ? undefined : { value };\n },\n },\n };\n}\n\n/** The request header the proxy forwards the URL on — AuthKit's `x-url` — for a server component to read. */\nexport const URL_HEADER = \"x-kanzo-url\";\n\n\nexport function bind(config: KanzoAuthConfig): Bound {\n const basePath = withoutTrailingSlashes(config.basePath ?? \"/api/auth\");\n const problemPage = config.problemPage ?? \"/auth/problem\";\n const forward = config.api;\n const api =\n forward === undefined\n ? undefined\n : {\n mount: withoutTrailingSlashes(forward.mount),\n target: (organization: string | undefined) => {\n const target = typeof forward.target === \"string\" ? forward.target : forward.target(organization);\n return target === undefined ? undefined : new URL(target);\n },\n };\n const resolve = config.organization;\n return {\n party: relyingParty(config),\n basePath,\n problemPage,\n redirectUri: config.redirectUri,\n renewWithin: config.renewWithin,\n api,\n open: [basePath, problemPage, ...(api === undefined ? [] : [api.mount]), ...(config.public ?? [])].map(\n withoutTrailingSlashes,\n ),\n tenant: async (request) => (resolve === undefined ? undefined : resolve(request)),\n };\n}\n\n/** The current tenant on a session, when there is one. */\nexport function withTenant(session: Session, organization: string | undefined): Session {\n return organization === undefined ? session : { ...session, organization };\n}\n\n"],"names":["tenantRequest","request","header","name","value","cookieValue","URL_HEADER","bind","config","basePath","withoutTrailingSlashes","problemPage","forward","api","organization","target","resolve","relyingParty","withTenant","session"],"mappings":";;;AAiFO,SAASA,EAAcC,GAAiC;AAC7D,QAAMC,IAASD,EAAQ,QAAQ,IAAI,QAAQ;AAC3C,SAAO;AAAA,IACL,KAAK,IAAI,IAAIA,EAAQ,GAAG;AAAA,IACxB,SAASA,EAAQ;AAAA,IACjB,SAAS;AAAA,MACP,IAAIE,GAAc;AAChB,cAAMC,IAAQC,EAAYH,GAAQC,CAAI;AACtC,eAAOC,MAAU,SAAY,SAAY,EAAE,OAAAA,EAAA;AAAA,MAC7C;AAAA,IAAA;AAAA,EACF;AAEJ;AAGO,MAAME,IAAa;AAGnB,SAASC,EAAKC,GAAgC;AACnD,QAAMC,IAAWC,EAAuBF,EAAO,YAAY,WAAW,GAChEG,IAAcH,EAAO,eAAe,iBACpCI,IAAUJ,EAAO,KACjBK,IACJD,MAAY,SACR,SACA;AAAA,IACE,OAAOF,EAAuBE,EAAQ,KAAK;AAAA,IAC3C,QAAQ,CAACE,MAAqC;AAC5C,YAAMC,IAAS,OAAOH,EAAQ,UAAW,WAAWA,EAAQ,SAASA,EAAQ,OAAOE,CAAY;AAChG,aAAOC,MAAW,SAAY,SAAY,IAAI,IAAIA,CAAM;AAAA,IAC1D;AAAA,EAAA,GAEFC,IAAUR,EAAO;AACvB,SAAO;AAAA,IACL,OAAOS,EAAaT,CAAM;AAAA,IAC1B,UAAAC;AAAA,IACA,aAAAE;AAAA,IACA,aAAaH,EAAO;AAAA,IACpB,aAAaA,EAAO;AAAA,IACpB,KAAAK;AAAA,IACA,MAAM,CAACJ,GAAUE,GAAa,GAAIE,MAAQ,SAAY,CAAA,IAAK,CAACA,EAAI,KAAK,GAAI,GAAIL,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,MACjGE;AAAA,IAAA;AAAA,IAEF,QAAQ,OAAOT,MAAae,MAAY,SAAY,SAAYA,EAAQf,CAAO;AAAA,EAAA;AAEnF;AAGO,SAASiB,EAAWC,GAAkBL,GAA2C;AACtF,SAAOA,MAAiB,SAAYK,IAAU,EAAE,GAAGA,GAAS,cAAAL,EAAA;AAC9D;"}
1
+ {"version":3,"file":"next-bound.js","sources":["../src/next-bound.ts"],"sourcesContent":["import { DEFAULT_RENEW_WITHIN } from \"./renew-within\";\nimport { 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 * The resource servers this application calls, by the path each is mounted on: a request under\n * the mount is forwarded to `target` with a bearer token for `audience` and the organization the\n * request addresses — the token-mediating backend. The proxy leaves every mount alone; `api`\n * answers them, the longest mount first.\n *\n * `audience` is the API's client id in the realm (`services/auth/modules/api`), listed in the\n * application's `apis` there. `target` may depend on the organization: one BFF in front of a\n * resource server per tenant, which is Keycloak's Organizations model — one client shared by\n * every organization — with the data still kept apart. `undefined` answers 404.\n */\n readonly apis?: Readonly<\n Record<\n string,\n {\n readonly audience: string;\n readonly target: string | ((organization: string | undefined) => string | undefined);\n }\n >\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 /** Every mount, the longest first, so a request is forwarded by the most specific one. */\n readonly apis: readonly Api[];\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/** One mounted resource server, once read. */\nexport interface Api {\n readonly mount: string;\n readonly audience: string;\n target(organization: string | undefined): URL | 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/** On a segment boundary, so `/health` opens `/health/live` and not `/healthcare`. */\nexport function under(pathname: string, prefixes: readonly string[]): boolean {\n return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));\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\nexport function bind(config: KanzoAuthConfig): Bound {\n const basePath = withoutTrailingSlashes(config.basePath ?? \"/api/auth\");\n const problemPage = config.problemPage ?? \"/auth/problem\";\n const apis: Api[] = Object.entries(config.apis ?? {})\n .map(([mount, api]) => ({\n mount: withoutTrailingSlashes(mount),\n audience: api.audience,\n target: (organization: string | undefined) => {\n const target = typeof api.target === \"string\" ? api.target : api.target(organization);\n return target === undefined ? undefined : new URL(target);\n },\n }))\n .sort((a, b) => b.mount.length - a.mount.length);\n const resolve = config.organization;\n return {\n party: relyingParty(config),\n basePath,\n problemPage,\n redirectUri: config.redirectUri,\n renewWithin: config.renewWithin ?? DEFAULT_RENEW_WITHIN,\n apis,\n open: [basePath, problemPage, ...apis.map((api) => 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","under","pathname","prefixes","prefix","URL_HEADER","bind","config","basePath","withoutTrailingSlashes","problemPage","apis","mount","api","organization","target","a","b","resolve","relyingParty","DEFAULT_RENEW_WITHIN","withTenant","session"],"mappings":";;;;AA0GO,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,SAASE,EAAMC,GAAkBC,GAAsC;AAC5E,SAAOA,EAAS,KAAK,CAACC,MAAWF,MAAaE,KAAUF,EAAS,WAAW,GAAGE,CAAM,GAAG,CAAC;AAC3F;AAGO,MAAMC,IAAa;AAEnB,SAASC,EAAKC,GAAgC;AACnD,QAAMC,IAAWC,EAAuBF,EAAO,YAAY,WAAW,GAChEG,IAAcH,EAAO,eAAe,iBACpCI,IAAc,OAAO,QAAQJ,EAAO,QAAQ,EAAE,EACjD,IAAI,CAAC,CAACK,GAAOC,CAAG,OAAO;AAAA,IACtB,OAAOJ,EAAuBG,CAAK;AAAA,IACnC,UAAUC,EAAI;AAAA,IACd,QAAQ,CAACC,MAAqC;AAC5C,YAAMC,IAAS,OAAOF,EAAI,UAAW,WAAWA,EAAI,SAASA,EAAI,OAAOC,CAAY;AACpF,aAAOC,MAAW,SAAY,SAAY,IAAI,IAAIA,CAAM;AAAA,IAC1D;AAAA,EAAA,EACA,EACD,KAAK,CAACC,GAAGC,MAAMA,EAAE,MAAM,SAASD,EAAE,MAAM,MAAM,GAC3CE,IAAUX,EAAO;AACvB,SAAO;AAAA,IACL,OAAOY,EAAaZ,CAAM;AAAA,IAC1B,UAAAC;AAAA,IACA,aAAAE;AAAA,IACA,aAAaH,EAAO;AAAA,IACpB,aAAaA,EAAO,eAAea;AAAA,IACnC,MAAAT;AAAA,IACA,MAAM,CAACH,GAAUE,GAAa,GAAGC,EAAK,IAAI,CAACE,MAAQA,EAAI,KAAK,GAAG,GAAIN,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,MACvFE;AAAA,IAAA;AAAA,IAEF,QAAQ,OAAOrB,MAAa8B,MAAY,SAAY,SAAYA,EAAQ9B,CAAO;AAAA,EAAA;AAEnF;AAGO,SAASiC,EAAWC,GAAkBR,GAA2C;AACtF,SAAOA,MAAiB,SAAYQ,IAAU,EAAE,GAAGA,GAAS,cAAAR,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,EAAmC,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAwF3E,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,71 @@
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) {
1
+ import { NextResponse as d } from "next/server";
2
+ import { addressedUrl as p, URL_HEADER as k, under as w } from "./next-bound.js";
3
+ import { outage as g } from "./next-routes.js";
4
+ import { AuthError as x } 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}/`));
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()) {
8
+ function y(t) {
9
+ const [r = "", ...l] = t.split(";").map((a) => a.trim()), i = r.indexOf("="), s = { name: r.slice(0, i), value: r.slice(i + 1) };
10
+ for (const a of l) {
11
+ const [n = "", c = ""] = a.split("=");
12
+ switch (n.toLowerCase()) {
16
13
  case "path":
17
- n.path = i;
14
+ s.path = c;
18
15
  break;
19
16
  case "httponly":
20
- n.httpOnly = !0;
17
+ s.httpOnly = !0;
21
18
  break;
22
19
  case "secure":
23
- n.secure = !0;
20
+ s.secure = !0;
24
21
  break;
25
22
  case "samesite":
26
- n.sameSite = i.toLowerCase();
23
+ s.sameSite = c.toLowerCase();
27
24
  break;
28
25
  case "max-age":
29
- n.maxAge = Number(i);
26
+ s.maxAge = Number(c);
30
27
  break;
31
28
  }
32
29
  }
33
- return n;
30
+ return s;
34
31
  }
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;
32
+ async function E(t, r, l) {
33
+ const { pathname: i, search: s } = r.nextUrl, a = p(r), n = new Headers(r.headers);
34
+ for (const e of [...n.keys()])
35
+ e.startsWith("x-kanzo-") && n.delete(e);
36
+ n.set(k, a.href), new Headers(l).forEach((e, o) => n.set(o, e));
37
+ const c = () => d.next({ request: { headers: n } });
38
+ if (b(i) || w(i, t.open)) return c();
39
+ const f = r.headers.get("sec-fetch-mode"), u = f === null || f === "navigate";
40
+ let h;
44
41
  try {
45
- f = await t.party.token(r.headers.get("cookie"), { renewWithin: t.renewWithin });
42
+ h = await t.party.refresh(r.headers.get("cookie"), { renewWithin: t.renewWithin });
46
43
  } catch (e) {
47
- if (!(e instanceof k)) throw e;
48
- if (e.code === "idp/unreachable" || e.code === "idp/silent") return l();
44
+ if (!(e instanceof x)) throw e;
45
+ if (e.code === "idp/unreachable" || e.code === "idp/silent") return c();
49
46
  if (!u)
50
- return new h(null, {
51
- status: p(e.code) ?? 500,
47
+ return new d(null, {
48
+ status: g(e.code) ?? 500,
52
49
  headers: { "cache-control": "no-store" }
53
50
  });
54
- const s = new URL(t.problemPage, r.url);
55
- return s.searchParams.set("code", e.code), h.redirect(s, 307);
51
+ const o = new URL(t.problemPage, a);
52
+ return o.searchParams.set("code", e.code), d.redirect(o, 307);
56
53
  }
57
- if (f.ended) {
54
+ if (h.ended) {
58
55
  let e;
59
56
  if (u) {
60
- const s = new URL(`${t.basePath}/signin`, r.url);
61
- s.searchParams.set("returnTo", `${c}${n}`), e = h.redirect(s, 307);
57
+ const o = new URL(`${t.basePath}/signin`, a);
58
+ o.searchParams.set("returnTo", `${i}${s}`), e = d.redirect(o, 307);
62
59
  } else
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);
60
+ e = new d(null, { status: 401, headers: { "cache-control": "no-store" } });
61
+ for (const o of h.cookies) e.headers.append("set-cookie", o);
65
62
  return e;
66
63
  }
67
- const d = l();
68
- for (const e of f.cookies) d.cookies.set(b(e));
69
- return d;
64
+ const m = c();
65
+ for (const e of h.cookies) m.cookies.set(y(e));
66
+ return m;
70
67
  }
71
68
  export {
72
- v as gate
69
+ E as gate
73
70
  };
74
71
  //# 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":";;;;AA6CA,SAASA,EAAOC,GAA2B;AACzC,SAAOA,EAAS,MAAMA,EAAS,YAAY,GAAG,IAAI,CAAC,EAAE,SAAS,GAAG;AACnE;AAGA,SAASC,EAAMD,GAAkBE,GAAsC;AACrE,SAAOA,EAAS,KAAK,CAACC,MAAWH,MAAaG,KAAUH,EAAS,WAAW,GAAGG,CAAM,GAAG,CAAC;AAC3F;AAOA,SAASC,EAASC,GAAgB;AAChC,QAAM,CAACC,IAAO,IAAI,GAAGC,CAAU,IAAIF,EAAO,MAAM,GAAG,EAAE,IAAI,CAACG,MAASA,EAAK,MAAM,GACxEC,IAAKH,EAAK,QAAQ,GAAG,GACrBI,IAQF,EAAE,MAAMJ,EAAK,MAAM,GAAGG,CAAE,GAAG,OAAOH,EAAK,MAAMG,IAAK,CAAC,EAAA;AACvD,aAAWE,KAAaJ,GAAY;AAClC,UAAM,CAACK,IAAM,IAAIC,IAAQ,EAAE,IAAIF,EAAU,MAAM,GAAG;AAClD,YAAQC,EAAI,eAAY;AAAA,MACtB,KAAK;AACH,QAAAF,EAAO,OAAOG;AACd;AAAA,MACF,KAAK;AACH,QAAAH,EAAO,WAAW;AAClB;AAAA,MACF,KAAK;AACH,QAAAA,EAAO,SAAS;AAChB;AAAA,MACF,KAAK;AACH,QAAAA,EAAO,WAAWG,EAAM,YAAA;AACxB;AAAA,MACF,KAAK;AACH,QAAAH,EAAO,SAAS,OAAOG,CAAK;AAC5B;AAAA,IAAA;AAAA,EAEN;AACA,SAAOH;AACT;AAEA,eAAsBI,EACpBC,GACAC,GACAC,GACuB;AACvB,QAAM,EAAE,UAAAjB,GAAU,QAAAkB,EAAA,IAAWF,EAAQ,SAE/BG,IAAY,IAAI,QAAQH,EAAQ,OAAO;AAC7C,aAAWI,KAAQ,CAAC,GAAGD,EAAU,KAAA,CAAM;AACrC,IAAIC,EAAK,WAAW,UAAU,KAAGD,EAAU,OAAOC,CAAI;AAExD,EAAAD,EAAU,IAAIE,GAAYL,EAAQ,GAAG,GACrC,IAAI,QAAQC,CAAK,EAAE,QAAQ,CAACJ,GAAOO,MAASD,EAAU,IAAIC,GAAMP,CAAK,CAAC;AACtE,QAAMS,IAAU,MAAMC,EAAa,KAAK,EAAE,SAAS,EAAE,SAASJ,EAAA,GAAa;AAE3E,MAAIpB,EAAOC,CAAQ,KAAKC,EAAMD,GAAUe,EAAM,IAAI,EAAG,QAAOO,EAAA;AAE5D,QAAME,IAAOR,EAAQ,QAAQ,IAAI,gBAAgB,GAC3CS,IAAaD,MAAS,QAAQA,MAAS;AAE7C,MAAIE;AACJ,MAAI;AACF,IAAAA,IAAO,MAAMX,EAAM,MAAM,MAAMC,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaD,EAAM,aAAa;AAAA,EAClG,SAASY,GAAO;AACd,QAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,QAAIA,EAAM,SAAS,qBAAqBA,EAAM,SAAS,qBAAqBL,EAAA;AAC5E,QAAI,CAACG;AACH,aAAO,IAAIF,EAAa,MAAM;AAAA,QAC5B,QAAQM,EAAOF,EAAM,IAAI,KAAK;AAAA,QAC9B,SAAS,EAAE,iBAAiB,WAAA;AAAA,MAAW,CACxC;AAEH,UAAMG,IAAU,IAAI,IAAIf,EAAM,aAAaC,EAAQ,GAAG;AACtD,WAAAc,EAAQ,aAAa,IAAI,QAAQH,EAAM,IAAI,GACpCJ,EAAa,SAASO,GAAS,GAAG;AAAA,EAC3C;AAEA,MAAIJ,EAAK,OAAO;AACd,QAAIK;AACJ,QAAIN,GAAY;AACd,YAAMO,IAAS,IAAI,IAAI,GAAGjB,EAAM,QAAQ,WAAWC,EAAQ,GAAG;AAG9D,MAAAgB,EAAO,aAAa,IAAI,YAAY,GAAGhC,CAAQ,GAAGkB,CAAM,EAAE,GAC1Da,IAASR,EAAa,SAASS,GAAQ,GAAG;AAAA,IAC5C;AACE,MAAAD,IAAS,IAAIR,EAAa,MAAM,EAAE,QAAQ,KAAK,SAAS,EAAE,iBAAiB,WAAA,GAAc;AAE3F,eAAWb,KAAUgB,EAAK,WAAgB,QAAQ,OAAO,cAAchB,CAAM;AAC7E,WAAOqB;AAAA,EACT;AAEA,QAAME,IAAWX,EAAA;AACjB,aAAWZ,KAAUgB,EAAK,QAAS,CAAAO,EAAS,QAAQ,IAAI7B,EAASM,CAAM,CAAC;AACxE,SAAOuB;AACT;"}
1
+ {"version":3,"file":"next-gate.js","sources":["../src/next-gate.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\nimport { addressedUrl, URL_HEADER, under, type Bound } from \"./next-bound\";\nimport { outage } from \"./next-routes\";\nimport type { Ended, Renewed } 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/**\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: Renewed | Ended;\n try {\n held = await bound.party.refresh(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","asCookie","header","pair","attributes","part","eq","cookie","attribute","key","value","gate","bound","request","extra","search","addressed","addressedUrl","forwarded","name","URL_HEADER","through","NextResponse","under","mode","navigation","held","error","AuthError","outage","problem","answer","signIn","response"],"mappings":";;;;AA6CA,SAASA,EAAOC,GAA2B;AACzC,SAAOA,EAAS,MAAMA,EAAS,YAAY,GAAG,IAAI,CAAC,EAAE,SAAS,GAAG;AACnE;AAOA,SAASC,EAASC,GAAgB;AAChC,QAAM,CAACC,IAAO,IAAI,GAAGC,CAAU,IAAIF,EAAO,MAAM,GAAG,EAAE,IAAI,CAACG,MAASA,EAAK,MAAM,GACxEC,IAAKH,EAAK,QAAQ,GAAG,GACrBI,IAQF,EAAE,MAAMJ,EAAK,MAAM,GAAGG,CAAE,GAAG,OAAOH,EAAK,MAAMG,IAAK,CAAC,EAAA;AACvD,aAAWE,KAAaJ,GAAY;AAClC,UAAM,CAACK,IAAM,IAAIC,IAAQ,EAAE,IAAIF,EAAU,MAAM,GAAG;AAClD,YAAQC,EAAI,eAAY;AAAA,MACtB,KAAK;AACH,QAAAF,EAAO,OAAOG;AACd;AAAA,MACF,KAAK;AACH,QAAAH,EAAO,WAAW;AAClB;AAAA,MACF,KAAK;AACH,QAAAA,EAAO,SAAS;AAChB;AAAA,MACF,KAAK;AACH,QAAAA,EAAO,WAAWG,EAAM,YAAA;AACxB;AAAA,MACF,KAAK;AACH,QAAAH,EAAO,SAAS,OAAOG,CAAK;AAC5B;AAAA,IAAA;AAAA,EAEN;AACA,SAAOH;AACT;AAEA,eAAsBI,EACpBC,GACAC,GACAC,GACuB;AACvB,QAAM,EAAE,UAAAd,GAAU,QAAAe,EAAA,IAAWF,EAAQ,SAE/BG,IAAYC,EAAaJ,CAAO,GAEhCK,IAAY,IAAI,QAAQL,EAAQ,OAAO;AAC7C,aAAWM,KAAQ,CAAC,GAAGD,EAAU,KAAA,CAAM;AACrC,IAAIC,EAAK,WAAW,UAAU,KAAGD,EAAU,OAAOC,CAAI;AAExD,EAAAD,EAAU,IAAIE,GAAYJ,EAAU,IAAI,GACxC,IAAI,QAAQF,CAAK,EAAE,QAAQ,CAACJ,GAAOS,MAASD,EAAU,IAAIC,GAAMT,CAAK,CAAC;AACtE,QAAMW,IAAU,MAAMC,EAAa,KAAK,EAAE,SAAS,EAAE,SAASJ,EAAA,GAAa;AAE3E,MAAInB,EAAOC,CAAQ,KAAKuB,EAAMvB,GAAUY,EAAM,IAAI,EAAG,QAAOS,EAAA;AAE5D,QAAMG,IAAOX,EAAQ,QAAQ,IAAI,gBAAgB,GAC3CY,IAAaD,MAAS,QAAQA,MAAS;AAE7C,MAAIE;AACJ,MAAI;AACF,IAAAA,IAAO,MAAMd,EAAM,MAAM,QAAQC,EAAQ,QAAQ,IAAI,QAAQ,GAAG,EAAE,aAAaD,EAAM,aAAa;AAAA,EACpG,SAASe,GAAO;AACd,QAAI,EAAEA,aAAiBC,GAAY,OAAMD;AACzC,QAAIA,EAAM,SAAS,qBAAqBA,EAAM,SAAS,qBAAqBN,EAAA;AAC5E,QAAI,CAACI;AACH,aAAO,IAAIH,EAAa,MAAM;AAAA,QAC5B,QAAQO,EAAOF,EAAM,IAAI,KAAK;AAAA,QAC9B,SAAS,EAAE,iBAAiB,WAAA;AAAA,MAAW,CACxC;AAEH,UAAMG,IAAU,IAAI,IAAIlB,EAAM,aAAaI,CAAS;AACpD,WAAAc,EAAQ,aAAa,IAAI,QAAQH,EAAM,IAAI,GACpCL,EAAa,SAASQ,GAAS,GAAG;AAAA,EAC3C;AAEA,MAAIJ,EAAK,OAAO;AACd,QAAIK;AACJ,QAAIN,GAAY;AACd,YAAMO,IAAS,IAAI,IAAI,GAAGpB,EAAM,QAAQ,WAAWI,CAAS;AAG5D,MAAAgB,EAAO,aAAa,IAAI,YAAY,GAAGhC,CAAQ,GAAGe,CAAM,EAAE,GAC1DgB,IAAST,EAAa,SAASU,GAAQ,GAAG;AAAA,IAC5C;AACE,MAAAD,IAAS,IAAIT,EAAa,MAAM,EAAE,QAAQ,KAAK,SAAS,EAAE,iBAAiB,WAAA,GAAc;AAE3F,eAAWf,KAAUmB,EAAK,WAAgB,QAAQ,OAAO,cAAcnB,CAAM;AAC7E,WAAOwB;AAAA,EACT;AAEA,QAAME,IAAWZ,EAAA;AACjB,aAAWd,KAAUmB,EAAK,QAAS,CAAAO,EAAS,QAAQ,IAAIhC,EAASM,CAAM,CAAC;AACxE,SAAO0B;AACT;"}
@@ -1,11 +1,12 @@
1
1
  import { Bound } from './next-bound';
2
2
  /**
3
3
  * The BFF half of the *token-mediating backend*: the browser's request goes out again carrying a
4
- * bearer token, and the cookie that got it here stops at this line. `kanzoAuth().api`.
4
+ * bearer token for that resource server and that organization alone, and the cookie that got it
5
+ * here stops at this line. `kanzoAuth().api`.
5
6
  *
6
7
  * ```ts
7
- * // lib/auth.ts: kanzoAuth(() => ({ …, api: { mount: "/api/data", target: "https://reports.internal/v1" } }))
8
- * // app/api/data/[...path]/route.ts
8
+ * // lib/auth.ts: kanzoAuth(() => ({ …, apis: { "/api/data": { audience: "reports", target: "https://reports.internal/v1" } } }))
9
+ * // app/api/[...path]/route.ts — one route file can serve every mount under it
9
10
  * export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = auth.api;
10
11
  * ```
11
12
  *
@@ -26,8 +27,14 @@ import { Bound } from './next-bound';
26
27
  * the same answer. There is nothing to forward: a request with no credential is not the resource
27
28
  * server's to refuse. `bffAuth` answers that 401 by asking `refresh`, and signs in when that is
28
29
  * refused too.
29
- * - **403** when the request is not same-site. `same-site.ts` carries why the package owes this.
30
- * - **404** when `kanzoAuth` was given no `api`.
30
+ * - **403** when the request is not same-site — `same-site.ts` carries why the package owes this —
31
+ * and when the realm will not issue a token for the organization the request addresses, because
32
+ * the person is not a member of it.
33
+ * - **404** when `kanzoAuth` was given no `apis`, or the mount's `target` names no server for the
34
+ * organization.
35
+ * - **400** for a path under no mount, and for an organization that is not an alias.
36
+ * - **502** when the realm would not exchange the token at all: the application is not allowed to
37
+ * ask for that audience, which is the deployment's to fix and not the person's.
31
38
  *
32
39
  * ## Renewal happens here too
33
40
  *
@@ -1 +1 @@
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
+ {"version":3,"file":"next-proxy.d.ts","sourceRoot":"","sources":["../src/next-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAMhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;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;AA2DD,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,CAsFnE"}