@kanzo-tech/auth 0.26.0 → 0.27.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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The pages of Keycloak's account console a product links to, by the route the console
3
+ * (account-ui, Keycloak 26) declares: personal info at its root, the password and two-factor
4
+ * methods, and the sessions on each device.
5
+ */
6
+ export type AccountPage = "" | "account-security/signing-in" | "account-security/device-activity";
7
+ /**
8
+ * Keycloak's account console for a realm — where a person changes their password, their sign-in
9
+ * methods and their sessions, none of which a product should rebuild: `{issuer}/account`, and one of
10
+ * its pages under it. `issuer` is the **public** issuer, the one the browser is sent to, as
11
+ * `relyingParty` and `browserAuth` take it.
12
+ *
13
+ * Pure, and on the root barrel, because a link is drawn wherever the session is — a server
14
+ * component, a SPA's menu — and needs no protocol to compute.
15
+ */
16
+ export declare function accountUrl(issuer: string, page?: AccountPage): string;
17
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.d.ts","sourceRoot":"","sources":["../src/account.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,EAAE,GAAG,6BAA6B,GAAG,kCAAkC,CAAC;AAElG;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,MAAM,CAGzE"}
@@ -0,0 +1,8 @@
1
+ function n(t, c = "") {
2
+ const o = `${t.replace(/\/+$/, "")}/account`;
3
+ return c ? `${o}/${c}` : o;
4
+ }
5
+ export {
6
+ n as accountUrl
7
+ };
8
+ //# sourceMappingURL=account.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.js","sources":["../src/account.ts"],"sourcesContent":["/**\n * The pages of Keycloak's account console a product links to, by the route the console\n * (account-ui, Keycloak 26) declares: personal info at its root, the password and two-factor\n * methods, and the sessions on each device.\n */\nexport type AccountPage = \"\" | \"account-security/signing-in\" | \"account-security/device-activity\";\n\n/**\n * Keycloak's account console for a realm — where a person changes their password, their sign-in\n * methods and their sessions, none of which a product should rebuild: `{issuer}/account`, and one of\n * its pages under it. `issuer` is the **public** issuer, the one the browser is sent to, as\n * `relyingParty` and `browserAuth` take it.\n *\n * Pure, and on the root barrel, because a link is drawn wherever the session is — a server\n * component, a SPA's menu — and needs no protocol to compute.\n */\nexport function accountUrl(issuer: string, page: AccountPage = \"\"): string {\n const root = `${issuer.replace(/\\/+$/, \"\")}/account`;\n return page ? `${root}/${page}` : root;\n}\n"],"names":["accountUrl","issuer","page","root"],"mappings":"AAgBO,SAASA,EAAWC,GAAgBC,IAAoB,IAAY;AACzE,QAAMC,IAAO,GAAGF,EAAO,QAAQ,QAAQ,EAAE,CAAC;AAC1C,SAAOC,IAAO,GAAGC,CAAI,IAAID,CAAI,KAAKC;AACpC;"}
package/dist/index.d.ts CHANGED
@@ -49,6 +49,7 @@
49
49
  * `bffAuth` lives here rather than behind a subpath for the same reason: with the token on the
50
50
  * server there is no protocol left in the browser, only a `fetch` to a session endpoint.
51
51
  */
52
+ export { accountUrl, type AccountPage } from './account';
52
53
  export { authFetch, type TokenSource } from './auth-fetch';
53
54
  export type { AuthContextValue, AuthStatus } from './auth-context';
54
55
  export { AuthProvider } from './auth-provider';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EACL,SAAS,EACT,KAAK,IAAI,EACT,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,oBAAoB,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EACL,SAAS,EACT,KAAK,IAAI,EACT,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -1,28 +1,30 @@
