@kanzo-tech/auth 0.29.0 → 0.30.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 (89) hide show
  1. package/README.md +36 -23
  2. package/dist/bff-auth.d.ts +1 -2
  3. package/dist/bff-auth.d.ts.map +1 -1
  4. package/dist/bff-auth.js +82 -77
  5. package/dist/bff-auth.js.map +1 -1
  6. package/dist/can.d.ts +13 -9
  7. package/dist/can.d.ts.map +1 -1
  8. package/dist/can.js +6 -6
  9. package/dist/can.js.map +1 -1
  10. package/dist/gate.d.ts +4 -4
  11. package/dist/gate.d.ts.map +1 -1
  12. package/dist/gate.js +8 -9
  13. package/dist/gate.js.map +1 -1
  14. package/dist/index.d.ts +11 -19
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +17 -23
  17. package/dist/index.js.map +1 -1
  18. package/dist/issuer.d.ts +16 -0
  19. package/dist/issuer.d.ts.map +1 -1
  20. package/dist/issuer.js +43 -25
  21. package/dist/issuer.js.map +1 -1
  22. package/dist/next-auth.d.ts +66 -0
  23. package/dist/next-auth.d.ts.map +1 -0
  24. package/dist/next-auth.js +43 -0
  25. package/dist/next-auth.js.map +1 -0
  26. package/dist/next-bound.d.ts +82 -0
  27. package/dist/next-bound.d.ts.map +1 -0
  28. package/dist/next-bound.js +27 -0
  29. package/dist/next-bound.js.map +1 -0
  30. package/dist/next-gate.d.ts +4 -0
  31. package/dist/next-gate.d.ts.map +1 -0
  32. package/dist/next-gate.js +74 -0
  33. package/dist/next-gate.js.map +1 -0
  34. package/dist/next-proxy.d.ts +15 -24
  35. package/dist/next-proxy.d.ts.map +1 -1
  36. package/dist/next-proxy.js +38 -33
  37. package/dist/next-proxy.js.map +1 -1
  38. package/dist/next-routes.d.ts +3 -21
  39. package/dist/next-routes.d.ts.map +1 -1
  40. package/dist/next-routes.js +93 -65
  41. package/dist/next-routes.js.map +1 -1
  42. package/dist/next.d.ts +16 -41
  43. package/dist/next.d.ts.map +1 -1
  44. package/dist/next.js +2 -10
  45. package/dist/next.js.map +1 -1
  46. package/dist/server.d.ts +60 -38
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +183 -159
  49. package/dist/server.js.map +1 -1
  50. package/dist/store.d.ts +102 -20
  51. package/dist/store.d.ts.map +1 -1
  52. package/dist/store.js +56 -21
  53. package/dist/store.js.map +1 -1
  54. package/dist/types.d.ts +40 -23
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/use-session.d.ts +6 -1
  58. package/dist/use-session.d.ts.map +1 -1
  59. package/dist/use-session.js +9 -7
  60. package/dist/use-session.js.map +1 -1
  61. package/package.json +10 -29
  62. package/dist/auth-fetch.d.ts +0 -44
  63. package/dist/auth-fetch.d.ts.map +0 -1
  64. package/dist/auth-fetch.js +0 -21
  65. package/dist/auth-fetch.js.map +0 -1
  66. package/dist/browser.d.ts +0 -61
  67. package/dist/browser.d.ts.map +0 -1
  68. package/dist/browser.js +0 -130
  69. package/dist/browser.js.map +0 -1
  70. package/dist/host.d.ts +0 -22
  71. package/dist/host.d.ts.map +0 -1
  72. package/dist/host.js +0 -10
  73. package/dist/host.js.map +0 -1
  74. package/dist/next-middleware.d.ts +0 -23
  75. package/dist/next-middleware.d.ts.map +0 -1
  76. package/dist/next-middleware.js +0 -23
  77. package/dist/next-middleware.js.map +0 -1
  78. package/dist/next-session.d.ts +0 -44
  79. package/dist/next-session.d.ts.map +0 -1
  80. package/dist/next-session.js +0 -11
  81. package/dist/next-session.js.map +0 -1
  82. package/dist/next-token.d.ts +0 -56
  83. package/dist/next-token.d.ts.map +0 -1
  84. package/dist/next-token.js +0 -9
  85. package/dist/next-token.js.map +0 -1
  86. package/dist/use-organization.d.ts +0 -22
  87. package/dist/use-organization.d.ts.map +0 -1
  88. package/dist/use-organization.js +0 -20
  89. package/dist/use-organization.js.map +0 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @kanzo-tech/auth
2
2
 
3
- Authentication over Keycloak: the claim vocabulary read into one session, the role evaluation that
4
- knows about organizations, and an authenticated `fetch`.
3
+ Authentication over Keycloak: a Backend For Frontend for Next in one object, `kanzoAuth`, the claim
4
+ vocabulary read into one session, and the role evaluation that knows about organizations.
5
5
 
6
6
  ## What it is not
7
7
 
@@ -10,14 +10,15 @@ screen is a logo, a legal line, a privacy notice and a button, and every product
10
10
  differently — `@kanzo-tech/ui` has the parts to draw one. What is genuinely shared sits underneath,
11
11
  and that is what is here.
12
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.
13
+ **There is no protocol in it either.** The confidential client is `openid-client`'s, behind
14
+ `./server`, and the browser holds a cookie and no token — RFC 10017's Backend For Frontend, the
15
+ architecture it recommends for business applications. Writing OAuth by hand is where mistakes turn
16
+ into vulnerabilities, and it is not what this package is for.
17
17
 
