@kanzo-tech/auth 0.29.1 → 0.30.1

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 +89 -0
  27. package/dist/next-bound.d.ts.map +1 -0
  28. package/dist/next-bound.js +48 -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 +47 -39
  37. package/dist/next-proxy.js.map +1 -1
  38. package/dist/next-routes.d.ts +4 -21
  39. package/dist/next-routes.d.ts.map +1 -1
  40. package/dist/next-routes.js +83 -64
  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 +11 -30
  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/dist/store.d.ts CHANGED
@@ -25,6 +25,14 @@ import { Session } from './types';
25
25
  */
26
26
  export interface SessionRecord {
27
27
  readonly session: Session;
28
+ /**
29
+ * The IdP's session id, the ID token's `sid`, kept across refreshes.
30
+ *
31
+ * It is what a back-channel logout names when one browser session ends at Keycloak rather than
32
+ * every session the person holds, and {@link ticketStore} writes it into the ticket for that
33
+ * reason. Absent on a realm that does not emit it, and then a logout can only end them all.
34
+ */
35
+ readonly sid?: string;
28
36
  /**
29
37
  * The credential for a resource server, and the reason this field exists.
30
38
  *
@@ -39,9 +47,8 @@ export interface SessionRecord {
39
47
  * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.
40
48
  *
41
49
  * The access token is opaque to us by contract — it is the resource server's to read — so its
42
- * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*
43
- * expiry and is a different number on a realm that gives the two different lifetimes; renewing
44
- * against the wrong one is how a request goes out with a credential that died a minute ago.
50
+ * lifetime comes from the envelope it arrived in, and {@link Session.expiresAt} is this same
51
+ * number: the moment the next renewal is due.
45
52
  */
46
53
  readonly accessTokenExpiresAt?: number;
47
54
  /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */
@@ -49,28 +56,73 @@ export interface SessionRecord {
49
56
  /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */
50
57
  readonly idToken?: string;
51
58
  }
59
+ /**
60
+ * Who a back-channel logout names: a person, and optionally one of their IdP sessions.
61
+ *
62
+ * The shape of OpenID Connect Back-Channel Logout 1.0's own claims, `sub` and `sid`.
63
+ */
64
+ export interface SessionSubject {
65
+ readonly sub: string;
66
+ readonly sid?: string;
67
+ }
68
+ /**
69
+ * Where sessions live between requests. A stable ticket, updated in place — Duende BFF's server-side
70
+ * session — rather than a new ticket per renewal.
71
+ *
72
+ * The ticket is issued **once, at sign-in**, which is the session-fixation defence: whatever cookie
73
+ * a browser arrived at the callback with, it leaves with a ticket nobody has seen before. After
74
+ * that the ticket names the session for its whole life, and a renewal changes what the row holds
75
+ * rather than which row it is. That is what lets a renewal that happens in the proxy leave the
76
+ * cookie alone, and what lets two tabs renewing at once agree on the session they share.
77
+ */
52
78
  export interface SessionStore {
53
79
  /**
54
- * Store a record and return the ticket that identifies it.
80
+ * Store a record from a sign-in and return the ticket that identifies it.
55
81
  *
56
82
  * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store
57
83
  * that enforces one live session per person does it here**, by dropping that person's previous
58
84
  * ticket as it issues this one.
59
85
  */
60
86
  put(record: SessionRecord): Promise<string>;
87
+ /**
88
+ * Replace the record behind `ticket` after a renewal, and return the ticket to carry from now on
89
+ * — or `null` when there is no longer a session to replace.
90
+ *
91
+ * A store with server state answers `ticket` itself and the cookie does not change. A store whose
92
+ * ticket *is* the record — {@link statelessStore} — has no row to update and answers a new
93
+ * ticket, which is the cookie being re-sealed.
94
+ *
95
+ * **The write is conditional.** A back-channel logout may delete the row while a renewal is in
96
+ * flight; an unconditional write would bring the ended session back. `null` is the renewal
97
+ * learning it lost that race, and the session ends instead.
98
+ */
99
+ update(ticket: string, record: SessionRecord): Promise<string | null>;
61
100
  /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */
62
101
  get(ticket: string): Promise<SessionRecord | null>;
63
102
  /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */
64
103
  drop(ticket: string): Promise<void>;
104
+ /**
105
+ * Forget every session of `sub`, or only the one Keycloak calls `sid` — a back-channel logout.
106
+ *
107
+ * A store that cannot find a person's sessions rejects with `AuthError` `session/irrevocable`
108
+ * rather than answering as though it had: a logout that ends nothing must not report success to
109
+ * the identity provider.
110
+ */
111
+ dropAll(subject: SessionSubject): Promise<void>;
65
112
  }
66
113
  /**
67
114
  * The default: no server state at all. The ticket *is* the record.
68
115
  *
69
- * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing
70
- * out clears the cookie, which is enough for the person holding the browser and is not enough for
71
- * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session
72
- * lifetime is therefore a real security parameter under this store, and "sign out everywhere" is
73
- * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.
116
+ * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing**, and
117
+ * **`dropAll` refuses** with `session/irrevocable`. Signing out clears the cookie, which is enough
118
+ * for the person holding the browser and is not enough for anyone else — a copy of that cookie
119
+ * taken beforehand keeps working until it expires. A session lifetime is therefore a real security
120
+ * parameter under this store, and neither "sign out everywhere" nor a back-channel logout is
121
+ * implementable on top of it — Auth0's SDK requires a session store for back-channel logout for
122
+ * the same reason. Give `relyingParty` a {@link ticketStore} when either matters.
123
+ *
124
+ * `update` re-seals: the record changed, so the ticket that *is* the record changes with it, and
125
+ * the cookie carrying it is reissued.
74
126
  *
75
127
  * ## It does not fit a record that carries an access token, and the numbers are the argument
76
128
  *
@@ -86,7 +138,8 @@ export interface SessionStore {
86
138
  */
87
139
  export declare function statelessStore(): SessionStore;
88
140
  /**
89
- * The two functions and a delete that a store needs from a deployment's own database.
141
+ * What a store needs from a deployment's own database — read, write, a conditional replace and a
142
+ * delete — and a fifth that only a back-channel logout needs.
90
143
  *
91
144
  * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace
92
145
  * and a file on disk — anything narrower would name one of them. **An adapter does not bound its
@@ -111,7 +164,24 @@ export interface TicketAdapter {
111
164
  * sessions, because the sealed cookie carrying the ticket expires on its own schedule.
112
165
  */
113
166
  write(key: string, value: string, ttl: number): Promise<void>;
167
+ /**
168
+ * Overwrite `key` only if it still exists, and say whether it did — Redis `SET … XX`, SQL
169
+ * `UPDATE … WHERE key = ?` and its row count.
170
+ *
171
+ * One atomic step, because the check and the write must not have a gap between them: that gap
172
+ * is where a back-channel logout's delete lands and a renewal's write undoes it.
173
+ */
174
+ replace(key: string, value: string, ttl: number): Promise<boolean>;
114
175
  delete(key: string): Promise<void>;
176
+ /**
177
+ * Every key that begins with `prefix` — Redis `SCAN MATCH <prefix>*`, SQL `LIKE '<prefix>%'`.
178
+ *
179
+ * Optional, because only `dropAll` uses it; without it a back-channel logout answers that it
180
+ * cannot end anything. The prefix never contains a glob metacharacter — see
181
+ * {@link ticketStore} — so the Redis pattern is literal as written. A SQL adapter still escapes
182
+ * `%` and `_`, which percent-encoding produces.
183
+ */
184
+ keys?(prefix: string): AsyncIterable<string>;
115
185
  }