1
- import { authFetch as t } from "./auth-fetch.js";
2
- import { AuthProvider as f } from "./auth-provider.js";
3
- import { bffAuth as a, readSession as i } from "./bff-auth.js";
4
- import { can as x, organizationOf as n } from "./can.js";
5
- import { claims as h, roleFromGroupPath as u } from "./claims.js";
6
- import { Gate as l } from "./gate.js";
7
- import { organizationFromHost as c } from "./host.js";
8
- import { singleFlight as A } from "./single-flight.js";
9
- import { AuthError as G } from "./types.js";
10
- import { useOrganization as P } from "./use-organization.js";
11
- import { useSession as b } from "./use-session.js";
1
+ import { accountUrl as t } from "./account.js";
2
+ import { authFetch as f } from "./auth-fetch.js";
3
+ import { AuthProvider as a } from "./auth-provider.js";
4
+ import { bffAuth as i, readSession as x } from "./bff-auth.js";
5
+ import { can as s, organizationOf as u } from "./can.js";
6
+ import { claims as c, roleFromGroupPath as g } from "./claims.js";
7
+ import { Gate as F } from "./gate.js";
8
+ import { organizationFromHost as A } from "./host.js";
9
+ import { singleFlight as G } from "./single-flight.js";
10
+ import { AuthError as P } from "./types.js";
11
+ import { useOrganization as b } from "./use-organization.js";
12
+ import { useSession as E } from "./use-session.js";
12
13
  export {
13
- G as AuthError,
14
- f as AuthProvider,
15
- l as Gate,
16
- t as authFetch,
17
- a as bffAuth,
18
- x as can,
19
- h as claims,
20
- c as organizationFromHost,
21
- n as organizationOf,
22
- i as readSession,
23
- u as roleFromGroupPath,
24
- A as singleFlight,
25
- P as useOrganization,
26
- b as useSession
14
+ P as AuthError,
15
+ a as AuthProvider,
16
+ F as Gate,
17
+ t as accountUrl,
18
+ f as authFetch,
19
+ i as bffAuth,
20
+ s as can,
21
+ c as claims,
22
+ A as organizationFromHost,
23
+ u as organizationOf,
24
+ x as readSession,
25
+ g as roleFromGroupPath,
26
+ G as singleFlight,
27
+ b as useOrganization,
28
+ E as useSession
27
29
  };
28
30
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;"}
package/dist/store.d.ts CHANGED
@@ -89,8 +89,13 @@ export declare function statelessStore(): SessionStore;
89
89
  * The two functions and a delete that a store needs from a deployment's own database.
90
90
  *
91
91
  * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace
92
- * and a file on disk — anything narrower would name one of them. The value is already serialized
93
- * and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held
92
+ * and a file on disk — anything narrower would name one of them. **An adapter does not bound its
93
+ * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver
94
+ * call is all an adapter is. What it still owns is the driver's own configuration — a connect
95
+ * timeout, no offline queue — which is what makes a store that is down refuse at once rather than
96
+ * hang until the deadline.
97
+ *
98
+ * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held
94
99
  * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt
95
100
  * the storage, not the row.
96
101
  */