18
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.**
19
+ `Session`, a role predicate that understands organizations, and the session lifecycle around them —
20
+ a proxy that renews and ends sessions, back-channel logout, and a browser half that signs in once
21
+ when a session is over.**
21
22
 
22
23
  ## Why a package, and not `@kanzo-tech/ui`
23
24
 
@@ -47,9 +48,18 @@ prising open a token.
47
48
  pnpm add @kanzo-tech/auth
48
49
  ```
49
50
 
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.
51
+ The root barrel carries no engine. The server doors name theirs — `openid-client` and `jose` for
52
+ `./server`, and `next` 16 or later as well for `./next` — and a consumer installs only what it
53
+ opens.
54
+
55
+ ```ts
56
+ // lib/auth.ts
57
+ export const auth = kanzoAuth(async () => ({ issuer, clientId, clientSecret, secret, store }));
58
+ // proxy.ts
59
+ export const proxy = (request: NextRequest) => auth.proxy(request);
60
+ // app/api/auth/[...auth]/route.ts
61
+ export const { GET, POST } = auth.routes;
62
+ ```
53
63
 
54
64
  ## The claims it reads
55
65
 
@@ -60,7 +70,8 @@ Nothing here is invented. Every claim is one Keycloak emits without being asked:
60
70
  | `sub`, `email`, `name` (or `given_name` + `family_name`), `preferred_username` | `session.user` |
61
71
  | `realm_access.roles` ∪ `resource_access.<clientId>.roles` | `session.roles` |
62
72
  | `organization` — `{ "acme": { "id": "…", "resource_access": { "<clientId>": { "roles": ["editor"] } } } }` | `session.organizations` |
63
- | `exp` | `session.expiresAt`, in milliseconds |
73
+ | `sid` | the session record, for back-channel logout |
74
+ | — the token response's `expires_in` | `session.expiresAt`, in milliseconds: when the access token expires |
64
75
 
65
76
  Inside each organization, `resource_access.<clientId>.roles` is what the person holds **there**:
66
77
  the roles an organization admin mapped onto the groups they are in, composites expanded by
@@ -69,35 +80,37 @@ application's roles in the same entry are ignored, so a role held in one applica
69
80
  authorises its holder in another. Nor are they merged into `session.roles`: a role in one
70
81
  organization says nothing about the next.
71
82
 
72
- Ask Keycloak for `organization:*` to receive every organization the person belongs to. Plain
73
- `organization` returns the only one when there is one and prompts for a choice when there are
74
- several, which is the documented behaviour behind more than one bug report about the claim
75
- "disappearing".
83
+ Every sign-in asks Keycloak for `organization:*`, which returns every organization the person
84
+ belongs to. Plain `organization` returns the only one when there is one and prompts for a choice
85
+ when there are several, which is the documented behaviour behind more than one bug report about the
86
+ claim "disappearing".
76
87
 
77
- ## Membership is in the session; the active organization is not
88
+ ## Membership is stored; the current organization is resolved per request
78
89
 
79
90
  ```ts
80
91
  interface Session {
81
92
  user: AuthUser;
82
93
  roles: readonly string[];
83
94
  organizations: readonly Organization[];
95
+ organization?: string; // the tenant this request addresses
84
96
  expiresAt: number;
85
97
  }
86
98
  ```
87
99
 
88
- There is no active organization on it, and the absence is deliberate. Membership is stable and comes
89
- from the token; which organization you are *looking at* is a property of the request — the URL — and
90
- deriving it per request is what lets two tabs sit in two organizations at once. A field here would
91
- be the single shared value they would fight over.
100
+ Membership is stable and comes from the token. Which organization a request is *in* is a property
101
+ of the request — its host, its path, a cookie — so `kanzoAuth`'s `organization` resolver answers it
102
+ per request and it is never stored. That is what lets two tabs sit in two organizations at once.
92
103
 
93
104
  Roles held inside an organization live on that organization, and are never merged into
94
105
  `session.roles`. Being an owner of one organization says nothing about another.
95
106
 
96
107
  ```ts