116
186
  export interface TicketStoreConfig {
117
187
  /**
@@ -125,14 +195,19 @@ export interface TicketStoreConfig {
125
195
  * database — which is what makes a sign-out a sign-out.
126
196
  *
127
197
  * ```ts
128
- * relyingParty({
198
+ * kanzoAuth(() => ({
129
199
  * …,
130
200
  * store: ticketStore({
131
201
  * read: (key) => redis.get(key),
132
202
  * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),
203
+ * replace: async (key, value, ttl) => (await redis.set(key, value, { XX: true, EX: ttl })) === "OK",
133
204
  * delete: (key) => redis.del(key),
205
+ * // node-redis 5 yields a batch of keys per SCAN step
206
+ * async *keys(prefix) {
207
+ * for await (const batch of redis.scanIterator({ MATCH: `${prefix}*` })) yield* batch;
208
+ * },
134
209
  * }),
135
- * });
210
+ * }));
136
211
  * ```
137
212
  *
138
213
  * ## What this buys that the cookie cannot
@@ -150,15 +225,22 @@ export interface TicketStoreConfig {
150
225
  * The library makes the wait, so the library bounds it: a deployment that forgot to race its
151
226
  * Redis client would otherwise hang a page on a store that stopped answering.
152
227
  *
153
- * ## The key carries the subject, and that is deliberate
228
+ * ## The key carries the subject and the IdP session, and that is deliberate
229
+ *
230
+ * A ticket is `<sub>:<sid>:<random>`, each part percent-encoded. The random part is the whole of
231
+ * the security — the subject and session id are not secrets and are not trusted on the way back
232
+ * in, because the record they name is read from the row and never from the key. What the prefix
233
+ * buys is the one operation a flat random key makes impossible: *every session belonging to this
234
+ * person*, or *the one Keycloak just ended*. That is `dropAll`, which a back-channel logout calls,
235
+ * and it is a prefix scan through the adapter's `keys` — the same `SCAN sub:*` a deployment could
236
+ * write by hand for "sign out on every device".
237
+ *
238
+ * ## `update` keeps the ticket
154
239
  *
155
- * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is
156
- * not a secret and is not trusted on the way back in, because the record it names is read from the
157
- * row and never from the key. What the prefix buys is the one operation a flat random key makes
158
- * impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE
159
- * 'sub:%'` is then a query a deployment can write, and "sign out on every device" and "one live
160
- * session per person" — which `put` is the place for — stop being features this package has to
161
- * grow an API for.
240
+ * A renewal rewrites the row under the same key, so the cookie that names it is untouched. It goes
241
+ * through `replace`, never `write`, so a row a back-channel logout deleted stays deleted. It also
242
+ * restarts the row's `ttl`, which can then outlive the cookie by up to one `ttl`; that is a row the
243
+ * adapter forgets later, never a session, because nothing can present the expired cookie.
162
244
  */
163
245
  export declare function ticketStore(adapter: TicketAdapter, config?: TicketStoreConfig): SessionStore;
164
246
  //# sourceMappingURL=store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oGAAoG;IACpG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,sFAAsF;IACtF,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACnD,6FAA6F;IAC7F,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAgB7C;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAcD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CA8BhG"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oGAAoG;IACpG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtE,sFAAsF;IACtF,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACnD,6FAA6F;IAC7F,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC;;;;;;OAMG;IACH,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAyB7C;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AA+BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CAgDhG"}
package/dist/store.js CHANGED
@@ -1,34 +1,57 @@
1
- import { deadline as c } from "./deadline.js";
2
- function l() {
1
+ import { deadline as y } from "./deadline.js";
2
+ import { AuthError as u } from "./types.js";
3
+ function w() {
3
4
  return {
4
- async put(t) {
5
- return JSON.stringify(t);
5
+ async put(e) {
6
+ return JSON.stringify(e);
6
7
  },
7
- async get(t) {
8
+ async update(e, r) {
9
+ return JSON.stringify(r);
10
+ },
11
+ async get(e) {
8
12
  try {
9
- return JSON.parse(t);
13
+ return JSON.parse(e);
10
14
  } catch {
11
15
  return null;
12
16
  }
13
17
  },
14
18
  async drop() {
19
+ },
20
+ async dropAll() {
21
+ throw new u(
22
+ "session/irrevocable",
23
+ "a stateless session lives in the cookie, so no server can end it: give relyingParty a ticketStore"
24
+ );
15
25
  }
16
26
  };
17
27
  }
18
- const a = 480 * 60;
19
- function u() {
20
- const t = crypto.getRandomValues(new Uint8Array(32));
21
- return btoa(String.fromCharCode(...t)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
28
+ const d = 480 * 60;
29
+ function a(e) {
30
+ return encodeURIComponent(e).replace(
31
+ /[!'()*]/g,
32
+ (r) => `%${r.charCodeAt(0).toString(16).toUpperCase()}`
33
+ );
34
+ }
35
+ function c(e) {
36
+ const r = `${a(e.sub)}:`;
37
+ return e.sid === void 0 ? r : `${r}${a(e.sid)}:`;
22
38
  }
23
- function y(t, s = {}) {
24
- const o = s.ttl ?? a, r = (e) => c("session/silent", e);
39
+ function p() {
40
+ const e = crypto.getRandomValues(new Uint8Array(32));
41
+ return btoa(String.fromCharCode(...e)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
42
+ }
43
+ function S(e, r = {}) {
44
+ const i = r.ttl ?? d, s = (t) => y("session/silent", t);
25
45
  return {
26
- async put(e) {
27
- const n = `${encodeURIComponent(e.session.user.id)}:${u()}`;
28
- return await r(() => t.write(n, JSON.stringify(e), o)), n;
46
+ async put(t) {
47
+ const n = `${c({ sub: t.session.user.id, sid: t.sid ?? "" })}${p()}`;
48
+ return await s(() => e.write(n, JSON.stringify(t), i)), n;
29
49
  },
30
- async get(e) {
31
- const n = await r(() => t.read(e));
50
+ async update(t, n) {
51
+ return await s(() => e.replace(t, JSON.stringify(n), i)) ? t : null;
52
+ },
53
+ async get(t) {
54
+ const n = await s(() => e.read(t));
32
55
  if (n === null) return null;
33
56
  try {
34
57
  return JSON.parse(n);
@@ -36,13 +59,25 @@ function y(t, s = {}) {
36
59
  return null;
37
60
  }
38
61
  },
39
- async drop(e) {
40
- await r(() => t.delete(e));
62
+ async drop(t) {
63
+ await s(() => e.delete(t));
64
+ },
65
+ async dropAll(t) {
66
+ var o;
67
+ const n = (o = e.keys) == null ? void 0 : o.bind(e);
68
+ if (n === void 0)
69
+ throw new u(
70
+ "session/irrevocable",
71
+ "the ticket adapter has no `keys`, so a person's sessions cannot be found to end them"
72
+ );
73
+ await s(async () => {
74
+ for await (const l of n(c(t))) await e.delete(l);
75
+ });
41
76
  }
42
77
  };
43
78
  }
44
79
  export {
45
- l as statelessStore,
46
- y as ticketStore
80
+ w as statelessStore,
81
+ S as ticketStore
47
82
  };
48
83
  //# sourceMappingURL=store.js.map
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport type { Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * a product that enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*\n * expiry and is a different number on a realm that gives the two different lifetimes; renewing\n * against the wrong one is how a request goes out with a credential that died a minute ago.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\nexport interface SessionStore {\n /**\n * Store a record and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing\n * out clears the cookie, which is enough for the person holding the browser and is not enough for\n * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session\n * lifetime is therefore a real security parameter under this store, and \"sign out everywhere\" is\n * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n };\n}\n\n/**\n * The two functions and a delete that a store needs from a deployment's own database.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * relyingParty({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * delete: (key) => redis.del(key),\n * }),\n * });\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject, and that is deliberate\n *\n * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is\n * not a secret and is not trusted on the way back in, because the record it names is read from the\n * row and never from the key. What the prefix buys is the one operation a flat random key makes\n * impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE\n * 'sub:%'` is then a query a deployment can write, and \"sign out on every device\" and \"one live\n * session per person\" — which `put` is the place for — stop being features this package has to\n * grow an API for.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // `encodeURIComponent` on the subject, not on the whole key: a `sub` is a uuid on every realm\n // anyone has seen, and on the one that makes it something with a colon in it the prefix must\n // still be the prefix. The random half needs no encoding — base64url is already key-safe.\n const ticket = `${encodeURIComponent(record.session.user.id)}:${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n };\n}\n"],"names":["statelessStore","record","ticket","DEFAULT_TTL","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","value"],"mappings":";AA2FO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIC,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,EAAA;AAEJ;AAwCA,MAAMC,IAAc,MAAS;AAM7B,SAASC,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AA0CO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOL,GACpBO,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIV,GAAQ;AAIhB,YAAMC,IAAS,GAAG,mBAAmBD,EAAO,QAAQ,KAAK,EAAE,CAAC,IAAIG,EAAA,CAAc;AAC9E,mBAAMM,EAAQ,MAAMH,EAAQ,MAAML,GAAQ,KAAK,UAAUD,CAAM,GAAGQ,CAAG,CAAC,GAC/DP;AAAA,IACT;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMW,IAAQ,MAAMH,EAAQ,MAAMH,EAAQ,KAAKL,CAAM,CAAC;AACtD,UAAIW,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKX,GAAQ;AACjB,YAAMQ,EAAQ,MAAMH,EAAQ,OAAOL,CAAM,CAAC;AAAA,IAC5C;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport { AuthError, type Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * a product that enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The IdP's session id, the ID token's `sid`, kept across refreshes.\n *\n * It is what a back-channel logout names when one browser session ends at Keycloak rather than\n * every session the person holds, and {@link ticketStore} writes it into the ticket for that\n * reason. Absent on a realm that does not emit it, and then a logout can only end them all.\n */\n readonly sid?: string;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in, and {@link Session.expiresAt} is this same\n * number: the moment the next renewal is due.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\n/**\n * Who a back-channel logout names: a person, and optionally one of their IdP sessions.\n *\n * The shape of OpenID Connect Back-Channel Logout 1.0's own claims, `sub` and `sid`.\n */\nexport interface SessionSubject {\n readonly sub: string;\n readonly sid?: string;\n}\n\n/**\n * Where sessions live between requests. A stable ticket, updated in place — Duende BFF's server-side\n * session — rather than a new ticket per renewal.\n *\n * The ticket is issued **once, at sign-in**, which is the session-fixation defence: whatever cookie\n * a browser arrived at the callback with, it leaves with a ticket nobody has seen before. After\n * that the ticket names the session for its whole life, and a renewal changes what the row holds\n * rather than which row it is. That is what lets a renewal that happens in the proxy leave the\n * cookie alone, and what lets two tabs renewing at once agree on the session they share.\n */\nexport interface SessionStore {\n /**\n * Store a record from a sign-in and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /**\n * Replace the record behind `ticket` after a renewal, and return the ticket to carry from now on\n * — or `null` when there is no longer a session to replace.\n *\n * A store with server state answers `ticket` itself and the cookie does not change. A store whose\n * ticket *is* the record — {@link statelessStore} — has no row to update and answers a new\n * ticket, which is the cookie being re-sealed.\n *\n * **The write is conditional.** A back-channel logout may delete the row while a renewal is in\n * flight; an unconditional write would bring the ended session back. `null` is the renewal\n * learning it lost that race, and the session ends instead.\n */\n update(ticket: string, record: SessionRecord): Promise<string | null>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n /**\n * Forget every session of `sub`, or only the one Keycloak calls `sid` — a back-channel logout.\n *\n * A store that cannot find a person's sessions rejects with `AuthError` `session/irrevocable`\n * rather than answering as though it had: a logout that ends nothing must not report success to\n * the identity provider.\n */\n dropAll(subject: SessionSubject): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing**, and\n * **`dropAll` refuses** with `session/irrevocable`. Signing out clears the cookie, which is enough\n * for the person holding the browser and is not enough for anyone else — a copy of that cookie\n * taken beforehand keeps working until it expires. A session lifetime is therefore a real security\n * parameter under this store, and neither \"sign out everywhere\" nor a back-channel logout is\n * implementable on top of it — Auth0's SDK requires a session store for back-channel logout for\n * the same reason. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * `update` re-seals: the record changed, so the ticket that *is* the record changes with it, and\n * the cookie carrying it is reissued.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async update(_ticket, record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n async dropAll() {\n throw new AuthError(\n \"session/irrevocable\",\n \"a stateless session lives in the cookie, so no server can end it: give relyingParty a ticketStore\",\n );\n },\n };\n}\n\n/**\n * What a store needs from a deployment's own database — read, write, a conditional replace and a\n * delete — and a fifth that only a back-channel logout needs.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n /**\n * Overwrite `key` only if it still exists, and say whether it did — Redis `SET … XX`, SQL\n * `UPDATE … WHERE key = ?` and its row count.\n *\n * One atomic step, because the check and the write must not have a gap between them: that gap\n * is where a back-channel logout's delete lands and a renewal's write undoes it.\n */\n replace(key: string, value: string, ttl: number): Promise<boolean>;\n delete(key: string): Promise<void>;\n /**\n * Every key that begins with `prefix` — Redis `SCAN MATCH <prefix>*`, SQL `LIKE '<prefix>%'`.\n *\n * Optional, because only `dropAll` uses it; without it a back-channel logout answers that it\n * cannot end anything. The prefix never contains a glob metacharacter — see\n * {@link ticketStore} — so the Redis pattern is literal as written. A SQL adapter still escapes\n * `%` and `_`, which percent-encoding produces.\n */\n keys?(prefix: string): AsyncIterable<string>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * `encodeURIComponent`, and the five characters it leaves alone that a glob or a pattern reads:\n * `!`, `'`, `(`, `)` and `*`. A subject spelled `*` must not become a prefix that matches everyone.\n */\nfunction keySegment(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\n/** Where a person's tickets begin, or one IdP session's: `<sub>:` and `<sub>:<sid>:`. */\nfunction prefixOf(subject: SessionSubject): string {\n const sub = `${keySegment(subject.sub)}:`;\n return subject.sid === undefined ? sub : `${sub}${keySegment(subject.sid)}:`;\n}\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * kanzoAuth(() => ({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * replace: async (key, value, ttl) => (await redis.set(key, value, { XX: true, EX: ttl })) === \"OK\",\n * delete: (key) => redis.del(key),\n * // node-redis 5 yields a batch of keys per SCAN step\n * async *keys(prefix) {\n * for await (const batch of redis.scanIterator({ MATCH: `${prefix}*` })) yield* batch;\n * },\n * }),\n * }));\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject and the IdP session, and that is deliberate\n *\n * A ticket is `<sub>:<sid>:<random>`, each part percent-encoded. The random part is the whole of\n * the security — the subject and session id are not secrets and are not trusted on the way back\n * in, because the record they name is read from the row and never from the key. What the prefix\n * buys is the one operation a flat random key makes impossible: *every session belonging to this\n * person*, or *the one Keycloak just ended*. That is `dropAll`, which a back-channel logout calls,\n * and it is a prefix scan through the adapter's `keys` — the same `SCAN sub:*` a deployment could\n * write by hand for \"sign out on every device\".\n *\n * ## `update` keeps the ticket\n *\n * A renewal rewrites the row under the same key, so the cookie that names it is untouched. It goes\n * through `replace`, never `write`, so a row a back-channel logout deleted stays deleted. It also\n * restarts the row's `ttl`, which can then outlive the cookie by up to one `ttl`; that is a row the\n * adapter forgets later, never a session, because nothing can present the expired cookie.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // Encoded per part, not as a whole key: a `sub` is a uuid on every realm anyone has seen, and\n // on the one that makes it something with a colon in it the prefix must still be the prefix.\n // The random part needs no encoding — base64url is already key-safe.\n const ticket = `${prefixOf({ sub: record.session.user.id, sid: record.sid ?? \"\" })}${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async update(ticket, record) {\n const replaced = await bounded(() => adapter.replace(ticket, JSON.stringify(record), ttl));\n return replaced ? ticket : null;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n\n async dropAll(subject) {\n const keys = adapter.keys?.bind(adapter);\n if (keys === undefined) {\n throw new AuthError(\n \"session/irrevocable\",\n \"the ticket adapter has no `keys`, so a person's sessions cannot be found to end them\",\n );\n }\n await bounded(async () => {\n for await (const key of keys(prefixOf(subject))) await adapter.delete(key);\n });\n },\n };\n}\n"],"names":["statelessStore","record","_ticket","ticket","AuthError","DEFAULT_TTL","keySegment","value","c","prefixOf","subject","sub","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","keys","_a","key"],"mappings":";;AAgJO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,OAAOC,GAASD,GAAQ;AAC5B,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIE,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,IACA,MAAM,UAAU;AACd,YAAM,IAAIC;AAAA,QACR;AAAA,QACA;AAAA,MAAA;AAAA,IAEJ;AAAA,EAAA;AAEJ;AA0DA,MAAMC,IAAc,MAAS;AAM7B,SAASC,EAAWC,GAAuB;AACzC,SAAO,mBAAmBA,CAAK,EAAE;AAAA,IAC/B;AAAA,IACA,CAACC,MAAM,IAAIA,EAAE,WAAW,CAAC,EAAE,SAAS,EAAE,EAAE,aAAa;AAAA,EAAA;AAEzD;AAGA,SAASC,EAASC,GAAiC;AACjD,QAAMC,IAAM,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AACtC,SAAOA,EAAQ,QAAQ,SAAYC,IAAM,GAAGA,CAAG,GAAGL,EAAWI,EAAQ,GAAG,CAAC;AAC3E;AAMA,SAASE,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AAsDO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOX,GACpBa,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIlB,GAAQ;AAIhB,YAAME,IAAS,GAAGM,EAAS,EAAE,KAAKR,EAAO,QAAQ,KAAK,IAAI,KAAKA,EAAO,OAAO,GAAA,CAAI,CAAC,GAAGW,GAAc;AACnG,mBAAMM,EAAQ,MAAMH,EAAQ,MAAMZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,GAC/Dd;AAAA,IACT;AAAA,IAEA,MAAM,OAAOA,GAAQF,GAAQ;AAE3B,aADiB,MAAMiB,EAAQ,MAAMH,EAAQ,QAAQZ,GAAQ,KAAK,UAAUF,CAAM,GAAGgB,CAAG,CAAC,IACvEd,IAAS;AAAA,IAC7B;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMI,IAAQ,MAAMW,EAAQ,MAAMH,EAAQ,KAAKZ,CAAM,CAAC;AACtD,UAAII,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKJ,GAAQ;AACjB,YAAMe,EAAQ,MAAMH,EAAQ,OAAOZ,CAAM,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,QAAQO,GAAS;;AACrB,YAAMW,KAAOC,IAAAP,EAAQ,SAAR,gBAAAO,EAAc,KAAKP;AAChC,UAAIM,MAAS;AACX,cAAM,IAAIjB;AAAA,UACR;AAAA,UACA;AAAA,QAAA;AAGJ,YAAMc,EAAQ,YAAY;AACxB,yBAAiBK,KAAOF,EAAKZ,EAASC,CAAO,CAAC,EAAG,OAAMK,EAAQ,OAAOQ,CAAG;AAAA,MAC3E,CAAC;AAAA,IACH;AAAA,EAAA;AAEJ;"}
package/dist/types.d.ts CHANGED
@@ -25,10 +25,11 @@ export interface Organization {
25
25
  /**
26
26
  * The whole of what the client knows about who is signed in.
27
27
  *
28
- * **There is no active organization here, and the absence is the design.** Membership is stable and
29
- * comes from the token; which organization you are *looking at* is a property of the request — the
30
- * URL — and deriving it per request is what lets two tabs sit in two organizations at once. A field
31
- * here would be the single shared value they would fight over.
28
+ * **Membership and the current organization are two different things, and only the first is
29
+ * stored.** Membership is stable and comes from the token. Which organization a request is *in* is
30
+ * a property of that request — its host, its path, a cookie — so `organization` is resolved per
31
+ * request by the product's resolver and never written into the session record. That is what lets
32
+ * two tabs sit in two organizations at once: there is no shared value for them to fight over.
32
33
  *
33
34
  * `roles` are the realm and client roles: global to the person. Roles held inside an organization
34
35
  * live on that `Organization`. They are kept apart on purpose, because merging them is how a role
@@ -38,7 +39,17 @@ export interface Session {
38
39
  readonly user: AuthUser;
39
40
  readonly roles: readonly string[];
40
41
  readonly organizations: readonly Organization[];
41
- /** Epoch milliseconds. The client uses it to refresh early, never to decide access. */
42
+ /**
43
+ * The alias of the organization this request addresses, as the product's resolver answered it.
44
+ *
45
+ * An address, not a proof of membership: `can` answers `false` for an organization the person
46
+ * does not belong to, which is how a product tells "not a member here" from "no tenant".
47
+ */
48
+ readonly organization?: string;
49
+ /**
50
+ * Epoch milliseconds: when the access token expires, which is when the next renewal is due. The
51
+ * client never uses it to decide access.
52
+ */
42
53
  readonly expiresAt: number;
43
54
  }
44
55
  /** Where to come back to, and which organization to ask for, when sending someone to the IdP. */
@@ -46,22 +57,19 @@ export interface SignInOptions {
46
57
  /** Defaults to the current URL. */
47
58
  readonly returnTo?: string;
48
59
  /**
49
- * Ask Keycloak for one organization's scope rather than every one the person belongs to.
50
- * Omitted, a multi-tenant product should request `organization:*` — `DEFAULT_SCOPE` in
51
- * `browser.ts` carries why the star is not optional.
60
+ * Ask Keycloak for one organization's scope in place of `organization:*`, which every sign-in
61
+ * otherwise requests — plain `organization` would make Keycloak prompt for a choice.
52
62
  */
53
63
  readonly organization?: string;
54
64
  }
55
65
  /**
56
- * What a product holds, and the only seam between the two deployment patterns.
57
- *
58
- * RFC 10017 names three architectures for browser applications and this package implements two:
59
- * a **Backend For Frontend**, where the token never reaches the browser and a cookie carries the
60
- * session, and a **browser-based OAuth client** with PKCE, for the SPA that has no server to put a
61
- * confidential client in. `bffAuth` and `browserAuth` are those two, and they are interchangeable
62
- * here — which is what lets `useSession`, `Gate` and `auth.fetch` be written once.
66
+ * What a product holds in the browser: the seam between the hooks and the session behind them.
63
67
  *
64
- * A product names its pattern on one line, at startup, and nothing downstream knows which it chose.
68
+ * RFC 10017 names three architectures for browser applications, and this package implements the
69
+ * one it recommends for business applications: a **Backend For Frontend**, where the token never
70
+ * reaches the browser and a cookie carries the session. `bffAuth` is that implementation. The
71
+ * interface stays an interface so `useSession`, `Gate` and a test double are written against what
72
+ * a session *does*, not against the transport underneath.
65
73
  */
66
74
  export interface Auth {
67
75
  /** The session now, or `null`. Answers from cache and renews when near expiry. */
@@ -75,10 +83,7 @@ export interface Auth {
75
83
  signOut(options?: {
76
84
  readonly returnTo?: string;
77
85
  }): Promise<void>;
78
- /**
79
- * A `fetch` that stays authenticated: the bearer token under one pattern, the cookie riding
80
- * along by itself under the other, and a single retry after a renewal in both.
81
- */
86
+ /** A `fetch` that stays authenticated: the cookie rides along, and one retry after a renewal. */
82
87
  readonly fetch: typeof globalThis.fetch;
83
88
  }
84
89
  /**
@@ -115,8 +120,6 @@ export type AuthErrorCode =
115
120
  * `session/unavailable`.
116
121
  */
117
122
  | "session/silent"
118
- /** Signed in, but holds no membership of the organization being addressed. */
119
- | "organization/not-a-member"
120
123
  /**
121
124
  * The organization asked for is not an alias, so it was not put into a scope.
122
125
  *
@@ -129,8 +132,22 @@ export type AuthErrorCode =
129
132
  | "callback/state-mismatch"
130
133
  /** The ID token's `nonce` is not the one that was sent — a replay. */
131
134
  | "callback/nonce-mismatch"
132
- /** The token endpoint refused the code or the refresh token, or returned no ID token. */
135
+ /**
136
+ * The token endpoint refused the authorization code or answered with something unusable — no ID
137
+ * token, no access token, a client it does not recognise. A deployment fault or a replayed code.
138
+ */
133
139
  | "token/exchange-failed"
140
+ /**
141
+ * A token was refused and the session is over: the IdP answered `invalid_grant` to the refresh
142
+ * token (its SSO session went idle, or was ended), or a back-channel logout token did not verify.
143
+ */
144
+ | "token/refused"
145
+ /**
146
+ * The session store cannot end a session from the server: a stateless store keeps the session in
147
+ * the cookie, and a ticket adapter without `keys` cannot find a person's sessions. A back-channel
148
+ * logout answers 501 for it.
149
+ */
150
+ | "session/irrevocable"
134
151
  /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */
135
152
  | "idp/unreachable"
136
153
  /** The IdP did not answer within `data.after` milliseconds. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mGAAmG;AACnG,MAAM,WAAW,QAAQ;IACvB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,CAAC;IAChD,uFAAuF;IACvF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,IAAI;IACnB,kFAAkF;IAClF,UAAU,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5C,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB;;;GAGG;GACD,qBAAqB;AACvB;;;;GAIG;GACD,gBAAgB;AAClB,8EAA8E;GAC5E,2BAA2B;AAC7B;;;;;;GAMG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B,yFAAyF;GACvF,uBAAuB;AACzB,wGAAwG;GACtG,iBAAiB;AACnB,+DAA+D;GAC7D,YAAY,CAAC;AAEjB,qBAAa,SAAU,SAAQ,KAAK;IAGhC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAE5B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;IAJtE,SAAkB,IAAI,eAAe;gBAE1B,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM,EACN,IAAI,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO,EACzE,OAAO,CAAC,EAAE,YAAY;CAIzB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mGAAmG;AACnG,MAAM,WAAW,QAAQ;IACvB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,CAAC;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,IAAI;IACnB,kFAAkF;IAClF,UAAU,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5C,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,iGAAiG;IACjG,QAAQ,CAAC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB;;;GAGG;GACD,qBAAqB;AACvB;;;;GAIG;GACD,gBAAgB;AAClB;;;;;;GAMG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B;;;GAGG;GACD,uBAAuB;AACzB;;;GAGG;GACD,eAAe;AACjB;;;;GAIG;GACD,qBAAqB;AACvB,wGAAwG;GACtG,iBAAiB;AACnB,+DAA+D;GAC7D,YAAY,CAAC;AAEjB,qBAAa,SAAU,SAAQ,KAAK;IAGhC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAE5B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;IAJtE,SAAkB,IAAI,eAAe;gBAE1B,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM,EACN,IAAI,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO,EACzE,OAAO,CAAC,EAAE,YAAY;CAIzB"}
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../src/types.ts"],"sourcesContent":["/**\n * What a session is, and nothing else. No React, no fetch, no Keycloak — those are `claims.ts`'s\n * problem and the doors'. A consumer reading one file to learn the model should read this one.\n */\n\n/** The person. Every field but `id` is optional because a realm decides which scopes it grants. */\nexport interface AuthUser {\n /** The IdP's stable subject (`sub`). Never an email: an email can be reassigned. */\n readonly id: string;\n readonly email?: string;\n readonly name?: string;\n readonly username?: string;\n}\n\n/**\n * One organization the person belongs to, with the roles they hold *inside it*.\n *\n * `alias` is the addressable name — the one in a hostname and in a Keycloak scope. `id` is the\n * stable uuid, present only when the realm's organization mapper is configured to include it, and\n * is the one to store: an alias can be renamed.\n */\nexport interface Organization {\n readonly alias: string;\n readonly id?: string;\n readonly roles: readonly string[];\n}\n\n/**\n * The whole of what the client knows about who is signed in.\n *\n * **There is no active organization here, and the absence is the design.** Membership is stable and\n * comes from the token; which organization you are *looking at* is a property of the request — the\n * URL — and deriving it per request is what lets two tabs sit in two organizations at once. A field\n * here would be the single shared value they would fight over.\n *\n * `roles` are the realm and client roles: global to the person. Roles held inside an organization\n * live on that `Organization`. They are kept apart on purpose, because merging them is how a role\n * granted in one organization comes to authorise something in another.\n */\nexport interface Session {\n readonly user: AuthUser;\n readonly roles: readonly string[];\n readonly organizations: readonly Organization[];\n /** Epoch milliseconds. The client uses it to refresh early, never to decide access. */\n readonly expiresAt: number;\n}\n\n/** Where to come back to, and which organization to ask for, when sending someone to the IdP. */\nexport interface SignInOptions {\n /** Defaults to the current URL. */\n readonly returnTo?: string;\n /**\n * Ask Keycloak for one organization's scope rather than every one the person belongs to.\n * Omitted, a multi-tenant product should request `organization:*` — `DEFAULT_SCOPE` in\n * `browser.ts` carries why the star is not optional.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds, and the only seam between the two deployment patterns.\n *\n * RFC 10017 names three architectures for browser applications and this package implements two:\n * a **Backend For Frontend**, where the token never reaches the browser and a cookie carries the\n * session, and a **browser-based OAuth client** with PKCE, for the SPA that has no server to put a\n * confidential client in. `bffAuth` and `browserAuth` are those two, and they are interchangeable\n * here — which is what lets `useSession`, `Gate` and `auth.fetch` be written once.\n *\n * A product names its pattern on one line, at startup, and nothing downstream knows which it chose.\n */\nexport interface Auth {\n /** The session now, or `null`. Answers from cache and renews when near expiry. */\n getSession(): Promise<Session | null>;\n /**\n * Call `onChange` when the session does — signed in, signed out, renewed, or changed in another\n * tab. Returns the unsubscribe.\n */\n subscribe(onChange: () => void): () => void;\n signIn(options?: SignInOptions): Promise<void>;\n signOut(options?: { readonly returnTo?: string }): Promise<void>;\n /**\n * A `fetch` that stays authenticated: the bearer token under one pattern, the cookie riding\n * along by itself under the other, and a single retry after a renewal in both.\n */\n readonly fetch: typeof globalThis.fetch;\n}\n\n/**\n * Why a credential was refused, or who did not answer when one was asked for, as a code a product\n * can route on.\n *\n * A boolean cannot be acted upon: \"not signed in\" sends the person to the IdP, \"signed in but not a\n * member\" sends them to a page that says so, and \"the IdP did not answer\" sends them nowhere — it is\n * an outage to name, and sending them to sign in is the loop that hides it. The codes are\n * `area/kind`, the grammar one host registry keys fossil's codes, the rest of kanzo-ui's and its own\n * server's in.\n *\n * **A programming or deployment fault is not one**: `useSession` called outside its provider, or a\n * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would\n * invite a product to `catch` them beside a refusal and route them to a sign-in page, which is the\n * wrong answer to \"you wired this up wrong\".\n *\n * *What would reverse it:* a product needing to route on one of those programmatically rather than\n * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.\n */\nexport type AuthErrorCode =\n /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */\n | \"claims/no-subject\"\n /** There is no session. The person has not signed in, or it expired. */\n | \"session/absent\"\n /**\n * The session could not be read: the session store failed, or the BFF's session endpoint answered\n * something other than a session or a 401. `data.status` is that answer's status.\n */\n | \"session/unavailable\"\n /**\n * The session did not answer within `data.after` milliseconds: in the browser, the BFF's session\n * endpoint; on the server, a `ticketStore`'s adapter — reported there as the cause of a\n * `session/unavailable`.\n */\n | \"session/silent\"\n /** Signed in, but holds no membership of the organization being addressed. */\n | \"organization/not-a-member\"\n /**\n * The organization asked for is not an alias, so it was not put into a scope.\n *\n * The value reaches `begin` from a query parameter on every product with an organization\n * switcher: a space in it is scope injection, and a product wants to answer \"no such\n * organization\" rather than let an unreadable 400 arrive at someone who typed a link wrong.\n */\n | \"organization/invalid\"\n /** The callback's `state` is absent, different, or has no transaction to match against. */\n | \"callback/state-mismatch\"\n /** The ID token's `nonce` is not the one that was sent — a replay. */\n | \"callback/nonce-mismatch\"\n /** The token endpoint refused the code or the refresh token, or returned no ID token. */\n | \"token/exchange-failed\"\n /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */\n | \"idp/unreachable\"\n /** The IdP did not answer within `data.after` milliseconds. */\n | \"idp/silent\";\n\nexport class AuthError extends Error {\n override readonly name = \"AuthError\";\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n readonly data: { readonly after?: number; readonly status?: number } = {},\n options?: ErrorOptions,\n ) {\n super(message, options);\n }\n}\n"],"names":["AuthError","code","message","data","options","__publicField"],"mappings":";;;AA8IO,MAAMA,UAAkB,MAAM;AAAA,EAEnC,YACWC,GACTC,GACSC,IAA8D,CAAA,GACvEC,GACA;AACA,UAAMF,GAASE,CAAO;AAPN,IAAAC,EAAA,cAAO;AAEd,SAAA,OAAAJ,GAEA,KAAA,OAAAE;AAAA,EAIX;AACF;"}
1
+ {"version":3,"file":"types.js","sources":["../src/types.ts"],"sourcesContent":["/**\n * What a session is, and nothing else. No React, no fetch, no Keycloak — those are `claims.ts`'s\n * problem and the doors'. A consumer reading one file to learn the model should read this one.\n */\n\n/** The person. Every field but `id` is optional because a realm decides which scopes it grants. */\nexport interface AuthUser {\n /** The IdP's stable subject (`sub`). Never an email: an email can be reassigned. */\n readonly id: string;\n readonly email?: string;\n readonly name?: string;\n readonly username?: string;\n}\n\n/**\n * One organization the person belongs to, with the roles they hold *inside it*.\n *\n * `alias` is the addressable name — the one in a hostname and in a Keycloak scope. `id` is the\n * stable uuid, present only when the realm's organization mapper is configured to include it, and\n * is the one to store: an alias can be renamed.\n */\nexport interface Organization {\n readonly alias: string;\n readonly id?: string;\n readonly roles: readonly string[];\n}\n\n/**\n * The whole of what the client knows about who is signed in.\n *\n * **Membership and the current organization are two different things, and only the first is\n * stored.** Membership is stable and comes from the token. Which organization a request is *in* is\n * a property of that request — its host, its path, a cookie — so `organization` is resolved per\n * request by the product's resolver and never written into the session record. That is what lets\n * two tabs sit in two organizations at once: there is no shared value for them to fight over.\n *\n * `roles` are the realm and client roles: global to the person. Roles held inside an organization\n * live on that `Organization`. They are kept apart on purpose, because merging them is how a role\n * granted in one organization comes to authorise something in another.\n */\nexport interface Session {\n readonly user: AuthUser;\n readonly roles: readonly string[];\n readonly organizations: readonly Organization[];\n /**\n * The alias of the organization this request addresses, as the product's resolver answered it.\n *\n * An address, not a proof of membership: `can` answers `false` for an organization the person\n * does not belong to, which is how a product tells \"not a member here\" from \"no tenant\".\n */\n readonly organization?: string;\n /**\n * Epoch milliseconds: when the access token expires, which is when the next renewal is due. The\n * client never uses it to decide access.\n */\n readonly expiresAt: number;\n}\n\n/** Where to come back to, and which organization to ask for, when sending someone to the IdP. */\nexport interface SignInOptions {\n /** Defaults to the current URL. */\n readonly returnTo?: string;\n /**\n * Ask Keycloak for one organization's scope in place of `organization:*`, which every sign-in\n * otherwise requests — plain `organization` would make Keycloak prompt for a choice.\n */\n readonly organization?: string;\n}\n\n/**\n * What a product holds in the browser: the seam between the hooks and the session behind them.\n *\n * RFC 10017 names three architectures for browser applications, and this package implements the\n * one it recommends for business applications: a **Backend For Frontend**, where the token never\n * reaches the browser and a cookie carries the session. `bffAuth` is that implementation. The\n * interface stays an interface so `useSession`, `Gate` and a test double are written against what\n * a session *does*, not against the transport underneath.\n */\nexport interface Auth {\n /** The session now, or `null`. Answers from cache and renews when near expiry. */\n getSession(): Promise<Session | null>;\n /**\n * Call `onChange` when the session does — signed in, signed out, renewed, or changed in another\n * tab. Returns the unsubscribe.\n */\n subscribe(onChange: () => void): () => void;\n signIn(options?: SignInOptions): Promise<void>;\n signOut(options?: { readonly returnTo?: string }): Promise<void>;\n /** A `fetch` that stays authenticated: the cookie rides along, and one retry after a renewal. */\n readonly fetch: typeof globalThis.fetch;\n}\n\n/**\n * Why a credential was refused, or who did not answer when one was asked for, as a code a product\n * can route on.\n *\n * A boolean cannot be acted upon: \"not signed in\" sends the person to the IdP, \"signed in but not a\n * member\" sends them to a page that says so, and \"the IdP did not answer\" sends them nowhere — it is\n * an outage to name, and sending them to sign in is the loop that hides it. The codes are\n * `area/kind`, the grammar one host registry keys fossil's codes, the rest of kanzo-ui's and its own\n * server's in.\n *\n * **A programming or deployment fault is not one**: `useSession` called outside its provider, or a\n * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would\n * invite a product to `catch` them beside a refusal and route them to a sign-in page, which is the\n * wrong answer to \"you wired this up wrong\".\n *\n * *What would reverse it:* a product needing to route on one of those programmatically rather than\n * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.\n */\nexport type AuthErrorCode =\n /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */\n | \"claims/no-subject\"\n /** There is no session. The person has not signed in, or it expired. */\n | \"session/absent\"\n /**\n * The session could not be read: the session store failed, or the BFF's session endpoint answered\n * something other than a session or a 401. `data.status` is that answer's status.\n */\n | \"session/unavailable\"\n /**\n * The session did not answer within `data.after` milliseconds: in the browser, the BFF's session\n * endpoint; on the server, a `ticketStore`'s adapter — reported there as the cause of a\n * `session/unavailable`.\n */\n | \"session/silent\"\n /**\n * The organization asked for is not an alias, so it was not put into a scope.\n *\n * The value reaches `begin` from a query parameter on every product with an organization\n * switcher: a space in it is scope injection, and a product wants to answer \"no such\n * organization\" rather than let an unreadable 400 arrive at someone who typed a link wrong.\n */\n | \"organization/invalid\"\n /** The callback's `state` is absent, different, or has no transaction to match against. */\n | \"callback/state-mismatch\"\n /** The ID token's `nonce` is not the one that was sent — a replay. */\n | \"callback/nonce-mismatch\"\n /**\n * The token endpoint refused the authorization code or answered with something unusable — no ID\n * token, no access token, a client it does not recognise. A deployment fault or a replayed code.\n */\n | \"token/exchange-failed\"\n /**\n * A token was refused and the session is over: the IdP answered `invalid_grant` to the refresh\n * token (its SSO session went idle, or was ended), or a back-channel logout token did not verify.\n */\n | \"token/refused\"\n /**\n * The session store cannot end a session from the server: a stateless store keeps the session in\n * the cookie, and a ticket adapter without `keys` cannot find a person's sessions. A back-channel\n * logout answers 501 for it.\n */\n | \"session/irrevocable\"\n /** The IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy page. */\n | \"idp/unreachable\"\n /** The IdP did not answer within `data.after` milliseconds. */\n | \"idp/silent\";\n\nexport class AuthError extends Error {\n override readonly name = \"AuthError\";\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n readonly data: { readonly after?: number; readonly status?: number } = {},\n options?: ErrorOptions,\n ) {\n super(message, options);\n }\n}\n"],"names":["AuthError","code","message","data","options","__publicField"],"mappings":";;;AA+JO,MAAMA,UAAkB,MAAM;AAAA,EAEnC,YACWC,GACTC,GACSC,IAA8D,CAAA,GACvEC,GACA;AACA,UAAMF,GAASE,CAAO;AAPN,IAAAC,EAAA,cAAO;AAEd,SAAA,OAAAJ,GAEA,KAAA,OAAAE;AAAA,EAIX;AACF;"}
@@ -1,14 +1,19 @@
1
1
  import { AuthContextValue } from './auth-context';
2
2
  /**
3
- * Who is signed in, and the two things you can do about it.
3
+ * Who is signed in, the two things you can do about it, and the role question asked here.
4
4
  *
5
5
  * `status` is the field to branch on, not `session === null`: those are the same answer for
6
6
  * "anonymous" and "we have not looked yet", and drawing a sign-in prompt during the second is the
7
7
  * flicker every application with a session has shipped at least once. `"failed"` is the session
8
8
  * that could not be read, with what was thrown on `error`.
9
+ *
10
+ * `can(role, organization?)` is {@link can} bound to this session, so it asks inside the current
11
+ * tenant unless told which — Clerk's `useAuth().has()`. It decides what to draw, never what to
12
+ * allow.
9
13
  */
10
14
  export declare function useSession(): AuthContextValue & {
11
15
  readonly signIn: AuthContextValue["auth"]["signIn"];
12
16
  readonly signOut: AuthContextValue["auth"]["signOut"];
17
+ readonly can: (role: string, organization?: string) => boolean;
13
18
  };
14
19
  //# sourceMappingURL=use-session.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-session.d.ts","sourceRoot":"","sources":["../src/use-session.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEpE;;;;;;;GAOG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC;CACvD,CAcA"}
1
+ {"version":3,"file":"use-session.d.ts","sourceRoot":"","sources":["../src/use-session.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGpE;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC;IACtD,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;CAChE,CAeA"}
@@ -1,19 +1,21 @@
1
1
  "use client";
2
- import { useContext as e } from "react";
3
- import { AuthContext as n } from "./auth-context.js";
4
- function s() {
5
- const t = e(n);
2
+ import { useContext as s } from "react";
3
+ import { AuthContext as e } from "./auth-context.js";
4
+ import { can as r } from "./can.js";
5
+ function h() {
6
+ const t = s(e);
6
7
  if (t === null)
7
8
  throw new Error(
8
- "useSession() was called outside <AuthProvider>. Wrap the application in one, passing the auth it should use — browserAuth() for a SPA, bffAuth() where there is a server."
9
+ "useSession() was called outside <AuthProvider>. Wrap the application in one, passing the auth it should use — bffAuth() for a product whose server runs kanzoAuth."
9
10
  );
10
11
  return {
11
12
  ...t,
12
13
  signIn: t.auth.signIn,
13
- signOut: t.auth.signOut
14
+ signOut: t.auth.signOut,
15
+ can: (n, o) => r(t.session, n, o)
14
16
  };
15
17
  }
16
18
  export {
17
- s as useSession
19
+ h as useSession
18
20
  };
19
21
  //# sourceMappingURL=use-session.js.map