@@ -137,6 +142,14 @@ export interface TicketStoreConfig {
137
142
  * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of
138
143
  * the cookie at once, immediately.
139
144
  *
145
+ * ## Every wait on the adapter is bounded here
146
+ *
147
+ * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not
148
+ * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server
149
+ * reports as `session/unavailable`, with that as its cause, like any other failure of the store.
150
+ * The library makes the wait, so the library bounds it: a deployment that forgot to race its
151
+ * Redis client would otherwise hang a page on a store that stopped answering.
152
+ *
140
153
  * ## The key carries the subject, and that is deliberate
141
154
  *
142
155
  * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,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;;;;;;;;GAQG;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,GAAE,iBAAsB,GAAG,YAAY,CA6BhG"}
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"}
package/dist/store.js CHANGED
@@ -1,4 +1,5 @@
1
- function u() {
1
+ import { deadline as c } from "./deadline.js";
2
+ function l() {
2
3
  return {
3
4
  async put(t) {
4
5
  return JSON.stringify(t);
@@ -15,19 +16,19 @@ function u() {
15
16
  };
16
17
  }
17
18
  const a = 480 * 60;
18
- function s() {
19
+ function u() {
19
20
  const t = crypto.getRandomValues(new Uint8Array(32));
20
21
  return btoa(String.fromCharCode(...t)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
21
22
  }
22
- function o(t, r = {}) {
23
- const c = r.ttl ?? a;
23
+ function y(t, s = {}) {
24
+ const o = s.ttl ?? a, r = (e) => c("session/silent", e);
24
25
  return {
25
26
  async put(e) {
26
- const n = `${encodeURIComponent(e.session.user.id)}:${s()}`;
27
- return await t.write(n, JSON.stringify(e), c), n;
27
+ const n = `${encodeURIComponent(e.session.user.id)}:${u()}`;
28
+ return await r(() => t.write(n, JSON.stringify(e), o)), n;
28
29
  },
29
30
  async get(e) {
30
- const n = await t.read(e);
31
+ const n = await r(() => t.read(e));
31
32
  if (n === null) return null;
32
33
  try {
33
34
  return JSON.parse(n);
@@ -36,12 +37,12 @@ function o(t, r = {}) {
36
37
  }
37
38
  },
38
39
  async drop(e) {
39
- await t.delete(e);
40
+ await r(() => t.delete(e));
40
41
  }
41
42
  };
42
43
  }
43
44
  export {
44
- u as statelessStore,
45
- o as ticketStore
45
+ l as statelessStore,
46
+ y as ticketStore
46
47
  };
47
48
  //# sourceMappingURL=store.js.map
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import type { Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * keasy's `server/src/db/sessions.rs` enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*\n * expiry and is a different number on a realm that gives the two different lifetimes; renewing\n * against the wrong one is how a request goes out with a credential that died a minute ago.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\nexport interface SessionStore {\n /**\n * Store a record and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing\n * out clears the cookie, which is enough for the person holding the browser and is not enough for\n * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session\n * lifetime is therefore a real security parameter under this store, and \"sign out everywhere\" is\n * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n };\n}\n\n/**\n * The two functions and a delete that a store needs from a deployment's own database.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. The value is already serialized\n * 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 * ## 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\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 adapter.write(ticket, JSON.stringify(record), ttl);\n return ticket;\n },\n\n async get(ticket) {\n const value = await 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 adapter.delete(ticket);\n },\n };\n}\n"],"names":["statelessStore","record","ticket","DEFAULT_TTL","opaqueTicket","bytes","ticketStore","adapter","config","ttl","value"],"mappings":"AA0FO,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;AAmCA,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;AAkCO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOL;AAE1B,SAAO;AAAA,IACL,MAAM,IAAIF,GAAQ;AAIhB,YAAMC,IAAS,GAAG,mBAAmBD,EAAO,QAAQ,KAAK,EAAE,CAAC,IAAIG,EAAA,CAAc;AAC9E,mBAAMG,EAAQ,MAAML,GAAQ,KAAK,UAAUD,CAAM,GAAGQ,CAAG,GAChDP;AAAA,IACT;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMQ,IAAQ,MAAMH,EAAQ,KAAKL,CAAM;AACvC,UAAIQ,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKR,GAAQ;AACjB,YAAMK,EAAQ,OAAOL,CAAM;AAAA,IAC7B;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"store.js","sources":["../src/store.ts"],"sourcesContent":["import { deadline } from \"./deadline\";\nimport type { Session } from \"./types\";\n\n/**\n * Where the server keeps what it knows about a signed-in person.\n *\n * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs\n * no database to hold a session. That is the right default and it is not sufficient for everyone —\n * keasy's `server/src/db/sessions.rs` enforces **one live session per user** and wants a sign-out\n * to take effect immediately, and a self-contained cookie can do neither. Both are the same\n * missing ability: a cookie already in someone's hands cannot be taken back.\n *\n * So: one interface, two implementations, and no catalogue. {@link statelessStore} is the cookie;\n * {@link ticketStore} is an opaque ticket over **a key-value adapter the deployment supplies**, so\n * plugging in Redis or a table is two functions rather than a reimplementation of the ticket. No\n * backend ships here — a driver is a dependency and a deployment decision, and neither is this\n * package's to make on its way past — but the *shape* does, because without it every product that\n * wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the\n * thing they all ship instead.\n */\n\n/**\n * What the server holds, and the browser never sees.\n *\n * The tokens are here rather than on {@link Session} because {@link Session} is the shape the\n * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the\n * server, and a type that carried both would make the leak a typo away.\n */\nexport interface SessionRecord {\n readonly session: Session;\n /**\n * The credential for a resource server, and the reason this field exists.\n *\n * Without it a token-mediating backend has nothing `typ: \"Bearer\"` to forward, and what it\n * reaches for instead is the ID token — which works on a realm that happens to put the same\n * audience in both and stops working the day the resource server checks the type, as it should.\n * An ID token says *who signed in*; it was never a key to an API. It is also what\n * `/token/introspect` and `/revoke` take, neither of which is reachable holding the other one.\n */\n readonly accessToken?: string;\n /**\n * Epoch milliseconds, from the token response's `expires_in` rather than from opening the token.\n *\n * The access token is opaque to us by contract — it is the resource server's to read — so its\n * lifetime comes from the envelope it arrived in. {@link Session.expiresAt} is the *ID token's*\n * expiry and is a different number on a realm that gives the two different lifetimes; renewing\n * against the wrong one is how a request goes out with a credential that died a minute ago.\n */\n readonly accessTokenExpiresAt?: number;\n /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */\n readonly refreshToken?: string;\n /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */\n readonly idToken?: string;\n}\n\nexport interface SessionStore {\n /**\n * Store a record and return the ticket that identifies it.\n *\n * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store\n * that enforces one live session per person does it here**, by dropping that person's previous\n * ticket as it issues this one.\n */\n put(record: SessionRecord): Promise<string>;\n /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */\n get(ticket: string): Promise<SessionRecord | null>;\n /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */\n drop(ticket: string): Promise<void>;\n}\n\n/**\n * The default: no server state at all. The ticket *is* the record.\n *\n * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing\n * out clears the cookie, which is enough for the person holding the browser and is not enough for\n * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session\n * lifetime is therefore a real security parameter under this store, and \"sign out everywhere\" is\n * not implementable on top of it. Give `relyingParty` a {@link ticketStore} when either matters.\n *\n * ## It does not fit a record that carries an access token, and the numbers are the argument\n *\n * A browser is only required to keep 4096 bytes of cookie. Sealed with the three tokens a\n * token-mediating backend holds, a realistic Keycloak record — two organizations, the roles that\n * come with them — measures **6407 bytes**, and it measured **4068** before the access token\n * joined it, which is 28 bytes of margin and not a design. `store.test.ts` holds both figures.\n *\n * So this store is for a product that reads identity and calls no resource server. The moment\n * there is an API to call, the cookie carries a ticket instead of the tokens — which is\n * {@link ticketStore}, and the sealing throws with the byte count rather than letting a browser\n * drop the cookie in silence.\n */\nexport function statelessStore(): SessionStore {\n return {\n async put(record) {\n return JSON.stringify(record);\n },\n async get(ticket) {\n try {\n return JSON.parse(ticket) as SessionRecord;\n } catch {\n return null;\n }\n },\n async drop() {\n /* Nothing to forget: see above, and mean it. */\n },\n };\n}\n\n/**\n * The two functions and a delete that a store needs from a deployment's own database.\n *\n * Keys and opaque strings, because that is the intersection of Redis, a SQL table, a KV namespace\n * and a file on disk — anything narrower would name one of them. **An adapter does not bound its\n * own waits**: {@link ticketStore} races every call against the package's deadline, so a driver\n * call is all an adapter is. What it still owns is the driver's own configuration — a connect\n * timeout, no offline queue — which is what makes a store that is down refuse at once rather than\n * hang until the deadline.\n *\n * The value is already serialized and it is **not encrypted**: it lives inside the deployment's own trust boundary, and a key held\n * by the same process that reads the rows protects against a stolen dump and nothing else. Encrypt\n * the storage, not the row.\n */\nexport interface TicketAdapter {\n /** The value written under `key`, or `null` when it is unknown or has expired. */\n read(key: string): Promise<string | null>;\n /**\n * Write `value` under `key`, to be forgotten after `ttl` seconds.\n *\n * **Honouring `ttl` is the adapter's job**, because every store that could hold this already has\n * an expiry of its own — `EX` on Redis, a column and a sweep on SQL — and a timer here would be\n * one that dies with the process. An adapter that ignores it leaks rows; it does not leak\n * sessions, because the sealed cookie carrying the ticket expires on its own schedule.\n */\n write(key: string, value: string, ttl: number): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface TicketStoreConfig {\n /**\n * Seconds a record is kept. Default eight hours — **set it to the `maxAge` you gave\n * `relyingParty`**, which is the lifetime of the cookie that carries the ticket.\n */\n readonly ttl?: number;\n}\n\n/** Eight hours, the same working day `relyingParty` defaults its cookie to. */\nconst DEFAULT_TTL = 8 * 60 * 60;\n\n/**\n * 256 bits from the CSPRNG, base64url. The ticket is a bearer credential in everything but name —\n * it is sealed in the cookie, and it still must not be guessable from another one.\n */\nfunction opaqueTicket(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n return btoa(String.fromCharCode(...bytes)).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/**\n * A store where the cookie carries an opaque ticket and the record lives in the deployment's own\n * database — which is what makes a sign-out a sign-out.\n *\n * ```ts\n * relyingParty({\n * …,\n * store: ticketStore({\n * read: (key) => redis.get(key),\n * write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),\n * delete: (key) => redis.del(key),\n * }),\n * });\n * ```\n *\n * ## What this buys that the cookie cannot\n *\n * **`drop` deletes.** Under {@link statelessStore} the ticket is the record, so a copy of the\n * cookie taken before sign-out keeps working until it expires and *sign out everywhere* is not\n * expressible at all. Here the cookie is a name for a row, and deleting the row ends every copy of\n * the cookie at once, immediately.\n *\n * ## Every wait on the adapter is bounded here\n *\n * Each `read`, `write` and `delete` is raced against `DEADLINE` (30 s), and one that has not\n * answered by then rejects as `AuthError` `session/silent` with `{ after }` — which the server\n * reports as `session/unavailable`, with that as its cause, like any other failure of the store.\n * The library makes the wait, so the library bounds it: a deployment that forgot to race its\n * Redis client would otherwise hang a page on a store that stopped answering.\n *\n * ## The key carries the subject, and that is deliberate\n *\n * A ticket is `<subject>:<random>`. The random half is the whole of the security — the subject is\n * not a secret and is not trusted on the way back in, because the record it names is read from the\n * row and never from the key. What the prefix buys is the one operation a flat random key makes\n * impossible: *every session belonging to this person*. `SCAN sub:*` or `DELETE … WHERE key LIKE\n * 'sub:%'` is then a query a deployment can write, and \"sign out on every device\" and \"one live\n * session per person\" — which `put` is the place for — stop being features this package has to\n * grow an API for.\n */\nexport function ticketStore(adapter: TicketAdapter, config: TicketStoreConfig = {}): SessionStore {\n const ttl = config.ttl ?? DEFAULT_TTL;\n const bounded = <T>(call: () => Promise<T>) => deadline(\"session/silent\", call);\n\n return {\n async put(record) {\n // `encodeURIComponent` on the subject, not on the whole key: a `sub` is a uuid on every realm\n // anyone has seen, and on the one that makes it something with a colon in it the prefix must\n // still be the prefix. The random half needs no encoding — base64url is already key-safe.\n const ticket = `${encodeURIComponent(record.session.user.id)}:${opaqueTicket()}`;\n await bounded(() => adapter.write(ticket, JSON.stringify(record), ttl));\n return ticket;\n },\n\n async get(ticket) {\n const value = await bounded(() => adapter.read(ticket));\n if (value === null) return null;\n try {\n return JSON.parse(value) as SessionRecord;\n } catch {\n // A row that is not a record is a row somebody else wrote, or one written by a version\n // that shaped it differently. Either way it names nobody, which is what `null` says.\n return null;\n }\n },\n\n async drop(ticket) {\n await bounded(() => adapter.delete(ticket));\n },\n };\n}\n"],"names":["statelessStore","record","ticket","DEFAULT_TTL","opaqueTicket","bytes","ticketStore","adapter","config","ttl","bounded","call","deadline","value"],"mappings":";AA2FO,SAASA,IAA+B;AAC7C,SAAO;AAAA,IACL,MAAM,IAAIC,GAAQ;AAChB,aAAO,KAAK,UAAUA,CAAM;AAAA,IAC9B;AAAA,IACA,MAAM,IAAIC,GAAQ;AAChB,UAAI;AACF,eAAO,KAAK,MAAMA,CAAM;AAAA,MAC1B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IACA,MAAM,OAAO;AAAA,IAEb;AAAA,EAAA;AAEJ;AAwCA,MAAMC,IAAc,MAAS;AAM7B,SAASC,IAAuB;AAC9B,QAAMC,IAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACvD,SAAO,KAAK,OAAO,aAAa,GAAGA,CAAK,CAAC,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AACtG;AA0CO,SAASC,EAAYC,GAAwBC,IAA4B,IAAkB;AAChG,QAAMC,IAAMD,EAAO,OAAOL,GACpBO,IAAU,CAAIC,MAA2BC,EAAS,kBAAkBD,CAAI;AAE9E,SAAO;AAAA,IACL,MAAM,IAAIV,GAAQ;AAIhB,YAAMC,IAAS,GAAG,mBAAmBD,EAAO,QAAQ,KAAK,EAAE,CAAC,IAAIG,EAAA,CAAc;AAC9E,mBAAMM,EAAQ,MAAMH,EAAQ,MAAML,GAAQ,KAAK,UAAUD,CAAM,GAAGQ,CAAG,CAAC,GAC/DP;AAAA,IACT;AAAA,IAEA,MAAM,IAAIA,GAAQ;AAChB,YAAMW,IAAQ,MAAMH,EAAQ,MAAMH,EAAQ,KAAKL,CAAM,CAAC;AACtD,UAAIW,MAAU,KAAM,QAAO;AAC3B,UAAI;AACF,eAAO,KAAK,MAAMA,CAAK;AAAA,MACzB,QAAQ;AAGN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,KAAKX,GAAQ;AACjB,YAAMQ,EAAQ,MAAMH,EAAQ,OAAOL,CAAM,CAAC;AAAA,IAC5C;AAAA,EAAA;AAEJ;"}
package/dist/types.d.ts CHANGED
@@ -109,7 +109,11 @@ export type AuthErrorCode =
109
109
  * something other than a session or a 401. `data.status` is that answer's status.
110
110
  */
111
111
  | "session/unavailable"
112
- /** The BFF's session endpoint did not answer within `data.after` milliseconds. */
112
+ /**
113
+ * The session did not answer within `data.after` milliseconds: in the browser, the BFF's session
114
+ * endpoint; on the server, a `ticketStore`'s adapter — reported there as the cause of a
115
+ * `session/unavailable`.
116
+ */
113
117
  | "session/silent"
114
118
  /** Signed in, but holds no membership of the organization being addressed. */
115
119
  | "organization/not-a-member"
@@ -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,kFAAkF;GAChF,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;;;;;;;;;;;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"}
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 /** The BFF's session endpoint did not answer within `data.after` milliseconds. */\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":";;;AA0IO,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 * **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;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Kanzo authentication over Keycloak — the claim vocabulary read into one Session, the role evaluation that knows about organizations, and an authenticated fetch. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude auth from the generic vocabulary by name.",
5
5
  "type": "module",
6
6
  "license": "MIT",