97
- can(session, "auditor"); // a realm or client role
98
- can(session, "owner", "acme"); // a role inside that organization
108
+ can(session, "editor"); // inside session.organization, the current tenant
109
+ can(session, "owner", "acme"); // inside that organization
99
110
  ```
100
111
 
112
+ With no current tenant, `can` answers from the realm and client roles.
113
+
101
114
  There is no role hierarchy in the predicate. That `owner` outranks `member` is a fact about a
102
115
  product, so a product writes it where it can be seen:
103
116
 
@@ -8,8 +8,7 @@ import { Auth, Session } from './types';
8
8
  *
9
9
  * So there is no engine on this path — no PKCE, no storage, no renewal — which is why it lives on
10
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.
11
+ * the routes `kanzoAuth` serves: a session endpoint, a refresh, a sign-in and a sign-out.
13
12
  */
14
13
  export interface BffAuthConfig {
15
14
  /** Where the BFF's auth routes are mounted. Default `/api/auth`. */
@@ -1 +1 @@
1
- {"version":3,"file":"bff-auth.d.ts","sourceRoot":"","sources":["../src/bff-auth.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,IAAI,EAAqB,KAAK,OAAO,EAAsB,MAAM,SAAS,CAAC;AAEpG;;;;;;;;;;;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;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAyB1D;AAED,wBAAgB,OAAO,CAAC,MAAM,GAAE,aAAkB,GAAG,IAAI,CA8IxD"}
1
+ {"version":3,"file":"bff-auth.d.ts","sourceRoot":"","sources":["../src/bff-auth.ts"],"names":[],"mappings":"AAEA,OAAO,EAAa,KAAK,IAAI,EAAqB,KAAK,OAAO,EAAsB,MAAM,SAAS,CAAC;AAEpG;;;;;;;;;;GAUG;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;AA8BD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CA6B1D;AAED,wBAAgB,OAAO,CAAC,MAAM,GAAE,aAAkB,GAAG,IAAI,CAgJxD"}
package/dist/bff-auth.js CHANGED
@@ -1,116 +1,121 @@
1
- import { isReplayable as $ } from "./auth-fetch.js";
2
1
  import { deadline as p } from "./deadline.js";
3
- import { singleFlight as y } from "./single-flight.js";
4
- import { AuthError as A } from "./types.js";
5
- function v(i) {
6
- if (typeof i != "object" || i === null) return null;
7
- const t = i, o = t.alias;
2
+ import { singleFlight as b } from "./single-flight.js";
3
+ import { AuthError as v } from "./types.js";
4
+ function z(a, r) {
5
+ if (typeof Request < "u" && a instanceof Request && a.body !== null) return !1;
6
+ const o = r == null ? void 0 : r.body;
7
+ return o == null ? !0 : !(typeof ReadableStream < "u" && o instanceof ReadableStream);
8
+ }
9
+ function T(a) {
10
+ if (typeof a != "object" || a === null) return null;
11
+ const r = a, o = r.alias;
8
12
  return typeof o != "string" || o.length === 0 ? null : {
9
13
  alias: o,
10
- id: typeof t.id == "string" ? t.id : void 0,
11
- roles: Array.isArray(t.roles) ? t.roles.filter((a) => typeof a == "string") : []
14
+ id: typeof r.id == "string" ? r.id : void 0,
15
+ roles: Array.isArray(r.roles) ? r.roles.filter((i) => typeof i == "string") : []
12
16
  };
13
17
  }
14
- function T(i) {
15
- if (typeof i != "object" || i === null) return null;
16
- const t = i, o = t.user;
18
+ function S(a) {
19
+ if (typeof a != "object" || a === null) return null;
20
+ const r = a, o = r.user;
17
21
  if (typeof o != "object" || o === null) return null;
18
- const a = o.id;
19
- if (typeof a != "string" || a.length === 0) return null;
22
+ const i = o.id;
23
+ if (typeof i != "string" || i.length === 0) return null;
20
24
  const l = o, c = (s) => typeof l[s] == "string" && l[s] !== "" ? l[s] : void 0;
21
25
  return {
22
- user: { id: a, email: c("email"), name: c("name"), username: c("username") },
23
- roles: Array.isArray(t.roles) ? t.roles.filter((s) => typeof s == "string") : [],
24
- organizations: Array.isArray(t.organizations) ? t.organizations.map(v).filter((s) => s !== null) : [],
25
- expiresAt: typeof t.expiresAt == "number" ? t.expiresAt : 0
26
+ user: { id: i, email: c("email"), name: c("name"), username: c("username") },
27
+ roles: Array.isArray(r.roles) ? r.roles.filter((s) => typeof s == "string") : [],
28
+ organizations: Array.isArray(r.organizations) ? r.organizations.map(T).filter((s) => s !== null) : [],
29
+ organization: typeof r.organization == "string" && r.organization !== "" ? r.organization : void 0,
30
+ expiresAt: typeof r.expiresAt == "number" ? r.expiresAt : 0
26
31
  };
27
32
  }
28
- function x(i = {}) {
29
- const t = (i.basePath ?? "/api/auth").replace(/\/$/, ""), o = i.fetch ?? ((...r) => globalThis.fetch(...r)), a = i.navigate ?? ((r) => void (globalThis.location.href = r)), l = /* @__PURE__ */ new Set(), c = () => {
30
- for (const r of l) r();
33
+ function q(a = {}) {
34
+ const r = (a.basePath ?? "/api/auth").replace(/\/$/, ""), o = a.fetch ?? ((...e) => globalThis.fetch(...e)), i = a.navigate ?? ((e) => void (globalThis.location.href = e)), l = /* @__PURE__ */ new Set(), c = () => {
35
+ for (const e of l) e();
31
36
  };
32
- let s = null, f = !1;
33
- const u = (r, e, n) => new A(
37
+ let s = null, d = !1, g = !1;
38
+ const f = (e, n, t) => new v(
34
39
  "session/unavailable",
35
- e === void 0 ? `${r} could not be reached` : `${r} answered ${e}`,
36
- e === void 0 ? {} : { status: e },
37
- n === void 0 ? void 0 : { cause: n }
38
- ), h = async (r, e) => {
39
- var n;
40
+ n === void 0 ? `${e} could not be reached` : `${e} answered ${n}`,
41
+ n === void 0 ? {} : { status: n },
42
+ t === void 0 ? void 0 : { cause: t }
43
+ ), y = async (e, n) => {
44
+ var t;
40
45
  try {
41
- return await o(r, e);
42
- } catch (d) {
43
- throw (n = e.signal) != null && n.aborted ? e.signal.reason : u(r, void 0, d);
46
+ return await o(e, n);
47
+ } catch (u) {
48
+ throw (t = n.signal) != null && t.aborted ? n.signal.reason : f(e, void 0, u);
44
49
  }
45
- }, m = y(
46
- () => p("session/silent", async (r) => {
47
- const e = await h(`${t}/session`, { headers: { Accept: "application/json" }, signal: r });
48
- if (e.status === 401) return null;
49
- if (!e.ok) throw u(`${t}/session`, e.status);
50
- let n;
50
+ }, m = b(
51
+ () => p("session/silent", async (e) => {
52
+ const n = await y(`${r}/session`, { headers: { Accept: "application/json" }, signal: e });
53
+ if (n.status === 401) return null;
54
+ if (!n.ok) throw f(`${r}/session`, n.status);
55
+ let t;
51
56
  try {
52
- n = await e.json();
53
- } catch (b) {
54
- throw u(`${t}/session`, e.status, b);
57
+ t = await n.json();
58
+ } catch (A) {
59
+ throw f(`${r}/session`, n.status, A);
55
60
  }
56
- const d = T(n);
57
- if (d === null) throw u(`${t}/session`, e.status);
58
- return d;
61
+ const u = S(t);
62
+ if (u === null) throw f(`${r}/session`, n.status);
63
+ return u;
59
64
  })
60
- ), w = y(
61
- () => p("session/silent", async (r) => {
62
- const e = await h(`${t}/refresh`, {
65
+ ), w = b(
66
+ () => p("session/silent", async (e) => {
67
+ const n = await y(`${r}/refresh`, {
63
68
  method: "POST",
64
69
  headers: { Accept: "application/json" },
65
- signal: r
70
+ signal: e
66
71
  });
67
- if (e.status >= 500) throw u(`${t}/refresh`, e.status);
68
- return e.ok;
72
+ if (n.status >= 500) throw f(`${r}/refresh`, n.status);
73
+ return n.ok;
69
74
  })
70
- ), g = async () => {
71
- const r = s;
72
- return s = await m(), f = !0, (r === null != (s === null) || (r == null ? void 0 : r.user.id) !== (s == null ? void 0 : s.user.id)) && c(), s;
75
+ ), $ = async () => {
76
+ const e = s;
77
+ return s = await m(), d = !0, (e === null != (s === null) || (e == null ? void 0 : e.user.id) !== (s == null ? void 0 : s.user.id)) && c(), s;
78
+ }, h = async (e = {}) => {
79
+ var t;
80
+ const n = new URLSearchParams();
81
+ n.set("returnTo", e.returnTo ?? ((t = globalThis.location) == null ? void 0 : t.href) ?? "/"), e.organization !== void 0 && n.set("organization", e.organization), i(`${r}/signin?${n.toString()}`);
73
82
  };
74
83
  return {
75
84
  async getSession() {
76
- return f ? s : g();
77
- },
78
- subscribe(r) {
79
- return l.add(r), () => l.delete(r);
85
+ return d ? s : $();
80
86
  },
81
- async signIn(r = {}) {
82
- var n;
83
- const e = new URLSearchParams();
84
- e.set("returnTo", r.returnTo ?? ((n = globalThis.location) == null ? void 0 : n.href) ?? "/"), r.organization !== void 0 && e.set("organization", r.organization), a(`${t}/signin?${e.toString()}`);
87
+ subscribe(e) {
88
+ return l.add(e), () => l.delete(e);
85
89
  },
86
- async signOut(r = {}) {
87
- const e = new URLSearchParams();
88
- r.returnTo !== void 0 && e.set("returnTo", r.returnTo);
89
- const n = e.toString();
90
- a(n ? `${t}/signout?${n}` : `${t}/signout`);
90
+ signIn: h,
91
+ async signOut(e = {}) {
92
+ const n = new URLSearchParams();
93
+ e.returnTo !== void 0 && n.set("returnTo", e.returnTo);
94
+ const t = n.toString();
95
+ i(t ? `${r}/signout?${t}` : `${r}/signout`);
91
96
  },
92
97
  /**
93
98
  * No `Authorization` header — the cookie rides along on a same-origin request by itself — and
94
99
  * **one** retry, behind one renewal.
95
100
  *
96
- * A 401 here is ambiguous in a way it is not under `browserAuth`: the cookie was sent and was
97
- * accepted, so what expired is the access token *behind* the cookie, which this half of the
98
- * pattern cannot see. So the 401 is taken as "renew and try again" first and as "the session
99
- * is gone" only when the renewal is refused — at which point re-reading tells the tree, which
100
- * is what it did before and all it did before.
101
+ * A 401 here is ambiguous: the cookie was sent and was accepted, so what expired may be the
102
+ * access token *behind* the cookie, which this half of the pattern cannot see. So the 401 is
103
+ * taken as "renew and try again" first, and as "the session is over" only when the renewal is
104
+ * refused — and then the answer is to sign in, once, coming back to this page. One layer: a
105
+ * product's data client does not need a 401 branch of its own.
101
106
  *
102
- * The retry is once, for the reason `authFetch` gives: twice turns an ended session into a
103
- * loop against the authorization server. A request whose body cannot be replayed is not
104
- * retried at all, and `isReplayable` is the same predicate the bearer-token path uses.
107
+ * The retry is once: twice turns an ended session into a loop against the authorization server.
108
+ * A request whose body cannot be replayed is not retried at all.
105
109
  */
106
- fetch: async (r, e) => {
107
- const n = await o(r, e);
108
- return n.status !== 401 || !f ? n : await w() ? $(r, e) ? o(r, e) : n : (f = !1, await g().catch(c), n);
110
+ fetch: async (e, n) => {
111
+ var u;
112
+ const t = await o(e, n);
113
+ return t.status !== 401 || !d ? t : await w() ? z(e, n) ? o(e, n) : t : (g || (g = !0, await h({ returnTo: (u = globalThis.location) == null ? void 0 : u.href })), t);
109
114
  }
110
115
  };
111
116
  }
112
117
  export {
113
- x as bffAuth,
114
- T as readSession
118
+ q as bffAuth,
119
+ S as readSession
115
120
  };
116
121
  //# sourceMappingURL=bff-auth.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"bff-auth.js","sources":["../src/bff-auth.ts"],"sourcesContent":["import { isReplayable } from \"./auth-fetch\";\nimport { deadline } from \"./deadline\";\nimport { singleFlight } from \"./single-flight\";\nimport { AuthError, type Auth, type Organization, type Session, type 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; `bffAuth` reads a 200 it cannot read as `session/unavailable`, because\n * the endpoint answers \"nobody is signed in\" with a 401 and never with a body.\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 const unavailable = (url: string, status: number | undefined, cause?: unknown) =>\n new AuthError(\n \"session/unavailable\",\n status === undefined ? `${url} could not be reached` : `${url} answered ${status}`,\n status === undefined ? {} : { status },\n cause === undefined ? undefined : { cause },\n );\n\n const send = async (url: string, init: RequestInit): Promise<Response> => {\n try {\n return await doFetch(url, init);\n } catch (error) {\n if (init.signal?.aborted) throw init.signal.reason;\n throw unavailable(url, undefined, error);\n }\n };\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 //\n // A 401 is the documented answer for \"nobody is signed in\". Anything else that is not a session\n // — a 5xx, a proxy's page, no answer at all — is a failure, and reading it as \"signed out\" would\n // send the person to sign in over an outage that will still be there when they get back.\n const read = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<Session | null> => {\n const response = await send(`${base}/session`, { headers: { Accept: \"application/json\" }, signal });\n if (response.status === 401) return null;\n if (!response.ok) throw unavailable(`${base}/session`, response.status);\n let body: unknown;\n try {\n body = await response.json();\n } catch (error) {\n throw unavailable(`${base}/session`, response.status, error);\n }\n const session = readSession(body);\n if (session === null) throw unavailable(`${base}/session`, response.status);\n return session;\n }),\n );\n\n /**\n * Ask the BFF to spend the refresh token, once for however many requests noticed at the moment.\n *\n * This is the browser end of the renewal, and without it the session cookie's lifetime and the\n * access token's are two different clocks with nothing between them: a cookie good for eight\n * hours in front of a token good for one produces seven hours in which `/session` answers 200,\n * the whole application draws, and every request for data is a 401 that nothing acts on.\n *\n * `POST`, because the route only answers `POST` — it spends something, and a `GET` that spends\n * something is one prefetch away from spending it unasked.\n */\n const renew = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<boolean> => {\n const response = await send(`${base}/refresh`, {\n method: \"POST\",\n headers: { Accept: \"application/json\" },\n signal,\n });\n // A refusal (401, 400) is the end of the session; a 5xx is an outage, not a refusal.\n if (response.status >= 500) throw unavailable(`${base}/refresh`, response.status);\n return response.ok;\n }),\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 /**\n * No `Authorization` header — the cookie rides along on a same-origin request by itself — and\n * **one** retry, behind one renewal.\n *\n * A 401 here is ambiguous in a way it is not under `browserAuth`: the cookie was sent and was\n * accepted, so what expired is the access token *behind* the cookie, which this half of the\n * pattern cannot see. So the 401 is taken as \"renew and try again\" first and as \"the session\n * is gone\" only when the renewal is refused — at which point re-reading tells the tree, which\n * is what it did before and all it did before.\n *\n * The retry is once, for the reason `authFetch` gives: twice turns an ended session into a\n * loop against the authorization server. A request whose body cannot be replayed is not\n * retried at all, and `isReplayable` is the same predicate the bearer-token path uses.\n */\n fetch: async (input, init) => {\n const response = await doFetch(input, init);\n if (response.status !== 401 || !known) return response;\n\n if (await renew()) {\n if (!isReplayable(input, init)) return response;\n return doFetch(input, init);\n }\n\n known = false;\n // The caller is owed its own response; a session that cannot be re-read is the provider's to\n // show, and announcing makes it read again and see the failure itself.\n await refresh().catch(announce);\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","unavailable","status","cause","AuthError","send","init","error","_a","read","singleFlight","deadline","signal","response","session","renew","refresh","next","onChange","options","params","query","input","isReplayable"],"mappings":"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"}
1
+ {"version":3,"file":"bff-auth.js","sources":["../src/bff-auth.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport { singleFlight } from \"./single-flight\";\nimport { AuthError, type Auth, type Organization, type Session, type 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 * the routes `kanzoAuth` serves: a session endpoint, a refresh, a sign-in and a 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\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\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; `bffAuth` reads a 200 it cannot read as `session/unavailable`, because\n * the endpoint answers \"nobody is signed in\" with a 401 and never with a body.\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 organization:\n typeof body[\"organization\"] === \"string\" && body[\"organization\"] !== \"\"\n ? body[\"organization\"]\n : undefined,\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 /** Set by the one sign-in a refused renewal starts; the page is leaving, so it is never unset. */\n let leaving = false;\n\n const unavailable = (url: string, status: number | undefined, cause?: unknown) =>\n new AuthError(\n \"session/unavailable\",\n status === undefined ? `${url} could not be reached` : `${url} answered ${status}`,\n status === undefined ? {} : { status },\n cause === undefined ? undefined : { cause },\n );\n\n const send = async (url: string, init: RequestInit): Promise<Response> => {\n try {\n return await doFetch(url, init);\n } catch (error) {\n if (init.signal?.aborted) throw init.signal.reason;\n throw unavailable(url, undefined, error);\n }\n };\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 //\n // A 401 is the documented answer for \"nobody is signed in\". Anything else that is not a session\n // — a 5xx, a proxy's page, no answer at all — is a failure, and reading it as \"signed out\" would\n // send the person to sign in over an outage that will still be there when they get back.\n const read = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<Session | null> => {\n const response = await send(`${base}/session`, { headers: { Accept: \"application/json\" }, signal });\n if (response.status === 401) return null;\n if (!response.ok) throw unavailable(`${base}/session`, response.status);\n let body: unknown;\n try {\n body = await response.json();\n } catch (error) {\n throw unavailable(`${base}/session`, response.status, error);\n }\n const session = readSession(body);\n if (session === null) throw unavailable(`${base}/session`, response.status);\n return session;\n }),\n );\n\n /**\n * Ask the BFF to spend the refresh token, once for however many requests noticed at the moment.\n *\n * The proxy renews before every page and the forwarder before every request it forwards, so this\n * is the last of three: a 401 that reached the browser anyway, from a resource the BFF does not\n * front or a token that died between the forwarder's check and the upstream's.\n *\n * `POST`, because the route only answers `POST` — it spends something, and a `GET` that spends\n * something is one prefetch away from spending it unasked.\n */\n const renew = singleFlight(() =>\n deadline(\"session/silent\", async (signal): Promise<boolean> => {\n const response = await send(`${base}/refresh`, {\n method: \"POST\",\n headers: { Accept: \"application/json\" },\n signal,\n });\n // A refusal (401, 400) is the end of the session; a 5xx is an outage, not a refusal.\n if (response.status >= 500) throw unavailable(`${base}/refresh`, response.status);\n return response.ok;\n }),\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 const signIn = async (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 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 signIn,\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 /**\n * No `Authorization` header — the cookie rides along on a same-origin request by itself — and\n * **one** retry, behind one renewal.\n *\n * A 401 here is ambiguous: the cookie was sent and was accepted, so what expired may be the\n * access token *behind* the cookie, which this half of the pattern cannot see. So the 401 is\n * taken as \"renew and try again\" first, and as \"the session is over\" only when the renewal is\n * refused — and then the answer is to sign in, once, coming back to this page. One layer: a\n * product's data client does not need a 401 branch of its own.\n *\n * The retry is once: twice turns an ended session into a loop against the authorization server.\n * A request whose body cannot be replayed is not retried at all.\n */\n fetch: async (input, init) => {\n const response = await doFetch(input, init);\n if (response.status !== 401 || !known) return response;\n\n if (await renew()) {\n if (!isReplayable(input, init)) return response;\n return doFetch(input, init);\n }\n\n if (!leaving) {\n leaving = true;\n await signIn({ returnTo: globalThis.location?.href });\n }\n return response;\n },\n };\n}\n"],"names":["isReplayable","input","init","body","readOrganization","value","org","alias","r","readSession","user","id","person","str","key","o","bffAuth","config","base","doFetch","args","go","url","listeners","announce","listener","cached","known","leaving","unavailable","status","cause","AuthError","send","error","_a","read","singleFlight","deadline","signal","response","session","renew","refresh","next","signIn","options","params","onChange","query"],"mappings":"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"}
package/dist/can.d.ts CHANGED
@@ -10,17 +10,21 @@ import { Organization, Session } from './types';
10
10
  /** The organization by that alias, or `undefined` for one this person does not belong to. */
11
11
  export declare function organizationOf(session: Session | null | undefined, alias: string): Organization | undefined;
12
12
  /**
13
- * Does this session hold `role`?
13
+ * Does this session hold `role` — in the organization this request addresses, unless told which?
14
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.
15
+ * The organization defaults to `session.organization`, the current tenant the product's resolver
16
+ * named, so `can(session, "editor")` asks the question a page means: *here*. Clerk's
17
+ * `auth().has()` binds to the active organization the same way. Named explicitly, the question is
18
+ * asked inside that one instead. Inside an organization the answer comes from the person's roles
19
+ * there, which is a different set from their realm and client roles and is deliberately not
20
+ * merged with them: a role held in one organization says nothing about another, and the day those
21
+ * two sets are unioned for convenience is the day one organization's owner is every
22
+ * organization's owner. With no tenant at all, the realm and client roles answer.
19
23
  *
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 `admin` contains `editor` is a fact about a
24
+ * Closed by default: no session, or no membership of the organization, is `false` rather than an
25
+ * error. There is no hierarchy here either: that `admin` contains `editor` is a fact about a
22
26
  * 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.
27
+ * the token carries the expanded set — so `can(s, "editor")` is true for an admin.
24
28
  */
25
- export declare function can(session: Session | null | undefined, role: string, organization?: string): boolean;
29
+ export declare function can(session: Session | null | undefined, role: string, organization?: string | undefined): boolean;
26
30
  //# 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;;;;;;;;;;;;GAYG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAIT"}
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;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,GAAG,CACjB,OAAO,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EACnC,IAAI,EAAE,MAAM,EACZ,YAAY,GAAE,MAAM,GAAG,SAAiC,GACvD,OAAO,CAIT"}
package/dist/can.js CHANGED
@@ -1,12 +1,12 @@
1
- function u(r, f) {
2
- return r == null ? void 0 : r.organizations.find((n) => n.alias === f);
1
+ function u(r, a) {
2
+ return r == null ? void 0 : r.organizations.find((t) => t.alias === a);
3
3
  }
4
- function a(r, f, n) {
5
- var t;
6
- return r ? n === void 0 ? r.roles.includes(f) : ((t = u(r, n)) == null ? void 0 : t.roles.includes(f)) ?? !1 : !1;
4
+ function l(r, a, t = r == null ? void 0 : r.organization) {
5
+ var f;
6
+ return r ? t === void 0 ? r.roles.includes(a) : ((f = u(r, t)) == null ? void 0 : f.roles.includes(a)) ?? !1 : !1;
7
7
  }
8
8
  export {
9
- a as can,
9
+ l as can,
10
10
  u as organizationOf
11
11
  };
12
12
  //# sourceMappingURL=can.js.map
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: 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;"}
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` — in the organization this request addresses, unless told which?\n *\n * The organization defaults to `session.organization`, the current tenant the product's resolver\n * named, so `can(session, \"editor\")` asks the question a page means: *here*. Clerk's\n * `auth().has()` binds to the active organization the same way. Named explicitly, the question is\n * asked inside that one instead. Inside an organization the answer comes from the person's roles\n * there, which is a different set from their realm and client roles and is deliberately not\n * merged with them: a role held in one organization says nothing about another, and the day those\n * two sets are unioned for convenience is the day one organization's owner is every\n * organization's owner. With no tenant at all, the realm and client roles answer.\n *\n * Closed by default: no session, or no membership of the organization, is `false` rather than an\n * 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\")` is true for an admin.\n */\nexport function can(\n session: Session | null | undefined,\n role: string,\n organization: string | undefined = session?.organization,\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;AAmBO,SAASE,EACdH,GACAI,GACAC,IAAmCL,KAAA,gBAAAA,EAAS,cACnC;AA5BJ,MAAAM;AA6BL,SAAKN,IACDK,MAAiB,SAAkBL,EAAQ,MAAM,SAASI,CAAI,MAC3DE,IAAAP,EAAeC,GAASK,CAAY,MAApC,gBAAAC,EAAuC,MAAM,SAASF,OAAS,KAFjD;AAGvB;"}
package/dist/gate.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Show `children` to someone who holds `role`, and `fallback` to everyone else.
3
3
  *
