@kanzo-tech/auth 0.2.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.
Files changed (90) hide show
  1. package/README.md +103 -0
  2. package/dist/auth-context.d.ts +13 -0
  3. package/dist/auth-context.d.ts.map +1 -0
  4. package/dist/auth-context.js +7 -0
  5. package/dist/auth-context.js.map +1 -0
  6. package/dist/auth-fetch.d.ts +32 -0
  7. package/dist/auth-fetch.d.ts.map +1 -0
  8. package/dist/auth-fetch.js +20 -0
  9. package/dist/auth-fetch.js.map +1 -0
  10. package/dist/auth-provider.d.ts +12 -0
  11. package/dist/auth-provider.d.ts.map +1 -0
  12. package/dist/auth-provider.js +35 -0
  13. package/dist/auth-provider.js.map +1 -0
  14. package/dist/bff-auth.d.ts +32 -0
  15. package/dist/bff-auth.d.ts.map +1 -0
  16. package/dist/bff-auth.js +70 -0
  17. package/dist/bff-auth.js.map +1 -0
  18. package/dist/browser.d.ts +61 -0
  19. package/dist/browser.d.ts.map +1 -0
  20. package/dist/browser.js +107 -0
  21. package/dist/browser.js.map +1 -0
  22. package/dist/can.d.ts +25 -0
  23. package/dist/can.d.ts.map +1 -0
  24. package/dist/can.js +12 -0
  25. package/dist/can.js.map +1 -0
  26. package/dist/claims.d.ts +50 -0
  27. package/dist/claims.d.ts.map +1 -0
  28. package/dist/claims.js +56 -0
  29. package/dist/claims.js.map +1 -0
  30. package/dist/cookie-session.d.ts +28 -0
  31. package/dist/cookie-session.d.ts.map +1 -0
  32. package/dist/cookie-session.js +51 -0
  33. package/dist/cookie-session.js.map +1 -0
  34. package/dist/gate.d.ts +16 -0
  35. package/dist/gate.d.ts.map +1 -0
  36. package/dist/gate.js +17 -0
  37. package/dist/gate.js.map +1 -0
  38. package/dist/host.d.ts +22 -0
  39. package/dist/host.d.ts.map +1 -0
  40. package/dist/host.js +10 -0
  41. package/dist/host.js.map +1 -0
  42. package/dist/index.d.ts +64 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +28 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/issuer.d.ts +92 -0
  47. package/dist/issuer.d.ts.map +1 -0
  48. package/dist/issuer.js +42 -0
  49. package/dist/issuer.js.map +1 -0
  50. package/dist/next-middleware.d.ts +17 -0
  51. package/dist/next-middleware.d.ts.map +1 -0
  52. package/dist/next-middleware.js +21 -0
  53. package/dist/next-middleware.js.map +1 -0
  54. package/dist/next-routes.d.ts +19 -0
  55. package/dist/next-routes.d.ts.map +1 -0
  56. package/dist/next-routes.js +65 -0
  57. package/dist/next-routes.js.map +1 -0
  58. package/dist/next-session.d.ts +44 -0
  59. package/dist/next-session.d.ts.map +1 -0
  60. package/dist/next-session.js +11 -0
  61. package/dist/next-session.js.map +1 -0
  62. package/dist/next.d.ts +42 -0
  63. package/dist/next.d.ts.map +1 -0
  64. package/dist/next.js +9 -0
  65. package/dist/next.js.map +1 -0
  66. package/dist/server.d.ts +53 -0
  67. package/dist/server.d.ts.map +1 -0
  68. package/dist/server.js +157 -0
  69. package/dist/server.js.map +1 -0
  70. package/dist/single-flight.d.ts +15 -0
  71. package/dist/single-flight.d.ts.map +1 -0
  72. package/dist/single-flight.js +10 -0
  73. package/dist/single-flight.js.map +1 -0
  74. package/dist/store.d.ts +52 -0
  75. package/dist/store.d.ts.map +1 -0
  76. package/dist/store.js +20 -0
  77. package/dist/store.js.map +1 -0
  78. package/dist/types.d.ts +118 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +9 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/use-organization.d.ts +22 -0
  83. package/dist/use-organization.d.ts.map +1 -0
  84. package/dist/use-organization.js +20 -0
  85. package/dist/use-organization.js.map +1 -0
  86. package/dist/use-session.d.ts +13 -0
  87. package/dist/use-session.d.ts.map +1 -0
  88. package/dist/use-session.js +19 -0
  89. package/dist/use-session.js.map +1 -0
  90. package/package.json +135 -0
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @kanzo-tech/auth
2
+
3
+ Authentication over Keycloak: the claim vocabulary read into one session, the role evaluation that
4
+ knows about organizations, and an authenticated `fetch`.
5
+
6
+ ## What it is not
7
+
8
+ **There is no sign-in screen.** That is the shape of the package rather than a gap in it. A sign-in
9
+ screen is a logo, a legal line, a privacy notice and a button, and every product answers those
10
+ differently — `@kanzo-tech/ui` has the parts to draw one. What is genuinely shared sits underneath,
11
+ and that is what is here.
12
+
13
+ **There is no protocol in it either.** PKCE, silent renewal, token storage and cross-tab
14
+ coordination are `oidc-client-ts`'s job, behind `./browser`; the confidential client is
15
+ `openid-client`'s, behind `./server`. Writing either by hand is where mistakes turn into
16
+ vulnerabilities, and neither is what this package is for.
17
+
18
+ What is left after those two subtractions is everything this package is: **Keycloak's claims as one
19
+ `Session`, a role predicate that understands organizations, one `fetch` that stays authenticated,
20
+ and the same session shape across two deployment patterns that otherwise share no code.**
21
+
22
+ ## Why a package, and not `@kanzo-tech/ui`
23
+
24
+ The [first admission rule](https://kanzo-tech.github.io/ui/docs/philosophy#admission) is *domain-free
25
+ — nothing about RDF / SHACL / fossil / graphs / **auth***. Auth is excluded by name, deliberately:
26
+ `ui` is the generic vocabulary every product shares. The rule was written after a sidebar composite
27
+ shipped a hard-coded log-out flow, confirmation dialog and untranslatable copy included.
28
+
29
+ ## The one rule to read first
30
+
31
+ **What a client knows about its roles is for drawing, never for deciding.**
32
+
33
+ The roles in a `Session` are a copy, and a copy is something an attacker controls the moment it
34
+ reaches the browser. `can` hides a button; the resource server, validating the access token it was
35
+ sent, is what refuses the request behind it. A product that gates only in the browser has not gated
36
+ anything.
37
+
38
+ The same rule one level down: **never read the access token in the client.** It is opaque to a
39
+ client by definition, its format is not guaranteed, and it may be encrypted for the resource —
40
+ depending on its contents is, in Microsoft's words for the same mistake, *"one of the most common
41
+ sources of errors and client logic breakage."* Roles for drawing come from the session, not from
42
+ prising open a token.
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ pnpm add @kanzo-tech/auth
48
+ ```
49
+
50
+ That is the whole of it for a single-page application: the root barrel carries no engine. The other
51
+ doors each name theirs — `oidc-client-ts` for `./browser`, `openid-client` and `jose` for
52
+ `./server`, `next` for `./next` — and a consumer installs only the one it opens.
53
+
54
+ ## The claims it reads
55
+
56
+ Nothing here is invented. Every claim is one Keycloak emits without being asked:
57
+
58
+ | claim | becomes |
59
+ | --- | --- |
60
+ | `sub`, `email`, `name` (or `given_name` + `family_name`), `preferred_username` | `session.user` |
61
+ | `realm_access.roles` ∪ `resource_access.<clientId>.roles` | `session.roles` |
62
+ | `organization` — `{ "acme": { "id": "…", "groups": ["/keasy/owner"] } }` | `session.organizations` |
63
+ | `exp` | `session.expiresAt`, in milliseconds |
64
+
65
+ A group path's **first segment is the application**: `/keasy/owner` is a role in keasy, `/hub/reader`
66
+ is not, and a single-segment `/owner` is granted across all of them. Without that filtering, a role
67
+ held in one application would authorise its holder in another.
68
+
69
+ Ask Keycloak for `organization:*` to receive every organization the person belongs to. Plain
70
+ `organization` returns the only one when there is one and prompts for a choice when there are
71
+ several, which is the documented behaviour behind more than one bug report about the claim
72
+ "disappearing".
73
+
74
+ ## Membership is in the session; the active organization is not
75
+
76
+ ```ts
77
+ interface Session {
78
+ user: AuthUser;
79
+ roles: readonly string[];
80
+ organizations: readonly Organization[];
81
+ expiresAt: number;
82
+ }
83
+ ```
84
+
85
+ There is no active organization on it, and the absence is deliberate. Membership is stable and comes
86
+ from the token; which organization you are *looking at* is a property of the request — the URL — and
87
+ deriving it per request is what lets two tabs sit in two organizations at once. A field here would
88
+ be the single shared value they would fight over.
89
+
90
+ Roles held inside an organization live on that organization, and are never merged into
91
+ `session.roles`. Being an owner of one organization says nothing about another.
92
+
93
+ ```ts
94
+ can(session, "auditor"); // a realm or client role
95
+ can(session, "owner", "acme"); // a role inside that organization
96
+ ```
97
+
98
+ There is no role hierarchy in the predicate. That `owner` outranks `member` is a fact about a
99
+ product, so a product writes it where it can be seen:
100
+
101
+ ```ts
102
+ const isMember = (org: string) => can(session, "owner", org) || can(session, "member", org);
103
+ ```
@@ -0,0 +1,13 @@
1
+ import { Auth, Session } from './types';
2
+ /**
3
+ * The context, in its own module because two files need it and neither should import the other.
4
+ * `createContext` is what makes this file client-only.
5
+ */
6
+ export type AuthStatus = "loading" | "authenticated" | "anonymous";
7
+ export interface AuthContextValue {
8
+ readonly auth: Auth;
9
+ readonly session: Session | null;
10
+ readonly status: AuthStatus;
11
+ }
12
+ export declare const AuthContext: import('react').Context<AuthContextValue | null>;
13
+ //# sourceMappingURL=auth-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-context.d.ts","sourceRoot":"","sources":["../src/auth-context.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAE7C;;;GAGG;AAEH,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,eAAe,GAAG,WAAW,CAAC;AAEnE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;CAC7B;AAED,eAAO,MAAM,WAAW,kDAA+C,CAAC"}
@@ -0,0 +1,7 @@
1
+ "use client";
2
+ import { createContext as t } from "react";
3
+ const e = t(null);
4
+ export {
5
+ e as AuthContext
6
+ };
7
+ //# sourceMappingURL=auth-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-context.js","sources":["../src/auth-context.ts"],"sourcesContent":["\"use client\";\n\nimport { createContext } from \"react\";\nimport type { Auth, Session } from \"./types\";\n\n/**\n * The context, in its own module because two files need it and neither should import the other.\n * `createContext` is what makes this file client-only.\n */\n\nexport type AuthStatus = \"loading\" | \"authenticated\" | \"anonymous\";\n\nexport interface AuthContextValue {\n readonly auth: Auth;\n readonly session: Session | null;\n readonly status: AuthStatus;\n}\n\nexport const AuthContext = createContext<AuthContextValue | null>(null);\n"],"names":[],"mappings":";;AAkBO;;;;"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A `fetch` that stays authenticated.
3
+ *
4
+ * This is the seam a product already has. keasy's API client is
5
+ * `createClient({ baseUrl: "/" })` with one middleware; the viewer's trace source takes its
6
+ * transport as a parameter. Handing either an authenticated `fetch` changes one line and no call
7
+ * site — which is the point, because the alternative is every call site remembering a header.
8
+ *
9
+ * Auth0's SPA SDK ships the same shape for the same reason: `fetchWithAuth`, *"a drop-in replacement
10
+ * for the Fetch API's `fetch()` method"* that builds the headers and handles the retries.
11
+ */
12
+ /**
13
+ * Where a token comes from, and how to ask for a fresh one.
14
+ *
15
+ * Both are the implementation's business to make cheap and **single-flight** — see `singleFlight`.
16
+ * `current` is called on every request, so it must answer from cache until the token is near
17
+ * expiry. `null` from both is legitimate: it means there is no bearer token to attach.
18
+ */
19
+ export interface TokenSource {
20
+ current(): Promise<string | null>;
21
+ renew(): Promise<string | null>;
22
+ }
23
+ /**
24
+ * Wrap a `fetch` so every request carries the session, and one stale token does not surface as a
25
+ * failure the user has to see.
26
+ *
27
+ * The retry is **once**, and only on a 401 we sent a token for. Retrying a 403 would be wrong — that
28
+ * is an answer, not a stale credential — and retrying twice turns an expired session into a loop
29
+ * against the authorization server.
30
+ */
31
+ export declare function authFetch(source: TokenSource, base?: typeof globalThis.fetch): typeof globalThis.fetch;
32
+ //# sourceMappingURL=auth-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-fetch.d.ts","sourceRoot":"","sources":["../src/auth-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAClC,KAAK,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACjC;AAgBD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,WAAW,EACnB,IAAI,GAAE,OAAO,UAAU,CAAC,KAAwB,GAC/C,OAAO,UAAU,CAAC,KAAK,CAuBzB"}
@@ -0,0 +1,20 @@
1
+ function c(r, n) {
2
+ if (typeof Request < "u" && r instanceof Request && r.body !== null) return !1;
3
+ const e = n == null ? void 0 : n.body;
4
+ return e == null ? !0 : !(typeof ReadableStream < "u" && e instanceof ReadableStream);
5
+ }
6
+ function h(r, n = globalThis.fetch) {
7
+ return async (e, t) => {
8
+ const u = async (d) => {
9
+ const l = (t == null ? void 0 : t.headers) ?? (typeof Request < "u" && e instanceof Request ? e.headers : void 0), f = new Headers(l);
10
+ return d !== null && f.set("Authorization", `Bearer ${d}`), n(e, { ...t, headers: f });
11
+ }, s = await r.current(), a = await u(s);
12
+ if (a.status !== 401 || s === null || !c(e, t)) return a;
13
+ const o = await r.renew();
14
+ return o === null || o === s ? a : u(o);
15
+ };
16
+ }
17
+ export {
18
+ h as authFetch
19
+ };
20
+ //# sourceMappingURL=auth-fetch.js.map
@@ -0,0 +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. keasy's API client is\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 */\nfunction 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":"AA+BA,SAASA,EAAaC,GAA0BC,GAA6B;AAC3E,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;"}
@@ -0,0 +1,12 @@
1
+ import { Auth } from './types';
2
+ /**
3
+ * Holds the session and keeps it current. One per application, at the root.
4
+ *
5
+ * It does not know which pattern is underneath it — `bffAuth` or `browserAuth` both satisfy `Auth`
6
+ * — which is the whole reason the hooks below it can be written once.
7
+ */
8
+ export declare function AuthProvider({ auth, children, }: {
9
+ readonly auth: Auth;
10
+ readonly children?: React.ReactNode;
11
+ }): import("react").JSX.Element;
12
+ //# sourceMappingURL=auth-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-provider.d.ts","sourceRoot":"","sources":["../src/auth-provider.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,IAAI,EAAW,MAAM,SAAS,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,IAAI,EACJ,QAAQ,GACT,EAAE;IACD,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,+BA0CA"}
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { jsx as c } from "react/jsx-runtime";
3
+ import { useState as f, useEffect as l, useMemo as m } from "react";
4
+ import { AuthContext as d } from "./auth-context.js";
5
+ function x({
6
+ auth: s,
7
+ children: u
8
+ }) {
9
+ const [t, o] = f({
10
+ session: null,
11
+ status: "loading"
12
+ });
13
+ l(() => {
14
+ let e = !0;
15
+ const n = () => {
16
+ s.getSession().then((r) => {
17
+ e && o({ session: r, status: r ? "authenticated" : "anonymous" });
18
+ }).catch(() => {
19
+ e && o({ session: null, status: "anonymous" });
20
+ });
21
+ }, a = s.subscribe(n);
22
+ return n(), () => {
23
+ e = !1, a();
24
+ };
25
+ }, [s]);
26
+ const i = m(
27
+ () => ({ auth: s, session: t.session, status: t.status }),
28
+ [s, t.session, t.status]
29
+ );
30
+ return /* @__PURE__ */ c(d.Provider, { value: i, children: u });
31
+ }
32
+ export {
33
+ x as AuthProvider
34
+ };
35
+ //# sourceMappingURL=auth-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-provider.js","sources":["../src/auth-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { AuthContext, type AuthStatus } from \"./auth-context\";\nimport type { Auth, Session } from \"./types\";\n\n/**\n * Holds the session and keeps it current. One per application, at the root.\n *\n * It does not know which pattern is underneath it — `bffAuth` or `browserAuth` both satisfy `Auth`\n * — which is the whole reason the hooks below it can be written once.\n */\nexport function AuthProvider({\n auth,\n children,\n}: {\n readonly auth: Auth;\n readonly children?: React.ReactNode;\n}) {\n const [state, setState] = useState<{ session: Session | null; status: AuthStatus }>({\n session: null,\n status: \"loading\",\n });\n\n useEffect(() => {\n // `live` rather than an AbortController: `getSession` may be a cache read with no request to\n // abort, and what must not happen is a setState after unmount — a resolved promise from the\n // provider that was just torn down, overwriting the one that replaced it.\n let live = true;\n\n const read = () => {\n void auth\n .getSession()\n .then((session) => {\n if (!live) return;\n setState({ session, status: session ? \"authenticated\" : \"anonymous\" });\n })\n .catch(() => {\n // A session that cannot be read is a session you do not have. Failing to \"anonymous\"\n // keeps the tree renderable and sends the person to the IdP, which is recoverable;\n // holding \"loading\" forever is a spinner nobody can escape.\n if (live) setState({ session: null, status: \"anonymous\" });\n });\n };\n\n const unsubscribe = auth.subscribe(read);\n read();\n\n return () => {\n live = false;\n unsubscribe();\n };\n }, [auth]);\n\n const value = useMemo(\n () => ({ auth, session: state.session, status: state.status }),\n [auth, state.session, state.status],\n );\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n"],"names":[],"mappings":";;;;AAYO;AAAsB;AAC3B;AAEF;AAIE;AAAoF;AACzE;AACD;AAGV;AAIE;AAEA;AACE;AAGI;AACqE;AAMrE;AAAyD;AAC1D;AAIL;AAGE;AACA;AACF;AAGF;AAAc;;AAEsB;AAGpC;AACF;;;;"}
@@ -0,0 +1,32 @@
1
+ import { Auth, Session } from './types';
2
+ /**
3
+ * The Backend-For-Frontend pattern: the token never reaches the browser.
4
+ *
5
+ * RFC 10017 calls this one *"strongly recommended for business applications, sensitive
6
+ * applications, and applications that handle personal data"*. The server holds the confidential
7
+ * client and the tokens; the browser holds a cookie it cannot read, and asks the server who it is.
8
+ *
9
+ * So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on
10
+ * the root barrel beside the hooks rather than behind a subpath. What it needs from the server is
11
+ * three routes, which `@kanzo-tech/auth/next` provides: a session endpoint, a sign-in and a
12
+ * sign-out.
13
+ */
14
+ export interface BffAuthConfig {
15
+ /** Where the BFF's auth routes are mounted. Default `/api/auth`. */
16
+ readonly basePath?: string;
17
+ /** Injectable for tests. Defaults to the global. */
18
+ readonly fetch?: typeof globalThis.fetch;
19
+ /** Injectable for tests. Defaults to assigning `window.location`. */
20
+ readonly navigate?: (url: string) => void;
21
+ }
22
+ /**
23
+ * The session endpoint's answer, checked rather than cast.
24
+ *
25
+ * It is our own server on the other end, and it is still **data off the wire**: a cast here would
26
+ * make a deploy skew or a proxy's error page arrive as a `Session` whose `user` is undefined, and
27
+ * the failure would surface three components away as a property read on nothing. `null` for
28
+ * anything unrecognisable is the same answer as "not signed in", which is the safe reading.
29
+ */
30
+ export declare function readSession(value: unknown): Session | null;
31
+ export declare function bffAuth(config?: BffAuthConfig): Auth;
32
+ //# sourceMappingURL=bff-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bff-auth.d.ts","sourceRoot":"","sources":["../src/bff-auth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAgB,OAAO,EAAiB,MAAM,SAAS,CAAC;AAE1E;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IACzC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAgBD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAyB1D;AAED,wBAAgB,OAAO,CAAC,MAAM,GAAE,aAAkB,GAAG,IAAI,CAwExD"}
@@ -0,0 +1,70 @@
1
+ import { singleFlight as p } from "./single-flight.js";
2
+ function y(e) {
3
+ if (typeof e != "object" || e === null) return null;
4
+ const t = e, s = t.alias;
5
+ return typeof s != "string" || s.length === 0 ? null : {
6
+ alias: s,
7
+ id: typeof t.id == "string" ? t.id : void 0,
8
+ roles: Array.isArray(t.roles) ? t.roles.filter((o) => typeof o == "string") : []
9
+ };
10
+ }
11
+ function d(e) {
12
+ if (typeof e != "object" || e === null) return null;
13
+ const t = e, s = t.user;
14
+ if (typeof s != "object" || s === null) return null;
15
+ const o = s.id;
16
+ if (typeof o != "string" || o.length === 0) return null;
17
+ const l = s, u = (r) => typeof l[r] == "string" && l[r] !== "" ? l[r] : void 0;
18
+ return {
19
+ user: { id: o, email: u("email"), name: u("name"), username: u("username") },
20
+ roles: Array.isArray(t.roles) ? t.roles.filter((r) => typeof r == "string") : [],
21
+ organizations: Array.isArray(t.organizations) ? t.organizations.map(y).filter((r) => r !== null) : [],
22
+ expiresAt: typeof t.expiresAt == "number" ? t.expiresAt : 0
23
+ };
24
+ }
25
+ function m(e = {}) {
26
+ const t = (e.basePath ?? "/api/auth").replace(/\/$/, ""), s = e.fetch ?? ((...n) => globalThis.fetch(...n)), o = e.navigate ?? ((n) => void (globalThis.location.href = n)), l = /* @__PURE__ */ new Set(), u = () => {
27
+ for (const n of l) n();
28
+ };
29
+ let r = null, c = !1;
30
+ const g = p(async () => {
31
+ const n = await s(`${t}/session`, {
32
+ headers: { Accept: "application/json" }
33
+ });
34
+ return n.status === 401 || !n.ok ? null : d(await n.json().catch(() => null));
35
+ }), f = async () => {
36
+ const n = r;
37
+ return r = await g(), c = !0, (n === null != (r === null) || (n == null ? void 0 : n.user.id) !== (r == null ? void 0 : r.user.id)) && u(), r;
38
+ };
39
+ return {
40
+ async getSession() {
41
+ return c ? r : f();
42
+ },
43
+ subscribe(n) {
44
+ return l.add(n), () => l.delete(n);
45
+ },
46
+ async signIn(n = {}) {
47
+ var a;
48
+ const i = new URLSearchParams();
49
+ i.set("returnTo", n.returnTo ?? ((a = globalThis.location) == null ? void 0 : a.href) ?? "/"), n.organization !== void 0 && i.set("organization", n.organization), o(`${t}/signin?${i.toString()}`);
50
+ },
51
+ async signOut(n = {}) {
52
+ const i = new URLSearchParams();
53
+ n.returnTo !== void 0 && i.set("returnTo", n.returnTo);
54
+ const a = i.toString();
55
+ o(a ? `${t}/signout?${a}` : `${t}/signout`);
56
+ },
57
+ // No `Authorization` header, and no retry: the cookie rides along on a same-origin request by
58
+ // itself, so there is no stale token to renew. A 401 means the session is *gone* rather than
59
+ // stale, and the useful response to that is to tell the tree — which is what re-reading does.
60
+ fetch: async (n, i) => {
61
+ const a = await s(n, i);
62
+ return a.status === 401 && c && (c = !1, await f()), a;
63
+ }
64
+ };
65
+ }
66
+ export {
67
+ m as bffAuth,
68
+ d as readSession
69
+ };
70
+ //# sourceMappingURL=bff-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bff-auth.js","sources":["../src/bff-auth.ts"],"sourcesContent":["import { singleFlight } from \"./single-flight\";\nimport type { Auth, Organization, Session, SignInOptions } from \"./types\";\n\n/**\n * The Backend-For-Frontend pattern: the token never reaches the browser.\n *\n * RFC 10017 calls this one *\"strongly recommended for business applications, sensitive\n * applications, and applications that handle personal data\"*. The server holds the confidential\n * client and the tokens; the browser holds a cookie it cannot read, and asks the server who it is.\n *\n * So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on\n * the root barrel beside the hooks rather than behind a subpath. What it needs from the server is\n * three routes, which `@kanzo-tech/auth/next` provides: a session endpoint, a sign-in and a\n * sign-out.\n */\n\nexport interface BffAuthConfig {\n /** Where the BFF's auth routes are mounted. Default `/api/auth`. */\n readonly basePath?: string;\n /** Injectable for tests. Defaults to the global. */\n readonly fetch?: typeof globalThis.fetch;\n /** Injectable for tests. Defaults to assigning `window.location`. */\n readonly navigate?: (url: string) => void;\n}\n\nfunction readOrganization(value: unknown): Organization | null {\n if (typeof value !== \"object\" || value === null) return null;\n const org = value as Record<string, unknown>;\n const alias = org[\"alias\"];\n if (typeof alias !== \"string\" || alias.length === 0) return null;\n return {\n alias,\n id: typeof org[\"id\"] === \"string\" ? org[\"id\"] : undefined,\n roles: Array.isArray(org[\"roles\"])\n ? org[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n };\n}\n\n/**\n * The session endpoint's answer, checked rather than cast.\n *\n * It is our own server on the other end, and it is still **data off the wire**: a cast here would\n * make a deploy skew or a proxy's error page arrive as a `Session` whose `user` is undefined, and\n * the failure would surface three components away as a property read on nothing. `null` for\n * anything unrecognisable is the same answer as \"not signed in\", which is the safe reading.\n */\nexport function readSession(value: unknown): Session | null {\n if (typeof value !== \"object\" || value === null) return null;\n const body = value as Record<string, unknown>;\n\n const user = body[\"user\"];\n if (typeof user !== \"object\" || user === null) return null;\n const id = (user as Record<string, unknown>)[\"id\"];\n if (typeof id !== \"string\" || id.length === 0) return null;\n\n const person = user as Record<string, unknown>;\n const str = (key: string) =>\n typeof person[key] === \"string\" && person[key] !== \"\" ? (person[key] as string) : undefined;\n\n return {\n user: { id, email: str(\"email\"), name: str(\"name\"), username: str(\"username\") },\n roles: Array.isArray(body[\"roles\"])\n ? body[\"roles\"].filter((r): r is string => typeof r === \"string\")\n : [],\n organizations: Array.isArray(body[\"organizations\"])\n ? body[\"organizations\"]\n .map(readOrganization)\n .filter((o): o is Organization => o !== null)\n : [],\n expiresAt: typeof body[\"expiresAt\"] === \"number\" ? body[\"expiresAt\"] : 0,\n };\n}\n\nexport function bffAuth(config: BffAuthConfig = {}): Auth {\n const base = (config.basePath ?? \"/api/auth\").replace(/\\/$/, \"\");\n const doFetch = config.fetch ?? ((...args) => globalThis.fetch(...args));\n const go = config.navigate ?? ((url: string) => void (globalThis.location.href = url));\n\n const listeners = new Set<() => void>();\n const announce = () => {\n for (const listener of listeners) listener();\n };\n\n let cached: Session | null = null;\n let known = false;\n\n // Single-flight for the same reason the token path needs it: a page that mounts six components\n // asks six times in one tick, and one answer serves them all. Here it costs a request rather\n // than a revoked token chain, which is a smaller bill for the same mistake.\n const read = singleFlight(async (): Promise<Session | null> => {\n const response = await doFetch(`${base}/session`, {\n headers: { Accept: \"application/json\" },\n });\n // 401 is the documented answer for \"nobody is signed in\", not a failure to report.\n if (response.status === 401) return null;\n if (!response.ok) return null;\n return readSession(await response.json().catch(() => null));\n });\n\n const refresh = async (): Promise<Session | null> => {\n const next = cached;\n cached = await read();\n known = true;\n // Announce only a change, so a poll does not re-render the tree every time.\n if ((next === null) !== (cached === null) || next?.user.id !== cached?.user.id) announce();\n return cached;\n };\n\n return {\n async getSession() {\n if (known) return cached;\n return refresh();\n },\n\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n\n async signIn(options: SignInOptions = {}) {\n const params = new URLSearchParams();\n params.set(\"returnTo\", options.returnTo ?? globalThis.location?.href ?? \"/\");\n if (options.organization !== undefined) params.set(\"organization\", options.organization);\n go(`${base}/signin?${params.toString()}`);\n },\n\n async signOut(options = {}) {\n const params = new URLSearchParams();\n if (options.returnTo !== undefined) params.set(\"returnTo\", options.returnTo);\n const query = params.toString();\n go(query ? `${base}/signout?${query}` : `${base}/signout`);\n },\n\n // No `Authorization` header, and no retry: the cookie rides along on a same-origin request by\n // itself, so there is no stale token to renew. A 401 means the session is *gone* rather than\n // stale, and the useful response to that is to tell the tree — which is what re-reading does.\n fetch: async (input, init) => {\n const response = await doFetch(input, init);\n if (response.status === 401 && known) {\n known = false;\n await refresh();\n }\n return response;\n },\n };\n}\n"],"names":["readOrganization","value","org","alias","r","readSession","body","user","id","person","str","key","o","bffAuth","config","base","doFetch","args","go","url","listeners","announce","listener","cached","known","read","singleFlight","response","refresh","next","onChange","options","params","_a","query","input","init"],"mappings":"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"}
@@ -0,0 +1,61 @@
1
+ import { UserManagerSettings } from 'oidc-client-ts';
2
+ import { Auth } from './types';
3
+ /** What this adapter reads off the library's `User`. The access token is carried, never opened. */
4
+ export interface OidcUser {
5
+ readonly profile: unknown;
6
+ readonly access_token: string;
7
+ readonly expires_at?: number;
8
+ readonly url_state?: string;
9
+ }
10
+ /**
11
+ * The slice of `UserManager` this file uses.
12
+ *
13
+ * It exists so the manager can be injected, which is what lets everything below be tested with no
14
+ * network, no IdP and no iframe. It is deliberately structural and deliberately small — and the
15
+ * default factory returns a real `UserManager`, so the compiler is what keeps the slice honest.
16
+ */
17
+ export interface OidcUserManager {
18
+ getUser(): Promise<OidcUser | null>;
19
+ signinRedirect(args?: {
20
+ scope?: string;
21
+ url_state?: string;
22
+ }): Promise<void>;
23
+ signinCallback(url?: string): Promise<OidcUser | undefined>;
24
+ signinSilent(): Promise<OidcUser | null>;
25
+ signoutRedirect(args?: {
26
+ post_logout_redirect_uri?: string;
27
+ }): Promise<void>;
28
+ readonly events: {
29
+ addUserLoaded(cb: () => void): () => void;
30
+ addUserUnloaded(cb: () => void): () => void;
31
+ addAccessTokenExpired(cb: () => void): () => void;
32
+ addUserSessionChanged(cb: () => void): () => void;
33
+ };
34
+ }
35
+ export interface BrowserAuthConfig {
36
+ /** The realm's issuer, e.g. `https://id.kanzo.tech/realms/kanzo`. */
37
+ readonly issuer: string;
38
+ /**
39
+ * This application's Keycloak client id. It selects the credential and, through {@link claims},
40
+ * which of the token's roles are this application's.
41
+ */
42
+ readonly clientId: string;
43
+ /** Must be registered with the IdP. Defaults to the current origin. */
44
+ readonly redirectUri?: string;
45
+ /** Must be registered with the IdP. Defaults to the current origin. */
46
+ readonly postLogoutRedirectUri?: string;
47
+ /** Defaults to `openid profile email organization:*`. */
48
+ readonly scope?: string;
49
+ /**
50
+ * Poll the OP's session state, so a sign-out performed elsewhere reaches this tab.
51
+ *
52
+ * Off by default because it cannot be relied on: it runs a hidden cross-site iframe, and a
53
+ * browser that partitions third-party cookies — which is now most of them — makes it report
54
+ * nothing rather than fail loudly. It is a knob and not a default for exactly that reason.
55
+ */
56
+ readonly monitorSession?: boolean;
57
+ /** The seam the tests inject through. Defaults to the real `UserManager`. */
58
+ readonly createManager?: (settings: UserManagerSettings) => OidcUserManager;
59
+ }
60
+ export declare function browserAuth(config: BrowserAuthConfig): Auth;
61
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,gBAAgB,CAAC;AAIxB,OAAO,KAAK,EAAE,IAAI,EAA0B,MAAM,SAAS,CAAC;AAiC5D,mGAAmG;AACnG,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,OAAO,IAAI,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACpC,cAAc,CAAC,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,cAAc,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CAAC;IAC5D,YAAY,IAAI,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACzC,eAAe,CAAC,IAAI,CAAC,EAAE;QAAE,wBAAwB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,QAAQ,CAAC,MAAM,EAAE;QACf,aAAa,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;QAC1C,eAAe,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;QAC5C,qBAAqB,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;QAClD,qBAAqB,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;KACnD,CAAC;CACH;AAED,MAAM,WAAW,iBAAiB;IAChC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,uEAAuE;IACvE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,yDAAyD;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,6EAA6E;IAC7E,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,mBAAmB,KAAK,eAAe,CAAC;CAC7E;AAgBD,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAiK3D"}
@@ -0,0 +1,107 @@
1
+ import { WebStorageStateStore as g, InMemoryWebStorage as b, UserManager as U } from "oidc-client-ts";
2
+ import { authFetch as R } from "./auth-fetch.js";
3
+ import { claims as T } from "./claims.js";
4
+ import { singleFlight as w } from "./single-flight.js";
5
+ const v = "openid profile email organization:*", x = ["code", "state", "session_state", "iss", "error", "error_description"];
6
+ function A(t) {
7
+ const s = new URLSearchParams(t);
8
+ return s.has("state") && (s.has("code") || s.has("error"));
9
+ }
10
+ function L(t, s) {
11
+ return s === void 0 ? t : [...t.split(" ").filter((n) => n.length > 0 && !n.startsWith("organization:")), `organization:${s}`].join(" ");
12
+ }
13
+ function F(t) {
14
+ var p;
15
+ const s = ((p = globalThis.location) == null ? void 0 : p.origin) ?? "", a = t.scope ?? v, n = {
16
+ authority: t.issuer,
17
+ client_id: t.clientId,
18
+ redirect_uri: t.redirectUri ?? s,
19
+ post_logout_redirect_uri: t.postLogoutRedirectUri ?? s,
20
+ scope: a,
21
+ response_type: "code",
22
+ monitorSession: t.monitorSession ?? !1,
23
+ // **Tokens in memory only.** The library's default is `sessionStorage`, and RFC 10017 is
24
+ // explicit that under a public client anything script can read is something an XSS can steal;
25
+ // a token on disk outlives the page that was compromised to get it.
26
+ //
27
+ // The cost is real and is the whole of the trade: a full page load starts with nothing, so the
28
+ // session is re-established by a silent renewal or a redirect rather than read off disk. That
29
+ // is one round trip on every load, and a signed-out first paint for any application that draws
30
+ // before `getSession()` settles.
31
+ userStore: new g({ store: new b() }),
32
+ // The PKCE verifier, `state` and `nonce` are **not** tokens and cannot be in memory: the page
33
+ // is unloaded by the navigation to the IdP, and memory goes with it. `sessionStorage` rather
34
+ // than the library's `localStorage` default — per tab, gone when the tab closes, and never
35
+ // visible to a second tab racing the same sign-in.
36
+ stateStore: new g({ store: globalThis.sessionStorage })
37
+ }, o = (t.createManager ?? ((e) => new U(e)))(n), u = (e) => {
38
+ const r = new URL(globalThis.location.href);
39
+ for (const y of x) r.searchParams.delete(y);
40
+ const i = e === void 0 ? r : new URL(e, r.origin), l = i.origin === r.origin ? i : r;
41
+ globalThis.history.replaceState(null, "", `${l.pathname}${l.search}${l.hash}`);
42
+ }, _ = async () => {
43
+ if (!A(globalThis.location.search)) return null;
44
+ try {
45
+ const e = await o.signinCallback(globalThis.location.href) ?? null;
46
+ return u(e == null ? void 0 : e.url_state), e;
47
+ } catch {
48
+ return u(void 0), null;
49
+ }
50
+ };
51
+ let d;
52
+ const m = w(async () => (d ?? (d = _()), await d ?? o.getUser())), c = w(async () => {
53
+ try {
54
+ return await o.signinSilent();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }), S = (e) => e.expires_at !== void 0 && e.expires_at * 1e3 <= Date.now(), h = async () => {
59
+ const e = await m();
60
+ return e === null || S(e) ? c() : e;
61
+ }, f = (e) => {
62
+ const r = T(e.profile, { clientId: t.clientId });
63
+ return e.expires_at === void 0 ? r : { ...r, expiresAt: e.expires_at * 1e3 };
64
+ };
65
+ return {
66
+ async getSession() {
67
+ const e = await h();
68
+ return e === null ? null : f(e);
69
+ },
70
+ subscribe(e) {
71
+ const r = [
72
+ o.events.addUserLoaded(e),
73
+ o.events.addUserUnloaded(e),
74
+ o.events.addAccessTokenExpired(e),
75
+ o.events.addUserSessionChanged(e)
76
+ ];
77
+ return () => {
78
+ for (const i of r) i();
79
+ };
80
+ },
81
+ async signIn(e = {}) {
82
+ await o.signinRedirect({
83
+ scope: L(a, e.organization),
84
+ url_state: e.returnTo ?? globalThis.location.href
85
+ });
86
+ },
87
+ async signOut(e = {}) {
88
+ await o.signoutRedirect({
89
+ post_logout_redirect_uri: e.returnTo ?? t.postLogoutRedirectUri
90
+ });
91
+ },
92
+ fetch: R({
93
+ async current() {
94
+ var e;
95
+ return ((e = await h()) == null ? void 0 : e.access_token) ?? null;
96
+ },
97
+ async renew() {
98
+ var e;
99
+ return ((e = await c()) == null ? void 0 : e.access_token) ?? null;
100
+ }
101
+ })
102
+ };
103
+ }
104
+ export {
105
+ F as browserAuth
106
+ };
107
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","sources":["../src/browser.ts"],"sourcesContent":["import {\n InMemoryWebStorage,\n UserManager,\n WebStorageStateStore,\n type UserManagerSettings,\n} from \"oidc-client-ts\";\nimport { authFetch, type TokenSource } from \"./auth-fetch\";\nimport { claims } from \"./claims\";\nimport { singleFlight } from \"./single-flight\";\nimport type { Auth, Session, SignInOptions } from \"./types\";\n\n/**\n * `./browser` — RFC 10017's *browser-based OAuth 2.0 client*: a public client with PKCE, for the\n * SPA that has no server to hold a confidential one. `agents/viewer` and `hub/browser` are both\n * that shape, and for them it is the only architecture available.\n *\n * **This file is an adapter, not an implementation.** PKCE, silent renewal, storage, the callback\n * and RP-initiated logout are `oidc-client-ts`'s; a `UserManager` goes in and our `Auth` comes out.\n * The reference evaluation picked that library precisely so none of the protocol would be written\n * here, because a hand-written flow is where a mistake stops being a bug and becomes a\n * vulnerability. What is genuinely ours is the four things no library can have: Keycloak's claim\n * vocabulary read into one `Session`, the organization scope, the single-flight renewal, and one\n * `Session` across both deployment patterns.\n *\n * There is no `\"use client\"` here, and that is the rule rather than an oversight: nothing in this\n * module is stateful and no browser global is read at module scope. The listeners are registered\n * inside `subscribe`, and `location` inside the factory.\n */\n\n/**\n * `organization:*`, not `organization`.\n *\n * Plain `organization` returns the single organization when there is one and **prompts for a\n * choice** when there are several — which is the documented behaviour behind the reports of the\n * claim \"disappearing\" for multi-organization users. It never disappeared; the selection was\n * missing. A single-tenant product pays nothing for the star: the claim is simply absent.\n */\nconst DEFAULT_SCOPE = \"openid profile email organization:*\";\n\n/** The query parameters an authorization response puts in the URL, and that must not stay there. */\nconst RESPONSE_PARAMS = [\"code\", \"state\", \"session_state\", \"iss\", \"error\", \"error_description\"];\n\n/** What this adapter reads off the library's `User`. The access token is carried, never opened. */\nexport interface OidcUser {\n readonly profile: unknown;\n readonly access_token: string;\n readonly expires_at?: number;\n readonly url_state?: string;\n}\n\n/**\n * The slice of `UserManager` this file uses.\n *\n * It exists so the manager can be injected, which is what lets everything below be tested with no\n * network, no IdP and no iframe. It is deliberately structural and deliberately small — and the\n * default factory returns a real `UserManager`, so the compiler is what keeps the slice honest.\n */\nexport interface OidcUserManager {\n getUser(): Promise<OidcUser | null>;\n signinRedirect(args?: { scope?: string; url_state?: string }): Promise<void>;\n signinCallback(url?: string): Promise<OidcUser | undefined>;\n signinSilent(): Promise<OidcUser | null>;\n signoutRedirect(args?: { post_logout_redirect_uri?: string }): Promise<void>;\n readonly events: {\n addUserLoaded(cb: () => void): () => void;\n addUserUnloaded(cb: () => void): () => void;\n addAccessTokenExpired(cb: () => void): () => void;\n addUserSessionChanged(cb: () => void): () => void;\n };\n}\n\nexport interface BrowserAuthConfig {\n /** The realm's issuer, e.g. `https://id.kanzo.tech/realms/kanzo`. */\n readonly issuer: string;\n /**\n * This application's Keycloak client id. It selects the credential and, through {@link claims},\n * which of the token's roles are this application's.\n */\n readonly clientId: string;\n /** Must be registered with the IdP. Defaults to the current origin. */\n readonly redirectUri?: string;\n /** Must be registered with the IdP. Defaults to the current origin. */\n readonly postLogoutRedirectUri?: string;\n /** Defaults to `openid profile email organization:*`. */\n readonly scope?: string;\n /**\n * Poll the OP's session state, so a sign-out performed elsewhere reaches this tab.\n *\n * Off by default because it cannot be relied on: it runs a hidden cross-site iframe, and a\n * browser that partitions third-party cookies — which is now most of them — makes it report\n * nothing rather than fail loudly. It is a knob and not a default for exactly that reason.\n */\n readonly monitorSession?: boolean;\n /** The seam the tests inject through. Defaults to the real `UserManager`. */\n readonly createManager?: (settings: UserManagerSettings) => OidcUserManager;\n}\n\n/** `code` or `error`, together with `state`. Nothing else in a URL is an authorization response. */\nfunction isAuthorizationResponse(search: string): boolean {\n const params = new URLSearchParams(search);\n return params.has(\"state\") && (params.has(\"code\") || params.has(\"error\"));\n}\n\nfunction scopeFor(scope: string, organization: string | undefined): string {\n if (organization === undefined) return scope;\n // Replace rather than append: `organization:* organization:acme` asks for both, and the point of\n // naming one is to get one.\n const rest = scope.split(\" \").filter((s) => s.length > 0 && !s.startsWith(\"organization:\"));\n return [...rest, `organization:${organization}`].join(\" \");\n}\n\nexport function browserAuth(config: BrowserAuthConfig): Auth {\n const origin = globalThis.location?.origin ?? \"\";\n const scope = config.scope ?? DEFAULT_SCOPE;\n\n const settings: UserManagerSettings = {\n authority: config.issuer,\n client_id: config.clientId,\n redirect_uri: config.redirectUri ?? origin,\n post_logout_redirect_uri: config.postLogoutRedirectUri ?? origin,\n scope,\n response_type: \"code\",\n monitorSession: config.monitorSession ?? false,\n\n // **Tokens in memory only.** The library's default is `sessionStorage`, and RFC 10017 is\n // explicit that under a public client anything script can read is something an XSS can steal;\n // a token on disk outlives the page that was compromised to get it.\n //\n // The cost is real and is the whole of the trade: a full page load starts with nothing, so the\n // session is re-established by a silent renewal or a redirect rather than read off disk. That\n // is one round trip on every load, and a signed-out first paint for any application that draws\n // before `getSession()` settles.\n userStore: new WebStorageStateStore({ store: new InMemoryWebStorage() }),\n\n // The PKCE verifier, `state` and `nonce` are **not** tokens and cannot be in memory: the page\n // is unloaded by the navigation to the IdP, and memory goes with it. `sessionStorage` rather\n // than the library's `localStorage` default — per tab, gone when the tab closes, and never\n // visible to a second tab racing the same sign-in.\n stateStore: new WebStorageStateStore({ store: globalThis.sessionStorage }),\n };\n\n const manager = (config.createManager ?? ((s) => new UserManager(s)))(settings);\n\n /** Take the spent response out of the URL, and honour `returnTo` if it is ours to honour. */\n const restore = (returnTo: string | undefined): void => {\n const here = new URL(globalThis.location.href);\n for (const key of RESPONSE_PARAMS) here.searchParams.delete(key);\n const wanted = returnTo === undefined ? here : new URL(returnTo, here.origin);\n // `url_state` round-trips through the IdP, so following it anywhere would make our own callback\n // the bounce in an open redirect. Same origin, or we stay where we are.\n const url = wanted.origin === here.origin ? wanted : here;\n globalThis.history.replaceState(null, \"\", `${url.pathname}${url.search}${url.hash}`);\n };\n\n /**\n * Finish a redirect the IdP has just sent back.\n *\n * The URL is cleaned either way. An authorization code is single-use, and one left in the address\n * bar reaches history, bookmarks and the `Referer` — and replays as an error on the next reload.\n */\n const complete = async (): Promise<OidcUser | null> => {\n if (!isAuthorizationResponse(globalThis.location.search)) return null;\n try {\n const user = (await manager.signinCallback(globalThis.location.href)) ?? null;\n restore(user?.url_state);\n return user;\n } catch {\n // A stale state entry or a reloaded callback URL is \"not signed in\", not an exception for\n // every caller of `getSession` to handle.\n restore(undefined);\n return null;\n }\n };\n\n let completing: Promise<OidcUser | null> | undefined;\n\n const load = singleFlight(async (): Promise<OidcUser | null> => {\n completing ??= complete();\n return (await completing) ?? manager.getUser();\n });\n\n /**\n * One renewal at a time, shared by every caller — the most load-bearing line in this file, and\n * the failure `singleFlight` exists for: under the rotation RFC 10017 requires, a second renewal\n * replays a token the first already spent.\n */\n const renew = singleFlight(async (): Promise<OidcUser | null> => {\n try {\n return await manager.signinSilent();\n } catch {\n // A silent renewal fails when the refresh token is gone, rotated out, or the IdP session has\n // ended. All three mean \"no session\", and the answer is the same one `getUser` gives for it.\n return null;\n }\n });\n\n const isExpired = (user: OidcUser): boolean =>\n user.expires_at !== undefined && user.expires_at * 1000 <= Date.now();\n\n /**\n * The user — renewed when the token it carries has expired, and **asked for when there is no\n * user at all**.\n *\n * That second case is the whole of what makes memory-only storage liveable, and it was missing:\n * this returned `null` the moment `load()` did, so every reload signed the person out while the\n * comment on `userStore` promised a silent renewal would pick them back up. Memory-only is a\n * defensible trade against XSS *because* the IdP still holds the session and a `prompt=none`\n * round trip recovers it; without that round trip it is just a session that dies on F5, which no\n * product would accept and which no amount of XSS resistance would buy back.\n *\n * The cost is the one the `userStore` comment already names: an anonymous cold load spends a\n * failed `prompt=none` too. `renew()` answers `null` for it rather than throwing, so the caller\n * sees the same \"not signed in\" it saw before — one round trip later.\n */\n const fresh = async (): Promise<OidcUser | null> => {\n const user = await load();\n if (user === null) return renew();\n return isExpired(user) ? renew() : user;\n };\n\n const toSession = (user: OidcUser): Session => {\n const session = claims(user.profile, { clientId: config.clientId });\n if (user.expires_at === undefined) return session;\n // The access token's expiry comes from the token *response*, not from opening the token —\n // which stays opaque. It is the credential we attach, so it is the one worth counting down.\n return { ...session, expiresAt: user.expires_at * 1000 };\n };\n\n const source: TokenSource = {\n async current() {\n return (await fresh())?.access_token ?? null;\n },\n async renew() {\n return (await renew())?.access_token ?? null;\n },\n };\n\n return {\n async getSession() {\n const user = await fresh();\n return user === null ? null : toSession(user);\n },\n\n subscribe(onChange) {\n const detach = [\n manager.events.addUserLoaded(onChange),\n manager.events.addUserUnloaded(onChange),\n manager.events.addAccessTokenExpired(onChange),\n manager.events.addUserSessionChanged(onChange),\n ];\n return () => {\n for (const off of detach) off();\n };\n },\n\n async signIn(options: SignInOptions = {}) {\n await manager.signinRedirect({\n scope: scopeFor(scope, options.organization),\n url_state: options.returnTo ?? globalThis.location.href,\n });\n },\n\n async signOut(options = {}) {\n // RP-initiated logout through the library, which discovers `end_session_endpoint` and sends\n // the `id_token_hint`. A hand-built URL gets both wrong and neither failure is visible.\n await manager.signoutRedirect({\n post_logout_redirect_uri: options.returnTo ?? config.postLogoutRedirectUri,\n });\n },\n\n fetch: authFetch(source),\n };\n}\n"],"names":["DEFAULT_SCOPE","RESPONSE_PARAMS","isAuthorizationResponse","search","params","scopeFor","scope","organization","s","browserAuth","config","origin","_a","settings","WebStorageStateStore","InMemoryWebStorage","manager","UserManager","restore","returnTo","here","key","wanted","url","complete","user","completing","load","singleFlight","renew","isExpired","fresh","toSession","session","claims","onChange","detach","off","options","authFetch"],"mappings":"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"}
package/dist/can.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { Organization, Session } from './types';
2
+ /**
3
+ * The role predicate, and the lookup underneath it.
4
+ *
5
+ * **What this decides is what to draw, never what to allow.** The roles a client holds are a copy,
6
+ * and a copy is something an attacker controls the moment it reaches the browser: `can` hides a
7
+ * button, and the resource server — validating the access token it was sent — is what actually
8
+ * refuses the request behind it. A product that gates only here has not gated anything.
9
+ */
10
+ /** The organization by that alias, or `undefined` for one this person does not belong to. */
11
+ export declare function organizationOf(session: Session | null | undefined, alias: string): Organization | undefined;
12
+ /**
13
+ * Does this session hold `role`?
14
+ *
15
+ * With an `organization`, the question is asked *inside* it — the person's roles there, which is a
16
+ * different set from their realm and client roles and is deliberately not merged with them. A role
17
+ * held in one organization says nothing about another, and the day those two sets are unioned for
18
+ * convenience is the day one organization's owner is every organization's owner.
19
+ *
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 — that `owner` outranks `member` is a fact about a
22
+ * product, so a product spells it out: `can(s, "owner", org) || can(s, "member", org)`.
23
+ */
24
+ export declare function can(session: Session | null | undefined, role: string, organization?: string): boolean;
25
+ //# sourceMappingURL=can.d.ts.map