@kanzo-tech/auth 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +103 -0
  2. package/dist/auth-context.d.ts +13 -0
  3. package/dist/auth-context.d.ts.map +1 -0
  4. package/dist/auth-context.js +7 -0
  5. package/dist/auth-context.js.map +1 -0
  6. package/dist/auth-fetch.d.ts +32 -0
  7. package/dist/auth-fetch.d.ts.map +1 -0
  8. package/dist/auth-fetch.js +20 -0
  9. package/dist/auth-fetch.js.map +1 -0
  10. package/dist/auth-provider.d.ts +12 -0
  11. package/dist/auth-provider.d.ts.map +1 -0
  12. package/dist/auth-provider.js +35 -0
  13. package/dist/auth-provider.js.map +1 -0
  14. package/dist/bff-auth.d.ts +32 -0
  15. package/dist/bff-auth.d.ts.map +1 -0
  16. package/dist/bff-auth.js +70 -0
  17. package/dist/bff-auth.js.map +1 -0
  18. package/dist/browser.d.ts +61 -0
  19. package/dist/browser.d.ts.map +1 -0
  20. package/dist/browser.js +107 -0
  21. package/dist/browser.js.map +1 -0
  22. package/dist/can.d.ts +25 -0
  23. package/dist/can.d.ts.map +1 -0
  24. package/dist/can.js +12 -0
  25. package/dist/can.js.map +1 -0
  26. package/dist/claims.d.ts +50 -0
  27. package/dist/claims.d.ts.map +1 -0
  28. package/dist/claims.js +56 -0
  29. package/dist/claims.js.map +1 -0
  30. package/dist/cookie-session.d.ts +28 -0
  31. package/dist/cookie-session.d.ts.map +1 -0
  32. package/dist/cookie-session.js +51 -0
  33. package/dist/cookie-session.js.map +1 -0
  34. package/dist/gate.d.ts +16 -0
  35. package/dist/gate.d.ts.map +1 -0
  36. package/dist/gate.js +17 -0
  37. package/dist/gate.js.map +1 -0
  38. package/dist/host.d.ts +22 -0
  39. package/dist/host.d.ts.map +1 -0
  40. package/dist/host.js +10 -0
  41. package/dist/host.js.map +1 -0
  42. package/dist/index.d.ts +64 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +28 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/issuer.d.ts +92 -0
  47. package/dist/issuer.d.ts.map +1 -0
  48. package/dist/issuer.js +42 -0
  49. package/dist/issuer.js.map +1 -0
  50. package/dist/next-middleware.d.ts +17 -0
  51. package/dist/next-middleware.d.ts.map +1 -0
  52. package/dist/next-middleware.js +21 -0
  53. package/dist/next-middleware.js.map +1 -0
  54. package/dist/next-routes.d.ts +19 -0
  55. package/dist/next-routes.d.ts.map +1 -0
  56. package/dist/next-routes.js +65 -0
  57. package/dist/next-routes.js.map +1 -0
  58. package/dist/next-session.d.ts +44 -0
  59. package/dist/next-session.d.ts.map +1 -0
  60. package/dist/next-session.js +11 -0
  61. package/dist/next-session.js.map +1 -0
  62. package/dist/next.d.ts +42 -0
  63. package/dist/next.d.ts.map +1 -0
  64. package/dist/next.js +9 -0
  65. package/dist/next.js.map +1 -0
  66. package/dist/server.d.ts +53 -0
  67. package/dist/server.d.ts.map +1 -0
  68. package/dist/server.js +157 -0
  69. package/dist/server.js.map +1 -0
  70. package/dist/single-flight.d.ts +15 -0
  71. package/dist/single-flight.d.ts.map +1 -0
  72. package/dist/single-flight.js +10 -0
  73. package/dist/single-flight.js.map +1 -0
  74. package/dist/store.d.ts +52 -0
  75. package/dist/store.d.ts.map +1 -0
  76. package/dist/store.js +20 -0
  77. package/dist/store.js.map +1 -0
  78. package/dist/types.d.ts +118 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +9 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/use-organization.d.ts +22 -0
  83. package/dist/use-organization.d.ts.map +1 -0
  84. package/dist/use-organization.js +20 -0
  85. package/dist/use-organization.js.map +1 -0
  86. package/dist/use-session.d.ts +13 -0
  87. package/dist/use-session.d.ts.map +1 -0
  88. package/dist/use-session.js +19 -0
  89. package/dist/use-session.js.map +1 -0
  90. package/package.json +135 -0