4
- * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only
5
- * here is ungated.
4
+ * **This hides UI and protects nothing**, for the reason `can` carries: a product gated only here is
5
+ * ungated.
6
6
  *
7
- * `organization` asks the question inside that organization rather than against the realm roles,
8
- * and {@link can} does not merge the two.
7
+ * The question is asked inside the current tenant, `session.organization`, unless `organization`
8
+ * names another — the same default as `useSession().can`.
9
9
  */
10
10
  export declare function Gate({ role, organization, fallback, children, }: {
11
11
  readonly role: string;
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.tsx"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAI,EACJ,YAAY,EACZ,QAAe,EACf,QAAQ,GACT,EAAE;IACD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,sCAUA"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,EACnB,IAAI,EACJ,YAAY,EACZ,QAAe,EACf,QAAQ,GACT,EAAE;IACD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,sCAUA"}
package/dist/gate.js CHANGED
@@ -1,17 +1,16 @@
1
1
  "use client";
2
- import { jsx as s, Fragment as l } from "react/jsx-runtime";
3
- import { can as m } from "./can.js";
2
+ import { jsx as l, Fragment as s } from "react/jsx-runtime";
4
3
  import { useSession as u } from "./use-session.js";
5
- function p({
4
+ function m({
6
5
  role: r,
7
- organization: o,
8
- fallback: t = null,
9
- children: e
6
+ organization: t,
7
+ fallback: e = null,
8
+ children: o
10
9
  }) {
11
- const { session: i, status: n } = u();
12
- return n === "loading" || n === "failed" ? null : /* @__PURE__ */ s(l, { children: m(i, r, o) ? e : t });
10
+ const { can: i, status: n } = u();
11
+ return n === "loading" || n === "failed" ? null : /* @__PURE__ */ l(s, { children: i(r, t) ? o : e });
13
12
  }
14
13
  export {
15
- p as Gate
14
+ m as Gate
16
15
  };
17
16
  //# sourceMappingURL=gate.js.map
package/dist/gate.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { can } from \"./can\";\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason {@link can} carries: a product gated only\n * here is ungated.\n *\n * `organization` asks the question inside that organization rather than against the realm roles,\n * and {@link can} does not merge the two.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { session, status } = useSession();\n\n // While the session is still being read, or could not be, neither answer is known to be true, so\n // neither is drawn. Rendering the fallback here is the flicker worth avoiding — \"you cannot do\n // this\" shown to someone who can, for as long as the session endpoint takes — and rendering the\n // children is worse, because it flashes a control and then retracts it.\n if (status === \"loading\" || status === \"failed\") return null;\n\n return <>{can(session, role, organization) ? children : fallback}</>;\n}\n"],"names":[],"mappings":";;;;AAcO;AAAc;AACnB;AACA;AACW;AAEb;AAME;AAMA;AAGF;;;;"}
1
+ {"version":3,"file":"gate.js","sources":["../src/gate.tsx"],"sourcesContent":["\"use client\";\n\nimport { useSession } from \"./use-session\";\n\n/**\n * Show `children` to someone who holds `role`, and `fallback` to everyone else.\n *\n * **This hides UI and protects nothing**, for the reason `can` carries: a product gated only here is\n * ungated.\n *\n * The question is asked inside the current tenant, `session.organization`, unless `organization`\n * names another — the same default as `useSession().can`.\n */\nexport function Gate({\n role,\n organization,\n fallback = null,\n children,\n}: {\n readonly role: string;\n readonly organization?: string;\n readonly fallback?: React.ReactNode;\n readonly children?: React.ReactNode;\n}) {\n const { can, status } = useSession();\n\n // While the session is still being read, or could not be, neither answer is known to be true, so\n // neither is drawn. Rendering the fallback here is the flicker worth avoiding — \"you cannot do\n // this\" shown to someone who can, for as long as the session endpoint takes — and rendering the\n // children is worse, because it flashes a control and then retracts it.\n if (status === \"loading\" || status === \"failed\") return null;\n\n return <>{can(role, organization) ? children : fallback}</>;\n}\n"],"names":[],"mappings":";;;AAaO;AAAc;AACnB;AACA;AACW;AAEb;AAME;AAMA;AAGF;;;;"}
package/dist/index.d.ts CHANGED
@@ -15,9 +15,10 @@
15
15
  * those differently. What is genuinely shared sits underneath — reading Keycloak's claims into one
16
16
  * session, evaluating a role inside an organization, and keeping a `fetch` authenticated.
17
17
  *
18
- * **There is no protocol in it either.** PKCE, silent renewal, storage and cross-tab coordination
19
- * are `oidc-client-ts`'s, behind `./browser`; the confidential client is `openid-client`'s, behind
20
- * `./server`. Writing either by hand is where mistakes become vulnerabilities.
18
+ * **There is no protocol in it either.** The confidential client is `openid-client`'s, behind
19
+ * `./server`, and the browser holds no token at all — RFC 10017's Backend For Frontend, the one
20
+ * architecture it recommends for business applications. Writing OAuth by hand is where mistakes
21
+ * become vulnerabilities.
21
22
  *
22
23
  * ## The one rule to read before using it
23
24
  *
@@ -27,21 +28,15 @@
27
28
  *
28
29
  * ## What a name means here
29
30
  *
30
- * Three families, and the shape of the name says which one a thing is. Written down because four
31
- * of these were added in parallel and drifted into two conventions and one exception:
31
+ * Two families, and the shape of the name says which one a thing is:
32
32
  *
33
- * - **`<pattern>Auth`** returns an {@link Auth} — `browserAuth`, `bffAuth`. Both patterns RFC 10017
34
- * names, one interface, which is what lets `useSession` and `Gate` be written once.
35
- * - **`auth<Thing>`** returns an auth-flavoured *thing* — `authFetch` a `fetch`, and on `./next`
36
- * `authRoutes`, `authSession`, `authMiddleware`.
33
+ * - **`<where>Auth`** is a whole side of the BFF in one object — `bffAuth` in the browser, an
34
+ * {@link Auth}; `kanzoAuth` on `./next`, the server half for an App Router product.
37
35
  * - **Everything else is named for what it is**: `relyingParty`, `issuer`, `sealedCookie`, `claims`,
38
- * `can`. The first of those used to be `serverAuth`, which wore the suffix without returning an
39
- * `Auth` — the server half's job is `begin`/`complete`/`read`/`refresh`/`end`, not
40
- * `getSession`/`signIn`/`fetch`. A name that promises an interface it does not return is worse
41
- * than a long one, and *relying party* is the term the specification already uses for it.
42
- *
43
- * *What would reverse it:* a fourth family. Two exist because two patterns exist; a third convention
44
- * would mean the vocabulary has outgrown the rule rather than that the rule needs an exception.
36
+ * `can`. The first of those used to be `serverAuth`, which wore the suffix without being a side
37
+ * of anything — its job is `begin`/`complete`/`read`/`refresh`/`end`, the protocol's verbs. A
38
+ * name that promises an interface it does not return is worse than a long one, and *relying
39
+ * party* is the term the specification already uses for it.
45
40
  *
46
41
  * ## This door carries no engine
47
42
  *
@@ -50,16 +45,13 @@
50
45
  * server there is no protocol left in the browser, only a `fetch` to a session endpoint.
51
46
  */
52
47
  export { accountUrl, type AccountPage } from './account';
53
- export { authFetch, type TokenSource } from './auth-fetch';
54
48
  export type { AuthContextValue, AuthStatus } from './auth-context';
55
49
  export { AuthProvider } from './auth-provider';
56
50
  export { bffAuth, readSession, type BffAuthConfig } from './bff-auth';
57
51
  export { can, organizationOf } from './can';
58
52
  export { claims, type ClaimsConfig } from './claims';
59
53
  export { Gate } from './gate';
60
- export { organizationFromHost } from './host';
61
54
  export { singleFlight } from './single-flight';
62
55
  export { AuthError, type Auth, type AuthErrorCode, type AuthUser, type Organization, type Session, type SignInOptions, } from './types';
63
- export { useOrganization } from './use-organization';
64
56
  export { useSession } from './use-session';
65
57
  //# sourceMappingURL=index.d.ts.map