@kanzo-tech/auth 0.27.0 → 0.28.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.
- package/README.md +7 -4
- package/dist/auth-fetch.d.ts +1 -1
- package/dist/auth-fetch.js.map +1 -1
- package/dist/can.d.ts +3 -2
- package/dist/can.d.ts.map +1 -1
- package/dist/can.js.map +1 -1
- package/dist/claims.d.ts +3 -18
- package/dist/claims.d.ts.map +1 -1
- package/dist/claims.js +23 -27
- package/dist/claims.js.map +1 -1
- package/dist/cookie-session.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -14
- package/dist/next-middleware.js.map +1 -1
- package/dist/next-session.d.ts +2 -2
- package/dist/next-session.js.map +1 -1
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,12 +59,15 @@ Nothing here is invented. Every claim is one Keycloak emits without being asked:
|
|
|
59
59
|
| --- | --- |
|
|
60
60
|
| `sub`, `email`, `name` (or `given_name` + `family_name`), `preferred_username` | `session.user` |
|
|
61
61
|
| `realm_access.roles` ∪ `resource_access.<clientId>.roles` | `session.roles` |
|
|
62
|
-
| `organization` — `{ "acme": { "id": "…", "
|
|
62
|
+
| `organization` — `{ "acme": { "id": "…", "resource_access": { "<clientId>": { "roles": ["editor"] } } } }` | `session.organizations` |
|
|
63
63
|
| `exp` | `session.expiresAt`, in milliseconds |
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
Inside each organization, `resource_access.<clientId>.roles` is what the person holds **there**:
|
|
66
|
+
the roles an organization admin mapped onto the groups they are in, composites expanded by
|
|
67
|
+
Keycloak. Group names are never read — they are the organization's own business — and another
|
|
68
|
+
application's roles in the same entry are ignored, so a role held in one application never
|
|
69
|
+
authorises its holder in another. Nor are they merged into `session.roles`: a role in one
|
|
70
|
+
organization says nothing about the next.
|
|
68
71
|
|
|
69
72
|
Ask Keycloak for `organization:*` to receive every organization the person belongs to. Plain
|
|
70
73
|
`organization` returns the only one when there is one and prompts for a choice when there are
|
package/dist/auth-fetch.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A `fetch` that stays authenticated.
|
|
3
3
|
*
|
|
4
|
-
* This is the seam a product already has.
|
|
4
|
+
* This is the seam a product already has. A typed API client is often
|
|
5
5
|
* `createClient({ baseUrl: "/" })` with one middleware; the viewer's trace source takes its
|
|
6
6
|
* transport as a parameter. Handing either an authenticated `fetch` changes one line and no call
|
|
7
7
|
* site — which is the point, because the alternative is every call site remembering a header.
|
package/dist/auth-fetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-fetch.js","sources":["../src/auth-fetch.ts"],"sourcesContent":["/**\n * A `fetch` that stays authenticated.\n *\n * This is the seam a product already has.
|
|
1
|
+
{"version":3,"file":"auth-fetch.js","sources":["../src/auth-fetch.ts"],"sourcesContent":["/**\n * A `fetch` that stays authenticated.\n *\n * This is the seam a product already has. A typed API client is often\n * `createClient({ baseUrl: \"/\" })` with one middleware; the viewer's trace source takes its\n * transport as a parameter. Handing either an authenticated `fetch` changes one line and no call\n * site — which is the point, because the alternative is every call site remembering a header.\n *\n * Auth0's SPA SDK ships the same shape for the same reason: `fetchWithAuth`, *\"a drop-in replacement\n * for the Fetch API's `fetch()` method\"* that builds the headers and handles the retries.\n */\n\n/**\n * Where a token comes from, and how to ask for a fresh one.\n *\n * Both are the implementation's business to make cheap and **single-flight** — see `singleFlight`.\n * `current` is called on every request, so it must answer from cache until the token is near\n * expiry. `null` from both is legitimate: it means there is no bearer token to attach.\n */\nexport interface TokenSource {\n current(): Promise<string | null>;\n renew(): Promise<string | null>;\n}\n\n/**\n * Can this request be sent a second time?\n *\n * A body that is a stream can be read once, so a retry would send an empty one — silently, with a\n * misleading error at the far end. Where we cannot prove the body is replayable we do not retry: the\n * 401 reaches the caller, which is honest, rather than a corrupted request reaching the server.\n *\n * Exported for `bff-auth.ts`, which retries for a different reason — a renewed cookie rather than\n * a renewed bearer token — and must not answer the question differently. It is not on the barrel:\n * it is a shared predicate between two implementations, not a thing a consumer holds.\n */\nexport function isReplayable(input: RequestInfo | URL, init?: RequestInit): boolean {\n if (typeof Request !== \"undefined\" && input instanceof Request && input.body !== null) return false;\n const body = init?.body;\n if (body === undefined || body === null) return true;\n return !(typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream);\n}\n\n/**\n * Wrap a `fetch` so every request carries the session, and one stale token does not surface as a\n * failure the user has to see.\n *\n * The retry is **once**, and only on a 401 we sent a token for. Retrying a 403 would be wrong — that\n * is an answer, not a stale credential — and retrying twice turns an expired session into a loop\n * against the authorization server.\n */\nexport function authFetch(\n source: TokenSource,\n base: typeof globalThis.fetch = globalThis.fetch,\n): typeof globalThis.fetch {\n return async (input, init) => {\n const send = async (token: string | null): Promise<Response> => {\n const inherited =\n init?.headers ??\n (typeof Request !== \"undefined\" && input instanceof Request ? input.headers : undefined);\n const headers = new Headers(inherited);\n if (token !== null) headers.set(\"Authorization\", `Bearer ${token}`);\n return base(input, { ...init, headers });\n };\n\n const token = await source.current();\n const response = await send(token);\n\n // Nothing to renew against, or nothing that says the credential was the problem.\n if (response.status !== 401 || token === null) return response;\n if (!isReplayable(input, init)) return response;\n\n const renewed = await source.renew();\n if (renewed === null || renewed === token) return response;\n\n return send(renewed);\n };\n}\n"],"names":["isReplayable","input","init","body","authFetch","source","base","send","token","inherited","headers","response","renewed"],"mappings":"AAmCO,SAASA,EAAaC,GAA0BC,GAA6B;AAClF,MAAI,OAAO,UAAY,OAAeD,aAAiB,WAAWA,EAAM,SAAS,KAAM,QAAO;AAC9F,QAAME,IAAOD,KAAA,gBAAAA,EAAM;AACnB,SAA0BC,KAAS,OAAa,KACzC,EAAE,OAAO,iBAAmB,OAAeA,aAAgB;AACpE;AAUO,SAASC,EACdC,GACAC,IAAgC,WAAW,OAClB;AACzB,SAAO,OAAOL,GAAOC,MAAS;AAC5B,UAAMK,IAAO,OAAOC,MAA4C;AAC9D,YAAMC,KACJP,KAAA,gBAAAA,EAAM,aACL,OAAO,UAAY,OAAeD,aAAiB,UAAUA,EAAM,UAAU,SAC1ES,IAAU,IAAI,QAAQD,CAAS;AACrC,aAAID,MAAU,QAAME,EAAQ,IAAI,iBAAiB,UAAUF,CAAK,EAAE,GAC3DF,EAAKL,GAAO,EAAE,GAAGC,GAAM,SAAAQ,GAAS;AAAA,IACzC,GAEMF,IAAQ,MAAMH,EAAO,QAAA,GACrBM,IAAW,MAAMJ,EAAKC,CAAK;AAIjC,QADIG,EAAS,WAAW,OAAOH,MAAU,QACrC,CAACR,EAAaC,GAAOC,CAAI,EAAG,QAAOS;AAEvC,UAAMC,IAAU,MAAMP,EAAO,MAAA;AAC7B,WAAIO,MAAY,QAAQA,MAAYJ,IAAcG,IAE3CJ,EAAKK,CAAO;AAAA,EACrB;AACF;"}
|
package/dist/can.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export declare function organizationOf(session: Session | null | undefined, alia
|
|
|
18
18
|
* convenience is the day one organization's owner is every organization's owner.
|
|
19
19
|
*
|
|
20
20
|
* Closed by default: no session, or no membership of the named organization, is `false` rather than
|
|
21
|
-
* an error. There is no hierarchy here either
|
|
22
|
-
* product,
|
|
21
|
+
* an error. There is no hierarchy here either: that `admin` contains `editor` is a fact about a
|
|
22
|
+
* product, declared once as Keycloak composite roles where the product registers its client, and
|
|
23
|
+
* the token carries the expanded set — so `can(s, "editor", org)` is true for an admin.
|
|
23
24
|
*/
|
|
24
25
|
export declare function can(session: Session | null | undefined, role: string, organization?: string): boolean;
|
|
25
26
|
//# sourceMappingURL=can.d.ts.map
|
package/dist/can.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"can.d.ts","sourceRoot":"","sources":["../src/can.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD;;;;;;;GAOG;AAEH,6FAA6F;AAC7F,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GACZ,YAAY,GAAG,SAAS,CAE1B;AAED
|
|
1
|
+
{"version":3,"file":"can.d.ts","sourceRoot":"","sources":["../src/can.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD;;;;;;;GAOG;AAEH,6FAA6F;AAC7F,wBAAgB,cAAc,CAC5B,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,KAAK,EAAE,MAAM,GACZ,YAAY,GAAG,SAAS,CAE1B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAIT"}
|
package/dist/can.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"can.js","sources":["../src/can.ts"],"sourcesContent":["import type { Organization, Session } from \"./types\";\n\n/**\n * The role predicate, and the lookup underneath it.\n *\n * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,\n * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a\n * button, and the resource server — validating the access token it was sent — is what actually\n * refuses the request behind it. A product that gates only here has not gated anything.\n */\n\n/** The organization by that alias, or `undefined` for one this person does not belong to. */\nexport function organizationOf(\n session: Session | null | undefined,\n alias: string,\n): Organization | undefined {\n return session?.organizations.find((org) => org.alias === alias);\n}\n\n/**\n * Does this session hold `role`?\n *\n * With an `organization`, the question is asked *inside* it — the person's roles there, which is a\n * different set from their realm and client roles and is deliberately not merged with them. A role\n * held in one organization says nothing about another, and the day those two sets are unioned for\n * convenience is the day one organization's owner is every organization's owner.\n *\n * Closed by default: no session, or no membership of the named organization, is `false` rather than\n * an error. There is no hierarchy here either
|
|
1
|
+
{"version":3,"file":"can.js","sources":["../src/can.ts"],"sourcesContent":["import type { Organization, Session } from \"./types\";\n\n/**\n * The role predicate, and the lookup underneath it.\n *\n * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,\n * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a\n * button, and the resource server — validating the access token it was sent — is what actually\n * refuses the request behind it. A product that gates only here has not gated anything.\n */\n\n/** The organization by that alias, or `undefined` for one this person does not belong to. */\nexport function organizationOf(\n session: Session | null | undefined,\n alias: string,\n): Organization | undefined {\n return session?.organizations.find((org) => org.alias === alias);\n}\n\n/**\n * Does this session hold `role`?\n *\n * With an `organization`, the question is asked *inside* it — the person's roles there, which is a\n * different set from their realm and client roles and is deliberately not merged with them. A role\n * held in one organization says nothing about another, and the day those two sets are unioned for\n * convenience is the day one organization's owner is every organization's owner.\n *\n * Closed by default: no session, or no membership of the named organization, is `false` rather than\n * an error. There is no hierarchy here either: that `admin` contains `editor` is a fact about a\n * product, declared once as Keycloak composite roles where the product registers its client, and\n * the token carries the expanded set — so `can(s, \"editor\", org)` is true for an admin.\n */\nexport function can(\n session: Session | null | undefined,\n role: string,\n organization?: string,\n): boolean {\n if (!session) return false;\n if (organization === undefined) return session.roles.includes(role);\n return organizationOf(session, organization)?.roles.includes(role) ?? false;\n}\n"],"names":["organizationOf","session","alias","org","can","role","organization","_a"],"mappings":"AAYO,SAASA,EACdC,GACAC,GAC0B;AAC1B,SAAOD,KAAA,gBAAAA,EAAS,cAAc,KAAK,CAACE,MAAQA,EAAI,UAAUD;AAC5D;AAeO,SAASE,EACdH,GACAI,GACAC,GACS;AAxBJ,MAAAC;AAyBL,SAAKN,IACDK,MAAiB,SAAkBL,EAAQ,MAAM,SAASI,CAAI,MAC3DE,IAAAP,EAAeC,GAASK,CAAY,MAApC,gBAAAC,EAAuC,MAAM,SAASF,OAAS,KAFjD;AAGvB;"}
|
package/dist/claims.d.ts
CHANGED
|
@@ -9,32 +9,17 @@ import { Session } from './types';
|
|
|
9
9
|
*
|
|
10
10
|
* **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`
|
|
11
11
|
* — the claims Keycloak emits with no configuration — and membership is the `organization` claim
|
|
12
|
-
* from the organization scope. A deployment that renames these has made work for itself; a
|
|
12
|
+
* from the organization scope, which repeats `resource_access` inside each organization. A deployment that renames these has made work for itself; a
|
|
13
13
|
* deployment that uses them gets this file for free.
|
|
14
14
|
*/
|
|
15
15
|
/** What the reader needs to know about the application doing the reading. */
|
|
16
16
|
export interface ClaimsConfig {
|
|
17
17
|
/**
|
|
18
|
-
* This application's Keycloak client id
|
|
19
|
-
*
|
|
18
|
+
* This application's Keycloak client id: the entry of `resource_access` that is ours, at the top
|
|
19
|
+
* level and inside each organization.
|
|
20
20
|
*/
|
|
21
21
|
readonly clientId: string;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* A group path, as the role it grants *this* application — or `null` when it grants nothing here.
|
|
25
|
-
*
|
|
26
|
-
* Keycloak writes group membership as a path: `/keasy/owner`. Organization Groups (26.6) give each
|
|
27
|
-
* organization its own hierarchy, so the convention this package reads is that **the first segment
|
|
28
|
-
* is the application** when there is more than one:
|
|
29
|
-
*
|
|
30
|
-
* - `/keasy/owner` under client `keasy` → `owner`
|
|
31
|
-
* - `/hub/reader` under client `keasy` → `null`, because it is another application's role
|
|
32
|
-
* - `/owner` → `owner`, a role the organization grants across every application
|
|
33
|
-
*
|
|
34
|
-
* The filtering is not a nicety. Without it, a role granted to someone in the hub would authorise
|
|
35
|
-
* them in keasy, which is the whole failure this separation exists to prevent.
|
|
36
|
-
*/
|
|
37
|
-
export declare function roleFromGroupPath(path: string, clientId: string): string | null;
|
|
38
23
|
/**
|
|
39
24
|
* Read a decoded claim set into a {@link Session}.
|
|
40
25
|
*
|
package/dist/claims.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAErE;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;
|
|
1
|
+
{"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAErE;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AA0DD;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CA8BlE"}
|
package/dist/claims.js
CHANGED
|
@@ -1,56 +1,52 @@
|
|
|
1
|
-
import { AuthError as
|
|
2
|
-
function
|
|
1
|
+
import { AuthError as p } from "./types.js";
|
|
2
|
+
function o(r) {
|
|
3
3
|
return typeof r == "object" && r !== null && !Array.isArray(r) ? r : void 0;
|
|
4
4
|
}
|
|
5
|
-
function
|
|
5
|
+
function m(r) {
|
|
6
6
|
return Array.isArray(r) ? r.filter((t) => typeof t == "string") : [];
|
|
7
7
|
}
|
|
8
|
-
function
|
|
8
|
+
function i(r) {
|
|
9
9
|
return typeof r == "string" && r.length > 0 ? r : void 0;
|
|
10
10
|
}
|
|
11
|
-
function g(r, t) {
|
|
12
|
-
const e = r.split("/").filter((i) => i.length > 0), [n, ...s] = e;
|
|
13
|
-
return n === void 0 ? null : s.length === 0 ? n : n === t ? s.join("/") : null;
|
|
14
|
-
}
|
|
15
11
|
function y(r, t) {
|
|
16
12
|
if (Array.isArray(r))
|
|
17
13
|
return r.filter((n) => typeof n == "string").map((n) => ({ alias: n, roles: [] }));
|
|
18
|
-
const e =
|
|
19
|
-
return e === void 0 ? [] : Object.entries(e).map(([n,
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
const e = o(r);
|
|
15
|
+
return e === void 0 ? [] : Object.entries(e).map(([n, f]) => {
|
|
16
|
+
var u, c;
|
|
17
|
+
const s = o(f), a = m((c = o((u = o(s == null ? void 0 : s.resource_access)) == null ? void 0 : u[t])) == null ? void 0 : c.roles);
|
|
18
|
+
return { alias: n, id: i(s == null ? void 0 : s.id), roles: a };
|
|
22
19
|
});
|
|
23
20
|
}
|
|
24
|
-
function
|
|
25
|
-
const t =
|
|
21
|
+
function g(r) {
|
|
22
|
+
const t = i(r.name);
|
|
26
23
|
if (t !== void 0) return t;
|
|
27
|
-
const e = [
|
|
24
|
+
const e = [i(r.given_name), i(r.family_name)].filter(
|
|
28
25
|
(n) => n !== void 0
|
|
29
26
|
);
|
|
30
27
|
return e.length > 0 ? e.join(" ") : void 0;
|
|
31
28
|
}
|
|
32
29
|
function b(r, t) {
|
|
33
|
-
var
|
|
34
|
-
const e =
|
|
30
|
+
var c, l, d;
|
|
31
|
+
const e = o(r) ?? {}, n = i(e.sub);
|
|
35
32
|
if (n === void 0)
|
|
36
|
-
throw new
|
|
37
|
-
const
|
|
38
|
-
(
|
|
39
|
-
),
|
|
33
|
+
throw new p("claims/no-subject", "the claim set carries no `sub`, so it names nobody");
|
|
34
|
+
const f = m((c = o(e.realm_access)) == null ? void 0 : c.roles), s = m(
|
|
35
|
+
(d = o((l = o(e.resource_access)) == null ? void 0 : l[t.clientId])) == null ? void 0 : d.roles
|
|
36
|
+
), a = e.exp, u = typeof a == "number" && Number.isFinite(a) ? a * 1e3 : 0;
|
|
40
37
|
return {
|
|
41
38
|
user: {
|
|
42
39
|
id: n,
|
|
43
|
-
email:
|
|
44
|
-
name:
|
|
45
|
-
username:
|
|
40
|
+
email: i(e.email),
|
|
41
|
+
name: g(e),
|
|
42
|
+
username: i(e.preferred_username)
|
|
46
43
|
},
|
|
47
|
-
roles: [.../* @__PURE__ */ new Set([...
|
|
44
|
+
roles: [.../* @__PURE__ */ new Set([...f, ...s])],
|
|
48
45
|
organizations: y(e.organization, t.clientId),
|
|
49
46
|
expiresAt: u
|
|
50
47
|
};
|
|
51
48
|
}
|
|
52
49
|
export {
|
|
53
|
-
b as claims
|
|
54
|
-
g as roleFromGroupPath
|
|
50
|
+
b as claims
|
|
55
51
|
};
|
|
56
52
|
//# sourceMappingURL=claims.js.map
|
package/dist/claims.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claims.js","sources":["../src/claims.ts"],"sourcesContent":["import { AuthError, type Organization, type Session } from \"./types\";\n\n/**\n * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that\n * knows what Keycloak calls things.\n *\n * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what\n * makes the vocabulary testable without a realm, and it is why every door can share one reading of\n * it instead of each parsing the token its own way.\n *\n * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`\n * — the claims Keycloak emits with no configuration — and membership is the `organization` claim\n * from the organization scope. A deployment that renames these has made work for itself; a\n * deployment that uses them gets this file for free.\n */\n\n/** What the reader needs to know about the application doing the reading. */\nexport interface ClaimsConfig {\n /**\n * This application's Keycloak client id
|
|
1
|
+
{"version":3,"file":"claims.js","sources":["../src/claims.ts"],"sourcesContent":["import { AuthError, type Organization, type Session } from \"./types\";\n\n/**\n * Keycloak's claim vocabulary, read into a {@link Session}. The only file in this package that\n * knows what Keycloak calls things.\n *\n * It is pure on purpose: claims in, session out, no network, no storage, no React. That is what\n * makes the vocabulary testable without a realm, and it is why every door can share one reading of\n * it instead of each parsing the token its own way.\n *\n * **Nothing here is invented.** Roles are `realm_access.roles` and `resource_access.<clientId>.roles`\n * — the claims Keycloak emits with no configuration — and membership is the `organization` claim\n * from the organization scope, which repeats `resource_access` inside each organization. A deployment that renames these has made work for itself; a\n * deployment that uses them gets this file for free.\n */\n\n/** What the reader needs to know about the application doing the reading. */\nexport interface ClaimsConfig {\n /**\n * This application's Keycloak client id: the entry of `resource_access` that is ours, at the top\n * level and inside each organization.\n */\n readonly clientId: string;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | undefined {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : undefined;\n}\n\nfunction asStrings(value: unknown): string[] {\n return Array.isArray(value) ? value.filter((v): v is string => typeof v === \"string\") : [];\n}\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\n/**\n * The `organization` claim, as a list.\n *\n * Canonically it is an object keyed by alias, and each entry carries what the person holds THERE:\n * `{ \"acme\": { \"id\": \"…\", \"resource_access\": { \"board\": { \"roles\": [\"editor\", \"reader\"] } } } }`.\n * Keycloak writes `resource_access` inside the entry from the role mappings of the person's\n * groups in that organization, composites expanded. Group names (`groups`) are the organization's\n * own data and are not read: an application learns its roles, never how an organization arranged\n * its people. A realm whose mapper includes neither the id nor the roles emits the aliases alone,\n * so both shapes are read — the alternative is a session that silently loses its memberships on a\n * realm nobody thought to check.\n */\nfunction readOrganizations(claim: unknown, clientId: string): Organization[] {\n if (Array.isArray(claim)) {\n return claim\n .filter((alias): alias is string => typeof alias === \"string\")\n .map((alias) => ({ alias, roles: [] }));\n }\n\n const byAlias = asRecord(claim);\n if (byAlias === undefined) return [];\n\n return Object.entries(byAlias).map(([alias, value]) => {\n const body = asRecord(value);\n const roles = asStrings(asRecord(asRecord(body?.[\"resource_access\"])?.[clientId])?.[\"roles\"]);\n return { alias, id: asString(body?.[\"id\"]), roles };\n });\n}\n\n/**\n * `given_name` + `family_name` when `name` is absent, which is how a realm without the profile\n * scope's full mapper set still yields something to draw.\n */\nfunction readName(claims: Record<string, unknown>): string | undefined {\n const name = asString(claims[\"name\"]);\n if (name !== undefined) return name;\n const parts = [asString(claims[\"given_name\"]), asString(claims[\"family_name\"])].filter(\n (p): p is string => p !== undefined,\n );\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n\n/**\n * Read a decoded claim set into a {@link Session}.\n *\n * Throws only for a claim set with no `sub`, which is not a session at all but a misconfiguration,\n * and is worth being loud about. Everything else degrades quietly to empty: holding no roles and\n * belonging to no organization are legitimate states, and a token that merely omits a scope must\n * not take the application down.\n *\n * The claims are **data, never instructions** — they came over the wire. Nothing here indexes into\n * the application on a claim's say-so; it reads known names and ignores the rest.\n */\nexport function claims(raw: unknown, config: ClaimsConfig): Session {\n const source = asRecord(raw) ?? {};\n\n const id = asString(source[\"sub\"]);\n if (id === undefined) {\n throw new AuthError(\"claims/no-subject\", \"the claim set carries no `sub`, so it names nobody\");\n }\n\n const realmRoles = asStrings(asRecord(source[\"realm_access\"])?.[\"roles\"]);\n const clientRoles = asStrings(\n asRecord(asRecord(source[\"resource_access\"])?.[config.clientId])?.[\"roles\"],\n );\n\n // `exp` is seconds in the token and milliseconds everywhere in JS. Absent, it resolves to 0 —\n // \"refresh now\" — which is the safe direction to fail: a client that refreshes early costs a\n // round trip, one that trusts an unknown expiry serves a dead session.\n const exp = source[\"exp\"];\n const expiresAt = typeof exp === \"number\" && Number.isFinite(exp) ? exp * 1000 : 0;\n\n return {\n user: {\n id,\n email: asString(source[\"email\"]),\n name: readName(source),\n username: asString(source[\"preferred_username\"]),\n },\n roles: [...new Set([...realmRoles, ...clientRoles])],\n organizations: readOrganizations(source[\"organization\"], config.clientId),\n expiresAt,\n };\n}\n"],"names":["asRecord","value","asStrings","v","asString","readOrganizations","claim","clientId","alias","byAlias","body","roles","_b","_a","readName","claims","name","parts","p","raw","config","source","id","AuthError","realmRoles","clientRoles","_c","exp","expiresAt"],"mappings":";AAyBA,SAASA,EAASC,GAAqD;AACrE,SAAO,OAAOA,KAAU,YAAYA,MAAU,QAAQ,CAAC,MAAM,QAAQA,CAAK,IACrEA,IACD;AACN;AAEA,SAASC,EAAUD,GAA0B;AAC3C,SAAO,MAAM,QAAQA,CAAK,IAAIA,EAAM,OAAO,CAACE,MAAmB,OAAOA,KAAM,QAAQ,IAAI,CAAA;AAC1F;AAEA,SAASC,EAASH,GAAoC;AACpD,SAAO,OAAOA,KAAU,YAAYA,EAAM,SAAS,IAAIA,IAAQ;AACjE;AAcA,SAASI,EAAkBC,GAAgBC,GAAkC;AAC3E,MAAI,MAAM,QAAQD,CAAK;AACrB,WAAOA,EACJ,OAAO,CAACE,MAA2B,OAAOA,KAAU,QAAQ,EAC5D,IAAI,CAACA,OAAW,EAAE,OAAAA,GAAO,OAAO,CAAA,IAAK;AAG1C,QAAMC,IAAUT,EAASM,CAAK;AAC9B,SAAIG,MAAY,SAAkB,CAAA,IAE3B,OAAO,QAAQA,CAAO,EAAE,IAAI,CAAC,CAACD,GAAOP,CAAK,MAAM;;AACrD,UAAMS,IAAOV,EAASC,CAAK,GACrBU,IAAQT,GAAUU,IAAAZ,GAASa,IAAAb,EAASU,KAAA,gBAAAA,EAAO,eAAkB,MAAlC,gBAAAG,EAAsCN,EAAS,MAAxD,gBAAAK,EAA4D,KAAQ;AAC5F,WAAO,EAAE,OAAAJ,GAAO,IAAIJ,EAASM,KAAA,gBAAAA,EAAO,EAAK,GAAG,OAAAC,EAAA;AAAA,EAC9C,CAAC;AACH;AAMA,SAASG,EAASC,GAAqD;AACrE,QAAMC,IAAOZ,EAASW,EAAO,IAAO;AACpC,MAAIC,MAAS,OAAW,QAAOA;AAC/B,QAAMC,IAAQ,CAACb,EAASW,EAAO,UAAa,GAAGX,EAASW,EAAO,WAAc,CAAC,EAAE;AAAA,IAC9E,CAACG,MAAmBA,MAAM;AAAA,EAAA;AAE5B,SAAOD,EAAM,SAAS,IAAIA,EAAM,KAAK,GAAG,IAAI;AAC9C;AAaO,SAASF,EAAOI,GAAcC,GAA+B;;AAClE,QAAMC,IAASrB,EAASmB,CAAG,KAAK,CAAA,GAE1BG,IAAKlB,EAASiB,EAAO,GAAM;AACjC,MAAIC,MAAO;AACT,UAAM,IAAIC,EAAU,qBAAqB,oDAAoD;AAG/F,QAAMC,IAAatB,GAAUW,IAAAb,EAASqB,EAAO,YAAe,MAA/B,gBAAAR,EAAmC,KAAQ,GAClEY,IAAcvB;AAAA,KAClBwB,IAAA1B,GAASY,IAAAZ,EAASqB,EAAO,eAAkB,MAAlC,gBAAAT,EAAsCQ,EAAO,SAAS,MAA/D,gBAAAM,EAAmE;AAAA,EAAO,GAMtEC,IAAMN,EAAO,KACbO,IAAY,OAAOD,KAAQ,YAAY,OAAO,SAASA,CAAG,IAAIA,IAAM,MAAO;AAEjF,SAAO;AAAA,IACL,MAAM;AAAA,MACJ,IAAAL;AAAA,MACA,OAAOlB,EAASiB,EAAO,KAAQ;AAAA,MAC/B,MAAMP,EAASO,CAAM;AAAA,MACrB,UAAUjB,EAASiB,EAAO,kBAAqB;AAAA,IAAA;AAAA,IAEjD,OAAO,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAGG,GAAY,GAAGC,CAAW,CAAC,CAAC;AAAA,IACnD,eAAepB,EAAkBgB,EAAO,cAAiBD,EAAO,QAAQ;AAAA,IACxE,WAAAQ;AAAA,EAAA;AAEJ;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cookie-session.js","sources":["../src/cookie-session.ts"],"sourcesContent":["import { EncryptJWT, jwtDecrypt } from \"jose\";\n\n/**\n * A value sealed into a cookie, and read back out of one.\n *\n * There is **one** mechanism here and it is used twice: for the session that outlives a request,\n * and for the short-lived transaction that carries `state`, `nonce` and the PKCE verifier between\n * the two legs of the authorization code flow. Holding the transaction in a cookie rather than in\n * server memory is what makes the BFF stateless by default —
|
|
1
|
+
{"version":3,"file":"cookie-session.js","sources":["../src/cookie-session.ts"],"sourcesContent":["import { EncryptJWT, jwtDecrypt } from \"jose\";\n\n/**\n * A value sealed into a cookie, and read back out of one.\n *\n * There is **one** mechanism here and it is used twice: for the session that outlives a request,\n * and for the short-lived transaction that carries `state`, `nonce` and the PKCE verifier between\n * the two legs of the authorization code flow. Holding the transaction in a cookie rather than in\n * server memory is what makes the BFF stateless by default — keeping those three in a server\n * session pays for a session store before anyone has signed in.\n *\n * ## The attributes, and why each one\n *\n * - **`__Host-` prefix**, added here and not declinable. It forces `Secure` and `Path=/`, forbids\n * `Domain`, and — the part that matters — a page on a sibling subdomain cannot write it. Without\n * the prefix, anything that can serve `evil.example.test` can set a cookie that arrives at\n * `app.example.test` looking exactly like ours.\n * - **`HttpOnly`**, so script cannot read it. In a BFF the browser is not supposed to hold the\n * credential at all; this is the enforcement of that sentence.\n * - **`SameSite=Lax`**, not `Strict`. `Strict` withholds the cookie on the top-level navigation\n * *back* from the identity provider, so the callback arrives without the transaction it needs\n * and every sign-in fails. `Lax` sends it on exactly that navigation and on nothing else risky.\n * - **JWE, not a signature.** The payload is a refresh token: signing would authenticate it and\n * leave it readable to anyone who can see the cookie. `dir` + `A256GCM` is authenticated\n * encryption, so a tampered byte fails to decrypt rather than decrypting to something else.\n */\n\n/**\n * The 4 KB a browser is required to keep, and the reason this module has a size guard.\n *\n * A cookie over the limit is not rejected loudly — it is *dropped*, and the symptom is a sign-in\n * that appears to work and a session that is never there. A `SessionStore` is the way out, and the\n * error says so.\n */\nconst COOKIE_LIMIT = 4096;\n\nexport interface SealedCookieConfig {\n /**\n * The name **after** the `__Host-` prefix, which this module adds. A caller cannot decline it:\n * the prefix is the only cookie attribute a browser enforces on our behalf.\n */\n readonly name: string;\n /**\n * The sealing secret. Any length — it is hashed to the 256-bit key — but it is a *secret*, not a\n * password: generate it, do not choose it.\n */\n readonly secret: string | Uint8Array;\n /** Seconds. It is both the cookie's `Max-Age` and the JWE's `exp`, so neither can outlive the other. */\n readonly maxAge: number;\n}\n\nexport interface SealedCookie<T> {\n /** The full cookie name, prefix included. */\n readonly name: string;\n /** The value of a `Set-Cookie` header carrying `value`. */\n seal(value: T): Promise<string>;\n /** Read from a request's `Cookie` header. `null` for absent, tampered, or expired — all one answer. */\n read(header: string | null | undefined): Promise<T | null>;\n /** The value of a `Set-Cookie` header that removes it. */\n clear(): string;\n}\n\n/** One cookie value out of a request's `Cookie` header, or `undefined`. */\nexport function cookieValue(\n header: string | null | undefined,\n name: string,\n): string | undefined {\n if (header === null || header === undefined || header.length === 0) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n\nasync function keyFrom(secret: string | Uint8Array): Promise<Uint8Array> {\n const input = typeof secret === \"string\" ? new TextEncoder().encode(secret) : secret;\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", input as BufferSource));\n}\n\nfunction setCookie(name: string, value: string, maxAge: number): string {\n // No `Domain`: `__Host-` forbids it, and forbidding it is the point — a cookie without a domain\n // is the one a sibling host cannot reach.\n return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;\n}\n\nexport function sealedCookie<T>(config: SealedCookieConfig): SealedCookie<T> {\n const name = `__Host-${config.name}`;\n // Derived once, lazily: the digest is cheap but a request path should not pay for it per call.\n // The key is kept, not the promise, so a digest that failed is attempted again.\n let key: Uint8Array | undefined;\n const material = async () => (key ??= await keyFrom(config.secret));\n\n return {\n name,\n\n async seal(value) {\n // The payload is wrapped rather than spread, so a field named `exp` or `iss` in a `Session`\n // could never come to mean the JWT claim of the same name.\n const token = await new EncryptJWT({ v: value })\n .setProtectedHeader({ alg: \"dir\", enc: \"A256GCM\" })\n .setIssuedAt()\n .setExpirationTime(`${config.maxAge}s`)\n .encrypt(await material());\n\n const header = setCookie(name, token, config.maxAge);\n if (header.length > COOKIE_LIMIT) {\n throw new Error(\n `${name} is ${header.length} bytes and a browser is only required to keep ${COOKIE_LIMIT}; ` +\n \"a cookie over the limit is dropped silently and the session simply never appears. \" +\n \"Give relyingParty `store: ticketStore(adapter)` so the cookie carries an opaque \" +\n \"ticket instead of the tokens: a record holding an access token does not fit here.\",\n );\n }\n return header;\n },\n\n async read(header) {\n const token = cookieValue(header, name);\n if (token === undefined) return null;\n try {\n const { payload } = await jwtDecrypt(token, await material());\n return (payload[\"v\"] ?? null) as T | null;\n } catch {\n // Forged, re-keyed, truncated by a proxy, or simply expired. None of them is a session,\n // and none of them is worth a different answer to the caller — `bff-auth` reads the same\n // endpoint the same way. A thrown error here would only ever be caught and turned into\n // this.\n return null;\n }\n },\n\n clear() {\n return setCookie(name, \"\", 0);\n },\n };\n}\n"],"names":["COOKIE_LIMIT","cookieValue","header","name","part","eq","keyFrom","secret","input","setCookie","value","maxAge","sealedCookie","config","key","material","token","EncryptJWT","payload","jwtDecrypt"],"mappings":";AAkCA,MAAMA,IAAe;AA6Bd,SAASC,EACdC,GACAC,GACoB;AACpB,MAAI,EAAAD,KAAW,QAAgCA,EAAO,WAAW;AACjE,eAAWE,KAAQF,EAAO,MAAM,GAAG,GAAG;AACpC,YAAMG,IAAKD,EAAK,QAAQ,GAAG;AAC3B,UAAIC,MAAO,MACPD,EAAK,MAAM,GAAGC,CAAE,EAAE,KAAA,MAAWF;AAAM,eAAOC,EAAK,MAAMC,IAAK,CAAC,EAAE,KAAA;AAAA,IACnE;AAEF;AAEA,eAAeC,EAAQC,GAAkD;AACvE,QAAMC,IAAQ,OAAOD,KAAW,WAAW,IAAI,cAAc,OAAOA,CAAM,IAAIA;AAC9E,SAAO,IAAI,WAAW,MAAM,OAAO,OAAO,OAAO,WAAWC,CAAqB,CAAC;AACpF;AAEA,SAASC,EAAUN,GAAcO,GAAeC,GAAwB;AAGtE,SAAO,GAAGR,CAAI,IAAIO,CAAK,qDAAqDC,CAAM;AACpF;AAEO,SAASC,EAAgBC,GAA6C;AAC3E,QAAMV,IAAO,UAAUU,EAAO,IAAI;AAGlC,MAAIC;AACJ,QAAMC,IAAW,YAAaD,UAAQ,MAAMR,EAAQO,EAAO,MAAM;AAEjE,SAAO;AAAA,IACL,MAAAV;AAAA,IAEA,MAAM,KAAKO,GAAO;AAGhB,YAAMM,IAAQ,MAAM,IAAIC,EAAW,EAAE,GAAGP,EAAA,CAAO,EAC5C,mBAAmB,EAAE,KAAK,OAAO,KAAK,UAAA,CAAW,EACjD,YAAA,EACA,kBAAkB,GAAGG,EAAO,MAAM,GAAG,EACrC,QAAQ,MAAME,EAAA,CAAU,GAErBb,IAASO,EAAUN,GAAMa,GAAOH,EAAO,MAAM;AACnD,UAAIX,EAAO,SAASF;AAClB,cAAM,IAAI;AAAA,UACR,GAAGG,CAAI,OAAOD,EAAO,MAAM,iDAAiDF,CAAY;AAAA,QAAA;AAM5F,aAAOE;AAAA,IACT;AAAA,IAEA,MAAM,KAAKA,GAAQ;AACjB,YAAMc,IAAQf,EAAYC,GAAQC,CAAI;AACtC,UAAIa,MAAU,OAAW,QAAO;AAChC,UAAI;AACF,cAAM,EAAE,SAAAE,MAAY,MAAMC,EAAWH,GAAO,MAAMD,GAAU;AAC5D,eAAQG,EAAQ,KAAQ;AAAA,MAC1B,QAAQ;AAKN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,QAAQ;AACN,aAAOT,EAAUN,GAAM,IAAI,CAAC;AAAA,IAC9B;AAAA,EAAA;AAEJ;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export type { AuthContextValue, AuthStatus } from './auth-context';
|
|
|
55
55
|
export { AuthProvider } from './auth-provider';
|
|
56
56
|
export { bffAuth, readSession, type BffAuthConfig } from './bff-auth';
|
|
57
57
|
export { can, organizationOf } from './can';
|
|
58
|
-
export { claims,
|
|
58
|
+
export { claims, type ClaimsConfig } from './claims';
|
|
59
59
|
export { Gate } from './gate';
|
|
60
60
|
export { organizationFromHost } from './host';
|
|
61
61
|
export { singleFlight } from './single-flight';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EACL,SAAS,EACT,KAAK,IAAI,EACT,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
import { accountUrl as t } from "./account.js";
|
|
2
2
|
import { authFetch as f } from "./auth-fetch.js";
|
|
3
3
|
import { AuthProvider as a } from "./auth-provider.js";
|
|
4
|
-
import { bffAuth as
|
|
4
|
+
import { bffAuth as p, readSession as x } from "./bff-auth.js";
|
|
5
5
|
import { can as s, organizationOf as u } from "./can.js";
|
|
6
|
-
import { claims as c
|
|
7
|
-
import { Gate as
|
|
6
|
+
import { claims as c } from "./claims.js";
|
|
7
|
+
import { Gate as l } from "./gate.js";
|
|
8
8
|
import { organizationFromHost as A } from "./host.js";
|
|
9
|
-
import { singleFlight as
|
|
10
|
-
import { AuthError as
|
|
11
|
-
import { useOrganization as
|
|
12
|
-
import { useSession as
|
|
9
|
+
import { singleFlight as d } from "./single-flight.js";
|
|
10
|
+
import { AuthError as S } from "./types.js";
|
|
11
|
+
import { useOrganization as v } from "./use-organization.js";
|
|
12
|
+
import { useSession as G } from "./use-session.js";
|
|
13
13
|
export {
|
|
14
|
-
|
|
14
|
+
S as AuthError,
|
|
15
15
|
a as AuthProvider,
|
|
16
|
-
|
|
16
|
+
l as Gate,
|
|
17
17
|
t as accountUrl,
|
|
18
18
|
f as authFetch,
|
|
19
|
-
|
|
19
|
+
p as bffAuth,
|
|
20
20
|
s as can,
|
|
21
21
|
c as claims,
|
|
22
22
|
A as organizationFromHost,
|
|
23
23
|
u as organizationOf,
|
|
24
24
|
x as readSession,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
E as useSession
|
|
25
|
+
d as singleFlight,
|
|
26
|
+
v as useOrganization,
|
|
27
|
+
G as useSession
|
|
29
28
|
};
|
|
30
29
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-middleware.js","sources":["../src/next-middleware.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\n\n/**\n * The edge middleware that sends an anonymous browser to the sign-in route.\n *\n * ```ts\n * // middleware.ts\n * export const middleware = authMiddleware({ public: [\"/health\"] });\n * ```\n *\n * ## It checks for presence, and nothing else\n *\n * The cookie is sealed with JWE, and this file never opens it — no key, no `jose`, no store. Two\n * reasons, and the second is the one that matters:\n *\n * 1. Middleware runs on every request, including the ones that are about to be answered from a\n * cache. Decrypting there buys a redirect decision that a route handler and `authSession` are\n * both going to make again, properly, a millisecond later.\n * 2. **A redirect is not an authorization.** Letting a request past here grants nothing: the page\n * behind it reads the session itself, and the resource server behind *that* validates an access\n * token. A forged cookie gets someone as far as a page that will find no session and say so.\n * Treating this as the check is how a middleware becomes load-bearing and then gets edited by\n * someone who does not know it is.\n *\n * So this module imports nothing from the package. That is deliberate and it has a cost: the\n * cookie's name is written here a second time, and `next-middleware.test.ts` ties the two together\n * by signing in through a fake realm and asserting the default is the name `relyingParty` actually\n * emitted. A transcribed constant with no test is how the number in a generated fixture goes stale.\n *\n * ## The matcher\n *\n * A `middleware.ts` also exports its own `config.matcher`, and
|
|
1
|
+
{"version":3,"file":"next-middleware.js","sources":["../src/next-middleware.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from \"next/server\";\n\n/**\n * The edge middleware that sends an anonymous browser to the sign-in route.\n *\n * ```ts\n * // middleware.ts\n * export const middleware = authMiddleware({ public: [\"/health\"] });\n * ```\n *\n * ## It checks for presence, and nothing else\n *\n * The cookie is sealed with JWE, and this file never opens it — no key, no `jose`, no store. Two\n * reasons, and the second is the one that matters:\n *\n * 1. Middleware runs on every request, including the ones that are about to be answered from a\n * cache. Decrypting there buys a redirect decision that a route handler and `authSession` are\n * both going to make again, properly, a millisecond later.\n * 2. **A redirect is not an authorization.** Letting a request past here grants nothing: the page\n * behind it reads the session itself, and the resource server behind *that* validates an access\n * token. A forged cookie gets someone as far as a page that will find no session and say so.\n * Treating this as the check is how a middleware becomes load-bearing and then gets edited by\n * someone who does not know it is.\n *\n * So this module imports nothing from the package. That is deliberate and it has a cost: the\n * cookie's name is written here a second time, and `next-middleware.test.ts` ties the two together\n * by signing in through a fake realm and asserting the default is the name `relyingParty` actually\n * emitted. A transcribed constant with no test is how the number in a generated fixture goes stale.\n *\n * ## The matcher\n *\n * A `middleware.ts` also exports its own `config.matcher`, and what belongs in it was learned the\n * expensive way: a matcher that missed static files sent `/fossil/fossil_wasm_bg.wasm` to the\n * sign-in page, and the app loaded without its WebAssembly. The exemption is applied *here* as\n * well, on the path, so it holds whatever matcher a consumer writes — a fix for the class rather\n * than for the regexp. The matcher a product starts from:\n *\n * ```ts\n * export const config = { matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\..*).*)\"] };\n * ```\n */\n\n/**\n * The cookie `relyingParty` issues: `sealedCookie` prefixes every name with `__Host-`.\n *\n * Held by \"defaults to the cookie name relyingParty actually issues\" in `next-middleware.test.ts`.\n */\nconst SESSION_COOKIE = \"__Host-kanzo-session\";\n\n/** `authRoutes`'s default `problemPage`, repeated because importing it would put `openid-client` on the edge. */\nconst PROBLEM_PAGE = \"/auth/problem\";\n\nexport interface AuthMiddlewareConfig {\n /**\n * Path prefixes that need no session — a health probe, a marketing page, a legal notice.\n *\n * Matched on segment boundaries, so `/health` exempts `/health` and `/health/live` and does\n * **not** exempt `/healthcare`. A plain `startsWith` is one keystroke away from opening a route\n * nobody meant to open.\n */\n readonly public?: readonly string[];\n /** Where `authRoutes` is mounted. Always public — it is how a person signs in. Default `/api/auth`. */\n readonly basePath?: string;\n /**\n * The `problemPage` given to `authRoutes`. Always public — whoever is sent there has no session,\n * and sending them to sign in instead is a loop when signing in is what failed. Default\n * `/auth/problem`.\n */\n readonly problemPage?: string;\n /** Default `__Host-kanzo-session`. Set it only if `relyingParty` was given a different cookie. */\n readonly cookieName?: string;\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\nexport function authMiddleware(\n config: AuthMiddlewareConfig = {},\n): (request: NextRequest) => NextResponse {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const cookieName = config.cookieName ?? SESSION_COOKIE;\n const open = [base, config.problemPage ?? PROBLEM_PAGE, ...(config.public ?? [])].map((prefix) =>\n prefix.replace(/\\/$/, \"\"),\n );\n\n return (request) => {\n const { pathname, search } = request.nextUrl;\n\n if (isFile(pathname)) return NextResponse.next();\n if (open.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) {\n return NextResponse.next();\n }\n if (request.cookies.has(cookieName)) return NextResponse.next();\n\n const away = new URL(`${base}/signin`, request.nextUrl);\n // Where they were going, so the callback can put them back. `authRoutes` confines it to this\n // origin before sealing it, which is the check this line is relying on rather than repeating.\n away.searchParams.set(\"returnTo\", `${pathname}${search}`);\n return NextResponse.redirect(away);\n };\n}\n"],"names":["SESSION_COOKIE","PROBLEM_PAGE","isFile","pathname","authMiddleware","config","base","cookieName","open","prefix","request","search","NextResponse","away"],"mappings":";AA+CA,MAAMA,IAAiB,wBAGjBC,IAAe;AAwBrB,SAASC,EAAOC,GAA2B;AACzC,SAAOA,EAAS,MAAMA,EAAS,YAAY,GAAG,IAAI,CAAC,EAAE,SAAS,GAAG;AACnE;AAEO,SAASC,EACdC,IAA+B,IACS;AACxC,QAAMC,KAAQD,EAAO,YAAY,aAAa,QAAQ,OAAO,EAAE,GACzDE,IAAaF,EAAO,cAAcL,GAClCQ,IAAO,CAACF,GAAMD,EAAO,eAAeJ,GAAc,GAAII,EAAO,UAAU,CAAA,CAAG,EAAE;AAAA,IAAI,CAACI,MACrFA,EAAO,QAAQ,OAAO,EAAE;AAAA,EAAA;AAG1B,SAAO,CAACC,MAAY;AAClB,UAAM,EAAE,UAAAP,GAAU,QAAAQ,EAAA,IAAWD,EAAQ;AAErC,QAAIR,EAAOC,CAAQ,EAAG,QAAOS,EAAa,KAAA;AAC1C,QAAIJ,EAAK,KAAK,CAACC,MAAWN,MAAaM,KAAUN,EAAS,WAAW,GAAGM,CAAM,GAAG,CAAC;AAChF,aAAOG,EAAa,KAAA;AAEtB,QAAIF,EAAQ,QAAQ,IAAIH,CAAU,EAAG,QAAOK,EAAa,KAAA;AAEzD,UAAMC,IAAO,IAAI,IAAI,GAAGP,CAAI,WAAWI,EAAQ,OAAO;AAGtD,WAAAG,EAAK,aAAa,IAAI,YAAY,GAAGV,CAAQ,GAAGQ,CAAM,EAAE,GACjDC,EAAa,SAASC,CAAI;AAAA,EACnC;AACF;"}
|
package/dist/next-session.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ import { Session } from './types';
|
|
|
28
28
|
* ## `cache`, and what it is for
|
|
29
29
|
*
|
|
30
30
|
* React's `cache` scopes memoization to one request, so a page that asks in a layout, in a
|
|
31
|
-
* breadcrumb and in a menu unseals the cookie once.
|
|
32
|
-
*
|
|
31
|
+
* breadcrumb and in a menu unseals the cookie once. A host that wraps its own reader does it for
|
|
32
|
+
* exactly this reason. **It is dormant outside a React request scope** — `cache`
|
|
33
33
|
* with no dispatcher simply calls through — which is why the test beside this file asserts the
|
|
34
34
|
* answers and not the number of reads.
|
|
35
35
|
*/
|
package/dist/next-session.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next-session.js","sources":["../src/next-session.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { cache } from \"react\";\nimport { relyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\n\n/**\n * The session a React Server Component can read, without a request object in hand.\n *\n * An RSC is handed no `Request`; `next/headers` is how it reaches the one it is rendering for, and\n * that import is what makes this module — and only this module of the three — Node-only. It is\n * also what earns the whole door its place on a subpath: *a part belongs on a subpath only if it\n * imports that subpath's engine.*\n *\n * ## Why a factory, when the call site is `await getSession()`\n *\n * The call site is preserved exactly; what moved is where the binding is made. A\n * zero-argument import would have to find its secret somewhere ambient — an environment variable\n * this package would then be naming and documenting forever — and it could never be given a\n * {@link SessionStore}, which is an object and not a string. So the consumer binds it once, in the\n * same module that already holds the config it hands {@link authRoutes}:\n *\n * ```ts\n * // auth.ts\n * export const getSession = authSession({ issuer, clientId, clientSecret, secret });\n * ```\n *\n * and every server component writes `await getSession()`. That also makes the three names of this\n * door one shape — `authRoutes`, `authSession`, `authMiddleware` are all factories over a config —\n * rather than two factories and an exception.\n *\n * ## `cache`, and what it is for\n *\n * React's `cache` scopes memoization to one request, so a page that asks in a layout, in a\n * breadcrumb and in a menu unseals the cookie once.
|
|
1
|
+
{"version":3,"file":"next-session.js","sources":["../src/next-session.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { cache } from \"react\";\nimport { relyingParty, type RelyingPartyConfig } from \"./server\";\nimport type { Session } from \"./types\";\n\n/**\n * The session a React Server Component can read, without a request object in hand.\n *\n * An RSC is handed no `Request`; `next/headers` is how it reaches the one it is rendering for, and\n * that import is what makes this module — and only this module of the three — Node-only. It is\n * also what earns the whole door its place on a subpath: *a part belongs on a subpath only if it\n * imports that subpath's engine.*\n *\n * ## Why a factory, when the call site is `await getSession()`\n *\n * The call site is preserved exactly; what moved is where the binding is made. A\n * zero-argument import would have to find its secret somewhere ambient — an environment variable\n * this package would then be naming and documenting forever — and it could never be given a\n * {@link SessionStore}, which is an object and not a string. So the consumer binds it once, in the\n * same module that already holds the config it hands {@link authRoutes}:\n *\n * ```ts\n * // auth.ts\n * export const getSession = authSession({ issuer, clientId, clientSecret, secret });\n * ```\n *\n * and every server component writes `await getSession()`. That also makes the three names of this\n * door one shape — `authRoutes`, `authSession`, `authMiddleware` are all factories over a config —\n * rather than two factories and an exception.\n *\n * ## `cache`, and what it is for\n *\n * React's `cache` scopes memoization to one request, so a page that asks in a layout, in a\n * breadcrumb and in a menu unseals the cookie once. A host that wraps its own reader does it for\n * exactly this reason. **It is dormant outside a React request scope** — `cache`\n * with no dispatcher simply calls through — which is why the test beside this file asserts the\n * answers and not the number of reads.\n */\n\n/**\n * What reading a session needs, which is strictly less than signing one in.\n *\n * `redirectUri` is absent because no authorization request is built here: {@link RelyingParty.read}\n * unseals a cookie and asks the store, and neither of those has a browser to send anywhere.\n */\nexport type AuthSessionConfig = Omit<RelyingPartyConfig, \"redirectUri\">;\n\nexport function authSession(config: AuthSessionConfig): () => Promise<Session | null> {\n // The redirect URI is a required field of the confidential client and an unused one on this\n // path. Naming it here rather than making it optional on `RelyingPartyConfig` keeps the type that\n // signs people in honest: a sign-in without a redirect URI is a configuration error.\n const auth = relyingParty({ ...config, redirectUri: \"\" });\n\n return cache(async () => auth.read((await cookies()).toString()));\n}\n"],"names":["authSession","config","auth","relyingParty","cache","cookies"],"mappings":";;;AA+CO,SAASA,EAAYC,GAA0D;AAIpF,QAAMC,IAAOC,EAAa,EAAE,GAAGF,GAAQ,aAAa,IAAI;AAExD,SAAOG,EAAM,YAAYF,EAAK,MAAM,MAAMG,EAAA,GAAW,SAAA,CAAU,CAAC;AAClE;"}
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\nconst DEFAULT_SCOPE = \"openid profile email\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nconst DEFAULT_RENEW_WITHIN = 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. keasy's Rust learned this and answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\") return \"idp/silent\";\n if (code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\") return \"idp/unreachable\";\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const fail = (error: unknown): never =>\n refuse(\"session/unavailable\", \"the session store did not answer\", error);\n return {\n async put(record) {\n try {\n return await store.put(record);\n } catch (error) {\n return fail(error);\n }\n },\n async get(ticket) {\n try {\n return await store.get(ticket);\n } catch (error) {\n return fail(error);\n }\n },\n async drop(ticket) {\n try {\n await store.drop(ticket);\n } catch (error) {\n fail(error);\n }\n },\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What the transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Adopted> => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n\n const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session/absent\", \"there is no session cookie to refresh\");\n }\n const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session/absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; the slot above exists to keep us from causing it. An IdP that did not answer has\n // refused nothing, and says so.\n throw fromIdp(error, \"token/exchange-failed\", \"the refresh token was refused\");\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n });\n };\n\n return {\n async begin(options = {}) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${options.organization}`,\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. keasy's Rust calls\n // `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const { renewed } = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (record?.idToken !== undefined) parameters[\"id_token_hint\"] = record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","fail","record","ticket","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","renew","spent","refreshTokenGrant","options","discovered","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.js","sources":["../src/server.ts"],"sourcesContent":["import {\n buildAuthorizationUrl,\n buildEndSessionUrl,\n calculatePKCECodeChallenge,\n randomNonce,\n randomPKCECodeVerifier,\n randomState,\n refreshTokenGrant,\n authorizationCodeGrant,\n type Configuration,\n} from \"openid-client\";\nimport { claims } from \"./claims\";\nimport { sealedCookie, type SealedCookie } from \"./cookie-session\";\nimport { DEADLINE } from \"./deadline\";\nimport { issuer, type IssuerConfig } from \"./issuer\";\nimport { keyedSingleFlight } from \"./single-flight\";\nimport { statelessStore, type SessionRecord, type SessionStore } from \"./store\";\nimport { AuthError, type AuthErrorCode, type Session, type SignInOptions } from \"./types\";\n\n/**\n * `@kanzo-tech/auth/server` — the confidential OAuth client.\n *\n * This is the server half of the Backend For Frontend, which RFC 10017 calls *\"strongly\n * recommended for business applications, sensitive applications, and applications that handle\n * personal data\"*. The tokens live here and the browser gets a cookie it cannot read.\n *\n * **Nothing in this file implements OAuth.** `openid-client` does the flow, the ID token\n * verification and the end-session URL; `jose` does the sealing. What is written here is the\n * three-line sequence a route handler needs, the cookie discipline around it, and the reading of\n * Keycloak's claims into our one `Session` — which is the only part no library could have.\n *\n * ## The one thing that must never change\n *\n * **This module must not reach React.** It imports its siblings directly — `./claims`, never\n * `./index` — because importing the root barrel would drag React into a Node process. That is not\n * a hypothetical: it is the exact defect that forced `@kanzo-tech/mosaic` out of\n * `@kanzo-tech/ui`, and `server.test.ts` asserts it over source.\n *\n * ## Framework-agnostic on purpose\n *\n * Strings in, strings out: a URL and a `Cookie` header go in, a URL and `Set-Cookie` values come\n * out. `./next` is a thin wrapper over this, and so is anything else — there is no `Request` in\n * the signatures because a `Request` would make Next's flavour of it the one that fits.\n */\n\nconst DEFAULT_SCOPE = \"openid profile email\";\n/** Eight hours: a working day, after which the refresh token is the thing keeping you signed in. */\nconst DEFAULT_MAX_AGE = 8 * 60 * 60;\n/** Ten minutes is long enough to type a password and short enough that an abandoned leg expires. */\nconst TRANSACTION_MAX_AGE = 10 * 60;\n/**\n * Renew an access token with a minute left on it rather than after it dies.\n *\n * The window pays for two things at once: the flight time of the request we are about to send, and\n * the clock skew between this server and the one that will validate the token. A minute covers\n * both on every deployment anyone has run; going to zero means shipping tokens that expire in the\n * air, and going large means renewing constantly on a realm with a five-minute token.\n */\nconst DEFAULT_RENEW_WITHIN = 60;\n\nfunction refuse(code: AuthErrorCode, message: string, cause?: unknown): never {\n throw new AuthError(code, message, {}, cause === undefined ? undefined : { cause });\n}\n\nfunction codeOf(error: unknown): string | undefined {\n if (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n const code = (error as { code: unknown }).code;\n return typeof code === \"string\" ? code : undefined;\n}\n\n/**\n * A nonce mismatch, told apart from every other reason a grant can fail.\n *\n * `oauth4webapi` reports every failed claim comparison under one code and names the offending\n * claim on a `cause`, and `openid-client` re-wraps that in a `ClientError` — so the claim's name\n * is two `cause` hops down. Reading it is the only way to answer \"which check failed\", which is\n * the whole point of having codes rather than a 401. The walk is bounded because a cause chain is\n * data from a library, not something to trust to terminate.\n */\nfunction isNonceMismatch(error: unknown): boolean {\n const code = codeOf(error);\n\n // A *wrong* nonce is a claim comparison, and the claim's name is carried structurally.\n if (code === \"OAUTH_JWT_CLAIM_COMPARISON_FAILED\") {\n let node: unknown = error;\n for (let depth = 0; depth < 4 && typeof node === \"object\" && node !== null; depth++) {\n if ((node as { claim?: unknown }).claim === \"nonce\") return true;\n node = (node as { cause?: unknown }).cause;\n }\n return false;\n }\n\n // A *missing* nonce is reported as a malformed response instead, and the claim's name appears\n // only in the message. Matching on a library's prose is brittle, and the answer to that is the\n // test that pins it rather than a quieter code: if `oauth4webapi` rewords this, a test fails\n // here instead of production silently reclassifying a replay as a transport problem.\n return (\n code === \"OAUTH_INVALID_RESPONSE\" &&\n error instanceof Error &&\n error.cause instanceof Error &&\n error.cause.message.includes('\"nonce\"')\n );\n}\n\n/**\n * A failure that looks like the signing keys we hold are no longer the ones Keycloak signs with.\n *\n * Keycloak rotates its realm keys, and a client holding a cached JWKS sees a key id it has never\n * heard of. A resource server answers it the same way: re-fetch the metadata *once, on\n * a failure*, and retry. Refreshing on a timer instead would be a request every few minutes that\n * is wrong exactly when it matters.\n *\n * **This path is only reachable with `verifySignatures`.** Without it no key material is consulted\n * during a code grant at all — the channel vouches for the ID token — so there is nothing to go\n * stale. The Rust needed the retry unconditionally because `openidconnect` verifies the signature\n * either way; that is a difference between the two libraries, not between the two designs.\n */\nfunction isStaleKeyMaterial(error: unknown): boolean {\n return codeOf(error) === \"OAUTH_KEY_SELECTION_FAILED\";\n}\n\n/**\n * Who did not answer, when it was the IdP: `openid-client` reports its own deadline as\n * `OAUTH_TIMEOUT`, a status that is not OAuth's (a 502 from a proxy) as `OAUTH_RESPONSE_IS_NOT_CONFORM`,\n * and a connection that never opened as the platform's uncoded `TypeError`.\n */\nfunction unanswered(error: unknown): \"idp/silent\" | \"idp/unreachable\" | undefined {\n const code = codeOf(error);\n if (code === \"OAUTH_TIMEOUT\") return \"idp/silent\";\n if (code === \"OAUTH_RESPONSE_IS_NOT_CONFORM\") return \"idp/unreachable\";\n if (error instanceof TypeError && code === undefined) return \"idp/unreachable\";\n return undefined;\n}\n\n/**\n * A Keycloak organization alias, or `*`. Anything else is not put into a scope string.\n *\n * `scope` is a **space-delimited list**, so a value with a space in it does not become one scope\n * with a space in it — it becomes two scopes, and the second one is whatever the caller wrote.\n * `?organization=x%20offline_access` reaching `begin` unchecked is an authorization request for\n * `offline_access`, which is a refresh token that outlives the browser session, asked for by\n * whoever composed the link. That is scope injection, and the place to stop it is here rather than\n * at whichever door happened to be the one taking query parameters today.\n *\n * The alphabet is Keycloak's own for an alias — it is a hostname-ish name, and the realm will not\n * mint one outside this set — plus the `*` that asks for every organization at once.\n */\nconst ORGANIZATION = /^(\\*|[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?)$/;\n\n/**\n * One renewal per ticket, for the whole process rather than per `relyingParty`.\n *\n * `singleFlight`'s own header says why a second concurrent renewal is a revoked token chain and\n * not a wasted round trip. What that header does not say is that a server builds more than one\n * relying party: `authRoutes` rebuilds its own when the derived callback URL changes, `authToken`\n * and `authProxy` each hold theirs, and an instance-level slot would let a refresh from the route\n * and a refresh from the proxy replay the same token at the same moment. The ticket names the\n * session, so the ticket is the right key, and it is the same ticket whichever instance holds it.\n *\n * **It is per process.** Two Node instances behind a load balancer can still collide, and the\n * answer to that is a `SessionStore` whose `put` is the point of coordination — not a lock here,\n * which would be a distributed one pretending to be a `Map`.\n */\nconst renewals = keyedSingleFlight<Adopted>();\n\n/** The deployment's store, failing as `session/unavailable` rather than as whatever its driver throws. */\nfunction reachable(store: SessionStore): SessionStore {\n const fail = (error: unknown): never =>\n refuse(\"session/unavailable\", \"the session store did not answer\", error);\n return {\n async put(record) {\n try {\n return await store.put(record);\n } catch (error) {\n return fail(error);\n }\n },\n async get(ticket) {\n try {\n return await store.get(ticket);\n } catch (error) {\n return fail(error);\n }\n },\n async drop(ticket) {\n try {\n await store.drop(ticket);\n } catch (error) {\n fail(error);\n }\n },\n };\n}\n\n/** What `begin` and `end` answer: where to send the browser, and what to set on the way. */\nexport interface Redirect {\n readonly url: string;\n readonly cookies: readonly string[];\n}\n\n/** What `refresh` answers. */\nexport interface Renewed {\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/** What `complete` answers: a renewal, plus where the person was going before they were asked who they are. */\nexport interface SignedIn extends Renewed {\n readonly returnTo: string;\n}\n\n/**\n * What `token` answers: the credential a resource server takes, and what to set on the way out.\n *\n * **`cookies` is not optional to attach.** It is empty when nothing was renewed and carries a\n * rotated session when something was, and under the rotation RFC 10017 requires, dropping it\n * throws away the only refresh token still valid — the session does not go stale, it ends. A\n * caller with nowhere to put a `Set-Cookie` is a caller that must not be asking for this.\n */\nexport interface Token {\n readonly accessToken: string;\n readonly session: Session;\n readonly cookies: readonly string[];\n}\n\n/**\n * Everything a successful grant produced: what the caller is told, and the record behind it.\n *\n * The two are separate and only the first is ever returned from a public method, because a\n * `SessionRecord` holds the refresh token and a `Renewed` is the sort of thing a route handler\n * writes straight into a response body. Structural typing would have let one extra field ride\n * along unnoticed all the way to the browser.\n */\ninterface Adopted {\n readonly renewed: Renewed;\n readonly record: SessionRecord;\n}\n\nexport interface RelyingPartyConfig extends IssuerConfig {\n /** Registered at Keycloak, and where `complete` expects to be called. */\n readonly redirectUri: string;\n /** Seals the cookies. Any length; generate it. See `sealedCookie`. */\n readonly secret: string | Uint8Array;\n /** Default `openid profile email`. A multi-tenant product adds `organization:*`. */\n readonly scope?: string;\n /** Default {@link statelessStore}. Supply one to invalidate a session before it expires. */\n readonly store?: SessionStore;\n /** Session cookie lifetime in seconds. Default eight hours. */\n readonly maxAge?: number;\n /** Where Keycloak sends the browser after sign-out. Must be registered as a post-logout URI. */\n readonly postLogoutRedirectUri?: string;\n}\n\nexport interface RelyingParty {\n /** Leg one: the authorization URL, and the cookie that remembers this attempt. */\n begin(options?: SignInOptions): Promise<Redirect>;\n /** Leg two: the callback URL Keycloak returned to, and the `Cookie` header it arrived with. */\n complete(request: { readonly url: string | URL; readonly cookie: string | null }): Promise<SignedIn>;\n /** The session a request carries, or `null`. The read a route handler does on every request. */\n read(cookie: string | null | undefined): Promise<Session | null>;\n /**\n * The access token a request carries, renewed when it is about to expire — or `null` when there\n * is no session at all.\n *\n * This is the *token-mediating backend*: the browser holds a cookie, the resource server is\n * given a bearer token, and the two never meet. {@link read} is its sibling for identity, and\n * the difference in the signature is the whole of the difference in what they may be called\n * from — this one can answer with a `Set-Cookie` and therefore must be called somewhere that can\n * send one.\n *\n * Renewal is single-flight per ticket, so a page that fires eight requests at an expiring token\n * spends it once.\n */\n token(\n cookie: string | null | undefined,\n options?: { readonly renewWithin?: number },\n ): Promise<Token | null>;\n /**\n * Spend the refresh token, take the new one, and reissue the cookie.\n *\n * Single-flight per ticket across the whole process: a second concurrent call joins the first\n * rather than replaying a token it already spent. See `renewals`.\n */\n refresh(cookie: string | null | undefined): Promise<Renewed>;\n /** RP-initiated logout: forget the record here, clear the cookie, and end it at the IdP too. */\n end(cookie: string | null | undefined, options?: { readonly returnTo?: string }): Promise<Redirect>;\n}\n\n/** Everything either grant returns: one type, because both are answers from the token endpoint. */\ntype Tokens = Awaited<ReturnType<typeof refreshTokenGrant>>;\n\n/** What the session cookie carries: a ticket into the store, and nothing a browser could use. */\ninterface SessionTicket {\n readonly ticket: string;\n}\n\n/** What the transaction cookie carries between the two legs. */\ninterface Transaction {\n readonly state: string;\n readonly nonce: string;\n readonly verifier: string;\n readonly returnTo: string;\n}\n\nexport function relyingParty(config: RelyingPartyConfig): RelyingParty {\n const provider = issuer(config);\n const store = reachable(config.store ?? statelessStore());\n const after = DEADLINE;\n\n /** A failure to reach the IdP, coded by who did not answer; anything else as `fallback`. */\n const fromIdp = (error: unknown, fallback: AuthErrorCode, message: string): AuthError => {\n const code = unanswered(error) ?? fallback;\n return new AuthError(code, message, code === \"idp/silent\" ? { after } : {}, { cause: error });\n };\n\n /** Discovery, failing as the IdP's outage rather than as an uncoded error. */\n const configuration = async (): Promise<Configuration> => {\n try {\n return await provider.configuration();\n } catch (error) {\n throw fromIdp(error, \"idp/unreachable\", \"the IdP's discovery document could not be read\");\n }\n };\n\n const session: SealedCookie<SessionTicket> = sealedCookie({\n name: \"kanzo-session\",\n secret: config.secret,\n maxAge: config.maxAge ?? DEFAULT_MAX_AGE,\n });\n\n // A second cookie rather than a field on the first, because its lifetime is different by two\n // orders of magnitude and it must be gone the moment the callback has used it.\n const transaction: SealedCookie<Transaction> = sealedCookie({\n name: \"kanzo-auth\",\n secret: config.secret,\n maxAge: TRANSACTION_MAX_AGE,\n });\n\n const recordFrom = async (cookie: string | null | undefined): Promise<SessionRecord | null> => {\n const sealed = await session.read(cookie);\n if (sealed === null) return null;\n return store.get(sealed.ticket);\n };\n\n /** Everything a successful grant produces, in the one place both grants can use it. */\n const adopt = async (tokens: Tokens, previous: SessionRecord | null): Promise<Adopted> => {\n // A refresh that returns no new ID token leaves the identity as it was; only the tokens moved.\n const idClaims = tokens.claims();\n const next = idClaims === undefined ? previous?.session : claims(idClaims, config);\n if (next === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no ID token, so it names nobody\");\n }\n\n // `expiresIn()` counts down from the moment the response was parsed, which is the only honest\n // reading: the token endpoint says `expires_in`, never an absolute time, because it has no\n // opinion about our clock. Absent, the expiry is unknown rather than zero — a record that\n // claimed to have expired at the epoch would be renewed on every single request.\n const lifetime = tokens.expiresIn();\n\n const record: SessionRecord = {\n session: next,\n accessToken: tokens.access_token,\n accessTokenExpiresAt: lifetime === undefined ? undefined : Date.now() + lifetime * 1000,\n // RFC 10017 requires rotation, so the newly issued token is the only one still valid. An\n // authorization server that did not rotate returns none, and the one we hold stays good.\n refreshToken: tokens.refresh_token ?? previous?.refreshToken,\n idToken: tokens.id_token ?? previous?.idToken,\n };\n\n const ticket = await store.put(record);\n return { renewed: { session: next, cookies: [await session.seal({ ticket })] }, record };\n };\n\n /** The renewal both `refresh` and `token` run, with the record they each need a different half of. */\n const renew = async (cookie: string | null | undefined): Promise<Adopted> => {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed === null || record === null) {\n refuse(\"session/absent\", \"there is no session cookie to refresh\");\n }\n const spent = record.refreshToken;\n if (spent === undefined) {\n refuse(\"session/absent\", \"the session holds no refresh token, so it cannot be renewed\");\n }\n\n // Everything above is a read and may run concurrently; everything below spends a token that can\n // only be spent once, so it is the half behind the slot. A caller that joins gets the cookie\n // the first one was issued, which is the cookie it would have been issued anyway.\n return renewals(sealed.ticket, async () => {\n let tokens: Tokens;\n try {\n tokens = await refreshTokenGrant(await configuration(), spent);\n } catch (error) {\n if (error instanceof AuthError) throw error;\n // Under rotation a refused refresh is often a *replayed* token rather than an expired one,\n // and the authorization server may have revoked the whole chain. Either way the session is\n // over; the slot above exists to keep us from causing it. An IdP that did not answer has\n // refused nothing, and says so.\n throw fromIdp(error, \"token/exchange-failed\", \"the refresh token was refused\");\n }\n\n // The superseded ticket goes first: a store that enforces one live session per person must\n // not briefly hold two, and for the stateless default this is a no-op.\n await store.drop(sealed.ticket);\n return adopt(tokens, record);\n });\n };\n\n return {\n async begin(options = {}) {\n const discovered = await configuration();\n\n const verifier = randomPKCECodeVerifier();\n const state = randomState();\n const nonce = randomNonce();\n\n if (options.organization !== undefined && !ORGANIZATION.test(options.organization)) {\n refuse(\n \"organization/invalid\",\n `\\`${options.organization}\\` is not an organization alias, and a scope is a space-delimited list: see ORGANIZATION`,\n );\n }\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirectUri,\n // `organization:<alias>` asks Keycloak for one; a product with many asks for\n // `organization:*` through `scope`, because plain `organization` prompts for a choice.\n scope:\n options.organization === undefined\n ? (config.scope ?? DEFAULT_SCOPE)\n : `${config.scope ?? DEFAULT_SCOPE} organization:${options.organization}`,\n code_challenge: await calculatePKCECodeChallenge(verifier),\n code_challenge_method: \"S256\",\n state,\n nonce,\n };\n\n return {\n url: buildAuthorizationUrl(discovered, parameters).href,\n cookies: [\n await transaction.seal({ state, nonce, verifier, returnTo: options.returnTo ?? \"/\" }),\n ],\n };\n },\n\n async complete(request) {\n const pending = await transaction.read(request.cookie);\n if (pending === null) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback arrived with no transaction cookie, so there is nothing to match its `state` against\",\n );\n }\n\n const current = new URL(request.url);\n if (current.searchParams.get(\"state\") !== pending.state) {\n refuse(\n \"callback/state-mismatch\",\n \"the callback's `state` is not the one this browser was sent with\",\n );\n }\n\n const checks = {\n pkceCodeVerifier: pending.verifier,\n expectedState: pending.state,\n expectedNonce: pending.nonce,\n };\n\n const grant = (configuration: Configuration) =>\n authorizationCodeGrant(configuration, current, checks);\n\n let tokens: Tokens;\n try {\n tokens = await grant(await configuration());\n } catch (error) {\n if (error instanceof AuthError) throw error;\n if (isNonceMismatch(error)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n error,\n );\n }\n if (!isStaleKeyMaterial(error)) {\n throw fromIdp(error, \"token/exchange-failed\", \"the authorization code could not be exchanged\");\n }\n // Keycloak rotated its signing key. One re-discovery, one retry, then give up — a loop\n // here is a self-inflicted denial of service against the identity provider.\n try {\n tokens = await grant(await provider.rediscover());\n } catch (retried) {\n if (isNonceMismatch(retried)) {\n refuse(\n \"callback/nonce-mismatch\",\n \"the ID token's `nonce` is not the one this transaction sent\",\n retried,\n );\n }\n throw fromIdp(\n retried,\n \"token/exchange-failed\",\n \"the ID token did not verify, and did not verify against freshly discovered keys either\",\n );\n }\n }\n\n // Session fixation: the record is new, the ticket is new and the cookie is new, and any\n // session cookie this callback happened to arrive with is not read. A server-side session\n // store calls `cycle_id()` here for the same reason — an attacker who planted a session before sign-in\n // must not find themselves holding the one that sign-in produced.\n const { renewed } = await adopt(tokens, null);\n\n return {\n ...renewed,\n cookies: [...renewed.cookies, transaction.clear()],\n returnTo: pending.returnTo,\n };\n },\n\n async read(cookie) {\n return (await recordFrom(cookie))?.session ?? null;\n },\n\n async token(cookie, options = {}) {\n const record = await recordFrom(cookie);\n if (record === null) return null;\n\n const within = (options.renewWithin ?? DEFAULT_RENEW_WITHIN) * 1000;\n const held = record.accessToken;\n // An unknown expiry is not treated as expired: a realm that omits `expires_in` would\n // otherwise be renewed on every request, which is the replay this package exists to avoid.\n const stale =\n record.accessTokenExpiresAt !== undefined &&\n record.accessTokenExpiresAt - Date.now() <= within;\n\n if (held !== undefined && !stale) {\n return { accessToken: held, session: record.session, cookies: [] };\n }\n\n const { renewed, record: fresh } = await renew(cookie);\n if (fresh.accessToken === undefined) {\n refuse(\"token/exchange-failed\", \"the token response carried no access token\");\n }\n return { accessToken: fresh.accessToken, session: renewed.session, cookies: renewed.cookies };\n },\n\n async refresh(cookie) {\n return (await renew(cookie)).renewed;\n },\n\n async end(cookie, options = {}) {\n const sealed = await session.read(cookie);\n const record = sealed === null ? null : await store.get(sealed.ticket);\n if (sealed !== null) await store.drop(sealed.ticket);\n\n const parameters: Record<string, string> = {};\n const returnTo = options.returnTo ?? config.postLogoutRedirectUri;\n if (returnTo !== undefined) parameters[\"post_logout_redirect_uri\"] = returnTo;\n // Without the hint Keycloak cannot tell which session is ending and asks the person to\n // confirm — which reads as a bug to everyone who sees it.\n if (record?.idToken !== undefined) parameters[\"id_token_hint\"] = record.idToken;\n\n // `buildEndSessionUrl` rather than a hand-built URL: the endpoint comes from discovery, and\n // the parameter names are the specification's rather than ours to remember.\n return {\n url: buildEndSessionUrl(await configuration(), parameters).href,\n cookies: [session.clear()],\n };\n },\n };\n}\n\nexport { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from \"./issuer\";\nexport { sealedCookie, cookieValue, type SealedCookie, type SealedCookieConfig } from \"./cookie-session\";\nexport {\n statelessStore,\n ticketStore,\n type SessionRecord,\n type SessionStore,\n type TicketAdapter,\n type TicketStoreConfig,\n} from \"./store\";\n"],"names":["DEFAULT_SCOPE","DEFAULT_MAX_AGE","TRANSACTION_MAX_AGE","DEFAULT_RENEW_WITHIN","refuse","code","message","cause","AuthError","codeOf","error","isNonceMismatch","node","depth","isStaleKeyMaterial","unanswered","ORGANIZATION","renewals","keyedSingleFlight","reachable","store","fail","record","ticket","relyingParty","config","provider","issuer","statelessStore","after","DEADLINE","fromIdp","fallback","configuration","session","sealedCookie","transaction","recordFrom","cookie","sealed","adopt","tokens","previous","idClaims","next","claims","lifetime","renew","spent","refreshTokenGrant","options","discovered","verifier","randomPKCECodeVerifier","state","randomState","nonce","randomNonce","parameters","calculatePKCECodeChallenge","buildAuthorizationUrl","request","pending","current","checks","grant","authorizationCodeGrant","retried","renewed","_a","within","held","stale","fresh","returnTo","buildEndSessionUrl"],"mappings":"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"}
|
package/dist/store.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Session } from './types';
|
|
|
4
4
|
*
|
|
5
5
|
* The default is a cookie and nothing else: the record is sealed into it, and the deployment needs
|
|
6
6
|
* no database to hold a session. That is the right default and it is not sufficient for everyone —
|
|
7
|
-
*
|
|
7
|
+
* a product that enforces **one live session per user** and wants a sign-out
|
|
8
8
|
* to take effect immediately, and a self-contained cookie can do neither. Both are the same
|
|
9
9
|
* missing ability: a cookie already in someone's hands cannot be taken back.
|
|
10
10
|
*
|
package/dist/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport type { Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * keasy's `server/src/db/sessions.rs` enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*\n * expiry and is a different number on a realm that gives the two different lifetimes; renewing\n * against the wrong one is how a request goes out with a credential that died a minute ago.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\nexport interface SessionStore {\n /**\n * Store a record and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing\n * out clears the cookie, which is enough for the person holding the browser and is not enough for\n * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session\n * lifetime is therefore a real security parameter under this store, and \"sign out everywhere\" is\n * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n };\n}\n\n/**\n * The two functions and a delete that a store needs from a deployment's own database.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * relyingParty({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * delete: (key) => redis.del(key),\n * }),\n * });\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject, and that is deliberate\n *\n * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is\n * not a secret and is not trusted on the way back in, because the record it names is read from the\n * row and never from the key. What the prefix buys is the one operation a flat random key makes\n * impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE\n * 'sub:%'` is then a query a deployment can write, and \"sign out on every device\" and \"one live\n * session per person\" — which `put` is the place for — stop being features this package has to\n * grow an API for.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // `encodeURIComponent` on the subject, not on the whole key: a `sub` is a uuid on every realm\n // anyone has seen, and on the one that makes it something with a colon in it the prefix must\n // still be the prefix. The random half needs no encoding — base64url is already key-safe.\n const ticket = `${encodeURIComponent(record.session.user.id)}:${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n };\n}\n"],"names":["statelessStore","record","ticket","DEFAULT_TTL","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","value"],"mappings":";AA2FO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIC,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,EAAA;AAEJ;AAwCA,MAAMC,IAAc,MAAS;AAM7B,SAASC,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AA0CO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOL,GACpBO,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIV,GAAQ;AAIhB,YAAMC,IAAS,GAAG,mBAAmBD,EAAO,QAAQ,KAAK,EAAE,CAAC,IAAIG,EAAA,CAAc;AAC9E,mBAAMM,EAAQ,MAAMH,EAAQ,MAAML,GAAQ,KAAK,UAAUD,CAAM,GAAGQ,CAAG,CAAC,GAC/DP;AAAA,IACT;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMW,IAAQ,MAAMH,EAAQ,MAAMH,EAAQ,KAAKL,CAAM,CAAC;AACtD,UAAIW,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKX,GAAQ;AACjB,YAAMQ,EAAQ,MAAMH,EAAQ,OAAOL,CAAM,CAAC;AAAA,IAC5C;AAAA,EAAA;AAEJ;"}
|
|
1
|
+
{"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport type { Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * a product that enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*\n * expiry and is a different number on a realm that gives the two different lifetimes; renewing\n * against the wrong one is how a request goes out with a credential that died a minute ago.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\nexport interface SessionStore {\n /**\n * Store a record and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing\n * out clears the cookie, which is enough for the person holding the browser and is not enough for\n * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session\n * lifetime is therefore a real security parameter under this store, and \"sign out everywhere\" is\n * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n };\n}\n\n/**\n * The two functions and a delete that a store needs from a deployment's own database.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * relyingParty({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * delete: (key) => redis.del(key),\n * }),\n * });\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject, and that is deliberate\n *\n * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is\n * not a secret and is not trusted on the way back in, because the record it names is read from the\n * row and never from the key. What the prefix buys is the one operation a flat random key makes\n * impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE\n * 'sub:%'` is then a query a deployment can write, and \"sign out on every device\" and \"one live\n * session per person\" — which `put` is the place for — stop being features this package has to\n * grow an API for.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // `encodeURIComponent` on the subject, not on the whole key: a `sub` is a uuid on every realm\n // anyone has seen, and on the one that makes it something with a colon in it the prefix must\n // still be the prefix. The random half needs no encoding — base64url is already key-safe.\n const ticket = `${encodeURIComponent(record.session.user.id)}:${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n };\n}\n"],"names":["statelessStore","record","ticket","DEFAULT_TTL","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","value"],"mappings":";AA2FO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIC,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,EAAA;AAEJ;AAwCA,MAAMC,IAAc,MAAS;AAM7B,SAASC,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AA0CO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOL,GACpBO,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIV,GAAQ;AAIhB,YAAMC,IAAS,GAAG,mBAAmBD,EAAO,QAAQ,KAAK,EAAE,CAAC,IAAIG,EAAA,CAAc;AAC9E,mBAAMM,EAAQ,MAAMH,EAAQ,MAAML,GAAQ,KAAK,UAAUD,CAAM,GAAGQ,CAAG,CAAC,GAC/DP;AAAA,IACT;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMW,IAAQ,MAAMH,EAAQ,MAAMH,EAAQ,KAAKL,CAAM,CAAC;AACtD,UAAIW,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKX,GAAQ;AACjB,YAAMQ,EAAQ,MAAMH,EAAQ,OAAOL,CAAM,CAAC;AAAA,IAC5C;AAAA,EAAA;AAEJ;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Kanzo authentication over Keycloak — the claim vocabulary read into one Session, the role evaluation that knows about organizations, and an authenticated fetch. 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",
|