@kanzo-tech/auth 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/auth-fetch.d.ts +12 -0
  2. package/dist/auth-fetch.d.ts.map +1 -1
  3. package/dist/auth-fetch.js +2 -1
  4. package/dist/auth-fetch.js.map +1 -1
  5. package/dist/bff-auth.d.ts.map +1 -1
  6. package/dist/bff-auth.js +63 -48
  7. package/dist/bff-auth.js.map +1 -1
  8. package/dist/cookie-session.d.ts.map +1 -1
  9. package/dist/cookie-session.js +8 -8
  10. package/dist/cookie-session.js.map +1 -1
  11. package/dist/next-proxy.d.ts +65 -0
  12. package/dist/next-proxy.d.ts.map +1 -0
  13. package/dist/next-proxy.js +64 -0
  14. package/dist/next-proxy.js.map +1 -0
  15. package/dist/next-routes.d.ts.map +1 -1
  16. package/dist/next-routes.js +56 -39
  17. package/dist/next-routes.js.map +1 -1
  18. package/dist/next-token.d.ts +56 -0
  19. package/dist/next-token.d.ts.map +1 -0
  20. package/dist/next-token.js +9 -0
  21. package/dist/next-token.js.map +1 -0
  22. package/dist/next.d.ts +26 -12
  23. package/dist/next.d.ts.map +1 -1
  24. package/dist/next.js +10 -6
  25. package/dist/next.js.map +1 -1
  26. package/dist/same-site.d.ts +29 -0
  27. package/dist/same-site.d.ts.map +1 -0
  28. package/dist/same-site.js +11 -0
  29. package/dist/same-site.js.map +1 -0
  30. package/dist/server.d.ts +36 -2
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +109 -84
  33. package/dist/server.js.map +1 -1
  34. package/dist/single-flight.d.ts +18 -0
  35. package/dist/single-flight.d.ts.map +1 -1
  36. package/dist/single-flight.js +18 -6
  37. package/dist/single-flight.js.map +1 -1
  38. package/dist/store.d.ts +102 -3
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/store.js +33 -6
  41. package/dist/store.js.map +1 -1
  42. package/dist/types.d.ts +12 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js.map +1 -1
  45. package/package.json +4 -4