@@ -0,0 +1,52 @@
1
+ import { Session } from './types';
2
+ /**
3
+ * Where the server keeps what it knows about a signed-in person.
4
+ *
5
+ * The default is a cookie and nothing else: the record is sealed into it, and the deployment needs
6
+ * no database to hold a session. That is the right default and it is not sufficient for everyone —
7
+ * keasy's `server/src/db/sessions.rs` enforces **one live session per user** and wants a sign-out
8
+ * to take effect immediately, and a self-contained cookie can do neither. Both are the same
9
+ * missing ability: a cookie already in someone's hands cannot be taken back.
10
+ *
11
+ * So: one interface, one default implementation, and no catalogue. A Redis or SQL store is three
12
+ * methods a deployment writes; it is not a decision this package should be making on its way past.
13
+ */
14
+ /**
15
+ * What the server holds, and the browser never sees.
16
+ *
17
+ * The tokens are here rather than on {@link Session} because {@link Session} is the shape the
18
+ * browser is given. Under the BFF pattern the whole point is that the refresh token stops at the
19
+ * server, and a type that carried both would make the leak a typo away.
20
+ */
21
+ export interface SessionRecord {
22
+ readonly session: Session;
23
+ /** Rotated on every use, per RFC 10017. The stored one is always the newest issued. */
24
+ readonly refreshToken?: string;
25
+ /** Kept for `id_token_hint` at the end-session endpoint; without it the IdP asks who is leaving. */
26
+ readonly idToken?: string;
27
+ }
28
+ export interface SessionStore {
29
+ /**
30
+ * Store a record and return the ticket that identifies it.
31
+ *
32
+ * The ticket is what the cookie carries — sealed, so it never travels in the clear. **A store
33
+ * that enforces one live session per person does it here**, by dropping that person's previous
34
+ * ticket as it issues this one.
35
+ */
36
+ put(record: SessionRecord): Promise<string>;
37
+ /** The record, or `null` when the ticket is unknown, expired, or has been revoked. */
38
+ get(ticket: string): Promise<SessionRecord | null>;
39
+ /** Forget it. Sign-out, and the immediate invalidation a self-contained cookie cannot do. */
40
+ drop(ticket: string): Promise<void>;
41
+ }
42
+ /**
43
+ * The default: no server state at all. The ticket *is* the record.
44
+ *
45
+ * What it cannot do, stated plainly rather than discovered later: **`drop` does nothing.** Signing
46
+ * out clears the cookie, which is enough for the person holding the browser and is not enough for
47
+ * anyone else — a copy of that cookie taken beforehand keeps working until it expires. A session
48
+ * lifetime is therefore a real security parameter under this store, and "sign out everywhere" is
49
+ * not implementable on top of it. Give `relyingParty` a store of your own when either matters.
50
+ */
51
+ export declare function statelessStore(): SessionStore;
52
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +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;;;;;;;;;;;GAWG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,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;;;;;;;;GAQG;AACH,wBAAgB,cAAc,IAAI,YAAY,CAgB7C"}
package/dist/store.js ADDED
@@ -0,0 +1,20 @@
1
+ function t() {
2
+ return {
3
+ async put(r) {
4
+ return JSON.stringify(r);
5
+ },
6
+ async get(r) {
7
+ try {
8
+ return JSON.parse(r);
9
+ } catch {
10
+ return null;
11
+ }
12
+ },
13
+ async drop() {
14
+ }
15
+ };
16
+ }
17
+ export {
18
+ t as statelessStore
19
+ };
20
+ //# sourceMappingURL=store.js.map
@@ -0,0 +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;"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * What a session is, and nothing else. No React, no fetch, no Keycloak — those are `claims.ts`'s
3
+ * problem and the doors'. A consumer reading one file to learn the model should read this one.
4
+ */
5
+ /** The person. Every field but `id` is optional because a realm decides which scopes it grants. */
6
+ export interface AuthUser {
7
+ /** The IdP's stable subject (`sub`). Never an email: an email can be reassigned. */
8
+ readonly id: string;
9
+ readonly email?: string;
10
+ readonly name?: string;
11
+ readonly username?: string;
12
+ }
13
+ /**
14
+ * One organization the person belongs to, with the roles they hold *inside it*.
15
+ *
16
+ * `alias` is the addressable name — the one in a hostname and in a Keycloak scope. `id` is the
17
+ * stable uuid, present only when the realm's organization mapper is configured to include it, and
18
+ * is the one to store: an alias can be renamed.
19
+ */
20
+ export interface Organization {
21
+ readonly alias: string;
22
+ readonly id?: string;
23
+ readonly roles: readonly string[];
24
+ }
25
+ /**
26
+ * The whole of what the client knows about who is signed in.
27
+ *
28
+ * **There is no active organization here, and the absence is the design.** Membership is stable and
29
+ * comes from the token; which organization you are *looking at* is a property of the request — the
30
+ * URL — and deriving it per request is what lets two tabs sit in two organizations at once. A field
31
+ * here would be the single shared value they would fight over.
32
+ *
33
+ * `roles` are the realm and client roles: global to the person. Roles held inside an organization
34
+ * live on that `Organization`. They are kept apart on purpose, because merging them is how a role
35
+ * granted in one organization comes to authorise something in another.
36
+ */
37
+ export interface Session {
38
+ readonly user: AuthUser;
39
+ readonly roles: readonly string[];
40
+ readonly organizations: readonly Organization[];
41
+ /** Epoch milliseconds. The client uses it to refresh early, never to decide access. */
42
+ readonly expiresAt: number;
43
+ }
44
+ /** Where to come back to, and which organization to ask for, when sending someone to the IdP. */
45
+ export interface SignInOptions {
46
+ /** Defaults to the current URL. */
47
+ readonly returnTo?: string;
48
+ /**
49
+ * Ask Keycloak for one organization's scope rather than every one the person belongs to.
50
+ * Omitted, a multi-tenant product should request `organization:*` — `DEFAULT_SCOPE` in
51
+ * `browser.ts` carries why the star is not optional.
52
+ */
53
+ readonly organization?: string;
54
+ }
55
+ /**
56
+ * What a product holds, and the only seam between the two deployment patterns.
57
+ *
58
+ * RFC 10017 names three architectures for browser applications and this package implements two:
59
+ * a **Backend For Frontend**, where the token never reaches the browser and a cookie carries the
60
+ * session, and a **browser-based OAuth client** with PKCE, for the SPA that has no server to put a
61
+ * confidential client in. `bffAuth` and `browserAuth` are those two, and they are interchangeable
62
+ * here — which is what lets `useSession`, `Gate` and `auth.fetch` be written once.
63
+ *
64
+ * A product names its pattern on one line, at startup, and nothing downstream knows which it chose.
65
+ */
66
+ export interface Auth {
67
+ /** The session now, or `null`. Answers from cache and renews when near expiry. */
68
+ getSession(): Promise<Session | null>;
69
+ /**
70
+ * Call `onChange` when the session does — signed in, signed out, renewed, or changed in another
71
+ * tab. Returns the unsubscribe.
72
+ */
73
+ subscribe(onChange: () => void): () => void;
74
+ signIn(options?: SignInOptions): Promise<void>;
75
+ signOut(options?: {
76
+ readonly returnTo?: string;
77
+ }): Promise<void>;
78
+ /**
79
+ * A `fetch` that stays authenticated: the bearer token under one pattern, the cookie riding
80
+ * along by itself under the other, and a single retry after a renewal in both.
81
+ */
82
+ readonly fetch: typeof globalThis.fetch;
83
+ }
84
+ /**
85
+ * Why a credential was refused, as a code a product can route on.
86
+ *
87
+ * A boolean cannot be acted upon: "not signed in" sends the person to the IdP, "signed in but not a
88
+ * member" sends them to a page that says so, and telling them apart is the difference between a
89
+ * redirect loop and an explanation. The shape is borrowed from `agents/gateway`, which reports
90
+ * `validity.expired` / `proof.signature-invalid` / `issuer.unexpected` for the same reason.
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
95
+ * beside a refusal and route them to a sign-in page, which is the wrong answer to "you wired this
96
+ * up wrong" — and it would put a deployment mistake in the same type as a user's session expiring.
97
+ *
98
+ * *What would reverse it:* a product needing to route on one of those programmatically rather than
99
+ * read it in a stack trace. None has; both are faults you fix once, not conditions you handle.
100
+ */
101
+ export type AuthErrorCode =
102
+ /** The claims carry no `sub`. Not a session at all — a configuration or IdP fault, never a user's. */
103
+ "claims.no-subject"
104
+ /** There is no session. The person has not signed in, or it expired. */
105
+ | "session.absent"
106
+ /** Signed in, but holds no membership of the organization being addressed. */
107
+ | "organization.not-a-member"
108
+ /** The callback's `state` is absent, different, or has no transaction to match against. */
109
+ | "callback.state-mismatch"
110
+ /** The ID token's `nonce` is not the one that was sent — a replay. */
111
+ | "callback.nonce-mismatch"
112
+ /** The token endpoint refused the code or the refresh token, or returned no ID token. */
113
+ | "token.exchange-failed";
114
+ export declare class AuthError extends Error {
115
+ readonly code: AuthErrorCode;
116
+ constructor(code: AuthErrorCode, message: string);
117
+ }
118
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +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"}
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ class s extends Error {
2
+ constructor(r, t) {
3
+ super(t), this.code = r, this.name = "AuthError";
4
+ }
5
+ }
6
+ export {
7
+ s as AuthError
8
+ };
9
+ //# sourceMappingURL=types.js.map
@@ -0,0 +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;"}
@@ -0,0 +1,22 @@
1
+ import { Organization } from './types';
2
+ /**
3
+ * Which organization this view is *in*, resolved per render rather than stored.
4
+ *
5
+ * The session says which organizations you belong to; the URL says which one you are looking at.
6
+ * `Session` in `types.ts` carries why the second is never stored.
7
+ *
8
+ * `alias` names the organization explicitly; omitted, it is read from the hostname. A hostname
9
+ * naming an organization this person does not belong to yields `organization: undefined` and
10
+ * `isMember: false` — never a throw, and **never the first organization instead**. Falling back
11
+ * is how someone ends up reading another customer's data believing it is their own.
12
+ *
13
+ * `isMember` answers about the *candidate*, so it is a different question from
14
+ * `organizations.length > 0`: a product with one membership addressed at the wrong host is a member
15
+ * of something and a member of nothing here.
16
+ */
17
+ export declare function useOrganization(alias?: string): {
18
+ readonly organization: Organization | undefined;
19
+ readonly organizations: readonly Organization[];
20
+ readonly isMember: boolean;
21
+ };
22
+ //# sourceMappingURL=use-organization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-organization.d.ts","sourceRoot":"","sources":["../src/use-organization.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG5C;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG;IAC/C,QAAQ,CAAC,YAAY,EAAE,YAAY,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,CAAC;IAChD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B,CAmBA"}
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import { useMemo as r } from "react";
3
+ import { organizationOf as a } from "./can.js";
4
+ import { organizationFromHost as e } from "./host.js";
5
+ import { useSession as m } from "./use-session.js";
6
+ function c(i) {
7
+ const { session: o } = m();
8
+ return r(() => {
9
+ const n = i ?? (typeof window > "u" ? void 0 : e(window.location.hostname)), t = n === void 0 ? void 0 : a(o, n);
10
+ return {
11
+ organization: t,
12
+ organizations: (o == null ? void 0 : o.organizations) ?? [],
13
+ isMember: t !== void 0
14
+ };
15
+ }, [i, o]);
16
+ }
17
+ export {
18
+ c as useOrganization
19
+ };
20
+ //# sourceMappingURL=use-organization.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-organization.js","sources":["../src/use-organization.ts"],"sourcesContent":["\"use client\";\n\nimport { useMemo } from \"react\";\nimport { organizationOf } from \"./can\";\nimport { organizationFromHost } from \"./host\";\nimport type { Organization } from \"./types\";\nimport { useSession } from \"./use-session\";\n\n/**\n * Which organization this view is *in*, resolved per render rather than stored.\n *\n * The session says which organizations you belong to; the URL says which one you are looking at.\n * `Session` in `types.ts` carries why the second is never stored.\n *\n * `alias` names the organization explicitly; omitted, it is read from the hostname. A hostname\n * naming an organization this person does not belong to yields `organization: undefined` and\n * `isMember: false` — never a throw, and **never the first organization instead**. Falling back\n * is how someone ends up reading another customer's data believing it is their own.\n *\n * `isMember` answers about the *candidate*, so it is a different question from\n * `organizations.length > 0`: a product with one membership addressed at the wrong host is a member\n * of something and a member of nothing here.\n */\nexport function useOrganization(alias?: string): {\n readonly organization: Organization | undefined;\n readonly organizations: readonly Organization[];\n readonly isMember: boolean;\n} {\n const { session } = useSession();\n\n return useMemo(() => {\n // Read at render, and undefined on a server. There is no hydration mismatch to manage: the\n // session itself arrives in the provider's effect, so the server render and the first client\n // render both resolve against `null` and agree whatever the hostname says.\n const candidate =\n alias ??\n (typeof window === \"undefined\" ? undefined : organizationFromHost(window.location.hostname));\n\n const organization = candidate === undefined ? undefined : organizationOf(session, candidate);\n\n return {\n organization,\n organizations: session?.organizations ?? [],\n isMember: organization !== undefined,\n };\n }, [alias, session]);\n}\n"],"names":[],"mappings":";;;;;AAuBO;AAKL;AAEA;AAIE;AAMA;AAAO;AACL;AACyC;AACd;AAAA;AAGjC;;;;"}
@@ -0,0 +1,13 @@
1
+ import { AuthContextValue } from './auth-context';
2
+ /**
3
+ * Who is signed in, and the two things you can do about it.
4
+ *
5
+ * `status` is the field to branch on, not `session === null`: those are the same answer for
6
+ * "anonymous" and "we have not looked yet", and drawing a sign-in prompt during the second is the
7
+ * flicker every application with a session has shipped at least once.
8
+ */
9
+ export declare function useSession(): AuthContextValue & {
10
+ readonly signIn: AuthContextValue["auth"]["signIn"];
11
+ readonly signOut: AuthContextValue["auth"]["signOut"];
12
+ };
13
+ //# sourceMappingURL=use-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-session.d.ts","sourceRoot":"","sources":["../src/use-session.ts"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEpE;;;;;;GAMG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC;CACvD,CAcA"}
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { useContext as e } from "react";
3
+ import { AuthContext as n } from "./auth-context.js";
4
+ function s() {
5
+ const t = e(n);
6
+ if (t === null)
7
+ throw new Error(
8
+ "useSession() was called outside <AuthProvider>. Wrap the application in one, passing the auth it should use — browserAuth() for a SPA, bffAuth() where there is a server."
9
+ );
10
+ return {
11
+ ...t,
12
+ signIn: t.auth.signIn,
13
+ signOut: t.auth.signOut
14
+ };
15
+ }
16
+ export {
17
+ s as useSession
18
+ };
19
+ //# sourceMappingURL=use-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-session.js","sources":["../src/use-session.ts"],"sourcesContent":["\"use client\";\n\nimport { useContext } from \"react\";\nimport { AuthContext, type AuthContextValue } from \"./auth-context\";\n\n/**\n * Who is signed in, and the two things you can do about it.\n *\n * `status` is the field to branch on, not `session === null`: those are the same answer for\n * \"anonymous\" and \"we have not looked yet\", and drawing a sign-in prompt during the second is the\n * flicker every application with a session has shipped at least once.\n */\nexport function useSession(): AuthContextValue & {\n readonly signIn: AuthContextValue[\"auth\"][\"signIn\"];\n readonly signOut: AuthContextValue[\"auth\"][\"signOut\"];\n} {\n const context = useContext(AuthContext);\n if (context === null) {\n throw new Error(\n \"useSession() was called outside <AuthProvider>. Wrap the application in one, passing the \" +\n \"auth it should use — browserAuth() for a SPA, bffAuth() where there is a server.\",\n );\n }\n\n return {\n ...context,\n signIn: context.auth.signIn,\n signOut: context.auth.signOut,\n };\n}\n"],"names":[],"mappings":";;;AAYO;AAIL;AACA;AACE;AAAU;AACR;AAKJ;AAAO;AACF;AACkB;AACC;AAE1B;;;;"}
package/package.json ADDED
@@ -0,0 +1,135 @@
1
+ {
2
+ "name": "@kanzo-tech/auth",
3
+ "version": "0.2.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./browser": {
20
+ "types": "./dist/browser.d.ts",
21
+ "import": "./dist/browser.js"
22
+ },
23
+ "./server": {
24
+ "types": "./dist/server.d.ts",
25
+ "import": "./dist/server.js"
26
+ },
27
+ "./next": {
28
+ "types": "./dist/next.d.ts",
29
+ "import": "./dist/next.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "//peers": "react is REQUIRED: the root barrel exports a provider and hooks, and two copies of React are two context trees. Everything else is optional and lives behind a subpath, because the two deployment patterns in RFC 10017 need different engines and no consumer needs both — oidc-client-ts is the browser protocol behind `./browser`, openid-client and jose are the confidential client behind `./server`, and next is behind `./next`. A SPA installs this package alone. Deliberately absent: tailwind-variants, because nothing here draws — which is also what keeps this package out of the appearance guards, whose corpus in packages/ui/src/guard-corpus.ts selects on exactly that dependency. The day this package grows a screen the dependency arrives with it and the guards pick it up unasked; no list anywhere needs editing.",
34
+ "peerDependencies": {
35
+ "jose": "^6",
36
+ "next": ">=15",
37
+ "oidc-client-ts": "^3",
38
+ "openid-client": "^6",
39
+ "react": ">=19"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "jose": {
43
+ "optional": true
44
+ },
45
+ "next": {
46
+ "optional": true
47
+ },
48
+ "oidc-client-ts": {
49
+ "optional": true
50
+ },
51
+ "openid-client": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@testing-library/dom": "^10.4.1",
57
+ "@testing-library/react": "^16.3.2",
58
+ "@types/react": "^19.0.0",
59
+ "@types/react-dom": "^19.0.0",
60
+ "jose": "^6.2.12",
61
+ "jsdom": "^29.1.1",
62
+ "next": "^16.2.7",
63
+ "oidc-client-ts": "^3.5.0",
64
+ "openid-client": "^6.8.8",
65
+ "react": "^19.0.0",
66
+ "react-dom": "^19.0.0",
67
+ "rollup-plugin-preserve-directives": "^0.4.0"
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.",
70
+ "size-limit": [
71
+ {
72
+ "name": "root barrel (JS)",
73
+ "path": "dist/index.js",
74
+ "limit": "2.5 kB",
75
+ "ignore": [
76
+ "react",
77
+ "react-dom",
78
+ "react/jsx-runtime"
79
+ ]
80
+ },
81
+ {
82
+ "name": "browser subpath (JS)",
83
+ "path": "dist/browser.js",
84
+ "limit": "2 kB",
85
+ "ignore": [
86
+ "react",
87
+ "react-dom",
88
+ "react/jsx-runtime",
89
+ "oidc-client-ts"
90
+ ]
91
+ },
92
+ {
93
+ "name": "server subpath (JS)",
94
+ "path": "dist/server.js",
95
+ "limit": "3.2 kB",
96
+ "ignore": [
97
+ "react",
98
+ "react-dom",
99
+ "react/jsx-runtime",
100
+ "openid-client",
101
+ "jose"
102
+ ]
103
+ },
104
+ {
105
+ "name": "next subpath (JS)",
106
+ "path": "dist/next.js",
107
+ "limit": "4 kB",
108
+ "ignore": [
109
+ "react",
110
+ "react-dom",
111
+ "react/jsx-runtime",
112
+ "openid-client",
113
+ "jose",
114
+ "next",
115
+ "next/server",
116
+ "next/headers"
117
+ ]
118
+ }
119
+ ],
120
+ "repository": {
121
+ "type": "git",
122
+ "url": "git+https://github.com/Kanzo-Tech/ui.git",
123
+ "directory": "packages/auth"
124
+ },
125
+ "homepage": "https://kanzo-tech.github.io/ui",
126
+ "bugs": {
127
+ "url": "https://github.com/Kanzo-Tech/ui/issues"
128
+ },
129
+ "scripts": {
130
+ "build": "vite build",
131
+ "typecheck": "tsc --noEmit",
132
+ "test": "vitest run",
133
+ "size": "size-limit"
134
+ }
135
+ }