package/dist/store.js CHANGED
@@ -1,11 +1,11 @@
1
- function t() {
1
+ function u() {
2
2
  return {
3
- async put(r) {
4
- return JSON.stringify(r);
3
+ async put(t) {
4
+ return JSON.stringify(t);
5
5
  },
6
- async get(r) {
6
+ async get(t) {
7
7
  try {
8
- return JSON.parse(r);
8
+ return JSON.parse(t);
9
9
  } catch {
10
10
  return null;
11
11
  }
@@ -14,7 +14,34 @@ function t() {
14
14
  }
15
15
  };
16
16
  }
17
+ const a = 480 * 60;
18
+ function s() {
19
+ const t = crypto.getRandomValues(new Uint8Array(32));
20
+ return btoa(String.fromCharCode(...t)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
21
+ }
22
+ function o(t, r = {}) {
23
+ const c = r.ttl ?? a;
24
+ return {
25
+ async put(e) {
26
+ const n = `${encodeURIComponent(e.session.user.id)}:${s()}`;
27
+ return await t.write(n, JSON.stringify(e), c), n;
28
+ },
29
+ async get(e) {
30
+ const n = await t.read(e);
31
+ if (n === null) return null;
32
+ try {
33
+ return JSON.parse(n);
34
+ } catch {
35
+ return null;
36
+ }
37
+ },
38
+ async drop(e) {
39
+ await t.delete(e);
40
+ }
41
+ };
42
+ }
17
43
  export {
18
- t as statelessStore
44
+ u as statelessStore,
45
+ o as ticketStore
19
46
  };
20
47
  //# 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, one default implementation, and no catalogue. A Redis or SQL store is three\n * methods a deployment writes; it is not a decision this package should be making on its way past.\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 /** 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 store of your own when either matters.\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"],"names":["statelessStore","record","ticket"],"mappings":"AAsDO,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;"}
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;"}
package/dist/types.d.ts CHANGED
@@ -89,9 +89,9 @@ export interface Auth {
89
89
  * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports
90
90
  * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.
91
91
  *
92
- * **These codes are about a credential, and only about a credential.** A programming or deployment
93
- * fault is not one: `useSession` called outside its provider, or a session too large for a cookie,
94
- * both throw a plain `Error` on purpose. Giving those codes would invite a product to `catch` them
92
+ * **These codes are about a credential, or about the request for one, and about nothing else.** A
93
+ * programming or deployment fault is not one: `useSession` called outside its provider, or a
94
+ * session too large for a cookie, both throw a plain `Error` on purpose. Giving those codes would invite a product to `catch` them
95
95
  * beside a refusal and route them to a sign-in page, which is the wrong answer to "you wired this
96
96
  * up wrong" — and it would put a deployment mistake in the same type as a user's session expiring.
97
97
  *
@@ -105,6 +105,15 @@ export type AuthErrorCode =
105
105
  | "session.absent"
106
106
  /** Signed in, but holds no membership of the organization being addressed. */
107
107
  | "organization.not-a-member"
108
+ /**
109
+ * The organization asked for is not an alias, so it was not put into a scope.
110
+ *
111
+ * The one code here about the *request for* a credential rather than about a credential, and it
112
+ * earns that because the value reaches `begin` from a query parameter on every product with an
113
+ * organization switcher: a space in it is scope injection, and a product wants to answer "no
114
+ * such organization" rather than let an unreadable 400 arrive at someone who typed a link wrong.
115
+ */
116
+ | "organization.invalid"
108
117
  /** The callback's `state` is absent, different, or has no transaction to match against. */
109
118
  | "callback.state-mismatch"
110
119
  /** The ID token's `nonce` is not the one that was sent — a replay. */
@@ -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;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB,8EAA8E;GAC5E,2BAA2B;AAC7B,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B,yFAAyF;GACvF,uBAAuB,CAAC;AAE5B,qBAAa,SAAU,SAAQ,KAAK;IAEhC,QAAQ,CAAC,IAAI,EAAE,aAAa;gBAAnB,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM;CAKlB"}
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;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,aAAa;AACvB,sGAAsG;AACpG,mBAAmB;AACrB,wEAAwE;GACtE,gBAAgB;AAClB,8EAA8E;GAC5E,2BAA2B;AAC7B;;;;;;;GAOG;GACD,sBAAsB;AACxB,2FAA2F;GACzF,yBAAyB;AAC3B,sEAAsE;GACpE,yBAAyB;AAC3B,yFAAyF;GACvF,uBAAuB,CAAC;AAE5B,qBAAa,SAAU,SAAQ,KAAK;IAEhC,QAAQ,CAAC,IAAI,EAAE,aAAa;gBAAnB,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM;CAKlB"}
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, as a code a product 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 telling them apart is the difference between a\n * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports\n * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.\n *\n * **These codes are about a credential, and only about a credential.** A programming or deployment\n * fault is not one: `useSession` called outside its provider, or a session too large for a cookie,\n * both throw a plain `Error` on purpose. Giving those codes would invite a product to `catch` them\n * beside a refusal and route them to a sign-in page, which is the wrong answer to \"you wired this\n * up wrong\" — and it would put a deployment mistake in the same type as a user's session expiring.\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 /** Signed in, but holds no membership of the organization being addressed. */\n | \"organization.not-a-member\"\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\nexport class AuthError extends Error {\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"AuthError\";\n }\n}\n"],"names":["AuthError","code","message"],"mappings":"AAsHO,MAAMA,UAAkB,MAAM;AAAA,EACnC,YACWC,GACTC,GACA;AACA,UAAMA,CAAO,GAHJ,KAAA,OAAAD,GAIT,KAAK,OAAO;AAAA,EACd;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, as a code a product 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 telling them apart is the difference between a\n * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports\n * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.\n *\n * **These codes are about a credential, or about the request for one, and about nothing else.** A\n * 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 invite a product to `catch` them\n * beside a refusal and route them to a sign-in page, which is the wrong answer to \"you wired this\n * up wrong\" — and it would put a deployment mistake in the same type as a user's session expiring.\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 /** 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 one code here about the *request for* a credential rather than about a credential, and it\n * earns that because the value reaches `begin` from a query parameter on every product with an\n * organization switcher: a space in it is scope injection, and a product wants to answer \"no\n * such 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\nexport class AuthError extends Error {\n constructor(\n readonly code: AuthErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"AuthError\";\n }\n}\n"],"names":["AuthError","code","message"],"mappings":"AA+HO,MAAMA,UAAkB,MAAM;AAAA,EACnC,YACWC,GACTC,GACA;AACA,UAAMA,CAAO,GAHJ,KAAA,OAAAD,GAIT,KAAK,OAAO;AAAA,EACd;AACF;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/auth",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -66,7 +66,7 @@
66
66
  "react-dom": "^19.0.0",
67
67
  "rollup-plugin-preserve-directives": "^0.4.0"
68
68
  },
69
- "//size-limit": "The root barrel was 677 B at the first commit — the claim reader, the role predicate and the types — against a 1 kB budget set deliberately tight, with a note saying the raise would be a line in the commit that landed the hooks rather than a quiet edit. This is that line: 677 B -> 2.19 kB, and the budget goes to 2.5 kB. What grew is the provider, three hooks, `Gate` and `bffAuth`, which is the whole of what a consumer imports to have a session. Every budget here is set just above its first measurement for the same reason: one with 70% headroom detects nothing. Each subpath ignores its own engine, because the consumer installs that explicitly and what is being measured is our adapter over it. What these numbers are really watching for is a door reaching through another: the root barrel must never learn a protocol, and the two Node doors must never learn React. A jump of kilobytes on any of them means one of those happened, and `scripts/smoke-install.mjs` reads the built bytes to say which.",
69
+ "//size-limit": "The root barrel was 677 B at the first commit — the claim reader, the role predicate and the types — against a 1 kB budget set deliberately tight, with a note saying the raise would be a line in the commit that landed the hooks rather than a quiet edit. This is that line: 677 B -> 2.19 kB, and the budget goes to 2.5 kB. What grew is the provider, three hooks, `Gate` and `bffAuth`, which is the whole of what a consumer imports to have a session. Every budget here is set just above its first measurement for the same reason: one with 70% headroom detects nothing. Each subpath ignores its own engine, because the consumer installs that explicitly and what is being measured is our adapter over it. What these numbers are really watching for is a door reaching through another: the root barrel must never learn a protocol, and the two Node doors must never learn React. A jump of kilobytes on any of them means one of those happened, and `scripts/smoke-install.mjs` reads the built bytes to say which. Second raise, and it is a decision rather than a quiet edit: the session lifecycle — a refresh route, `authToken`, `authProxy`, `ticketStore` and the same-site check — moved the two Node doors, `server` 3.2 kB -> 3.4 kB (budget 3.6) and `next` 4 kB -> 4.59 kB (budget 5). What is being watched for is unchanged and still holds: neither door learned React and the barrel learned no protocol, which is why the other two numbers barely moved.",
70
70
  "size-limit": [
71
71
  {
72
72
  "name": "root barrel (JS)",
@@ -92,7 +92,7 @@
92
92
  {
93
93
  "name": "server subpath (JS)",
94
94
  "path": "dist/server.js",
95
- "limit": "3.2 kB",
95
+ "limit": "3.6 kB",
96
96
  "ignore": [
97
97
  "react",
98
98
  "react-dom",
@@ -104,7 +104,7 @@
104
104
  {
105
105
  "name": "next subpath (JS)",
106
106
  "path": "dist/next.js",
107
- "limit": "4 kB",
107
+ "limit": "5 kB",
108
108
  "ignore": [
109
109
  "react",
110
110
  "react-dom",