@hearth-auth/sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +680 -0
  3. package/package.json +44 -0
  4. package/src/admin.ts +157 -0
  5. package/src/browser-auth.ts +130 -0
  6. package/src/claims.ts +180 -0
  7. package/src/client.ts +251 -0
  8. package/src/errors.ts +173 -0
  9. package/src/generated/google/api/annotations_pb.ts +44 -0
  10. package/src/generated/google/api/http_pb.ts +467 -0
  11. package/src/generated/hearth/authz/v1/authz_pb.ts +593 -0
  12. package/src/generated/hearth/cluster/v1/raft_pb.ts +183 -0
  13. package/src/generated/hearth/events/v1/audit_pb.ts +886 -0
  14. package/src/generated/hearth/identity/v1/identity_pb.ts +1673 -0
  15. package/src/generated/hearth/identity/v1/oauth_pb.ts +1138 -0
  16. package/src/generated/hearth/rbac/v1/rbac_pb.ts +2000 -0
  17. package/src/hearth-client.ts +288 -0
  18. package/src/hearth.ts +224 -0
  19. package/src/index.ts +106 -0
  20. package/src/introspection-client.ts +83 -0
  21. package/src/jwks-client.ts +45 -0
  22. package/src/middleware.ts +82 -0
  23. package/src/pkce.ts +129 -0
  24. package/src/react.tsx +57 -0
  25. package/src/session-version-cache.ts +167 -0
  26. package/src/types.ts +188 -0
  27. package/tests/admin-crud.test.ts +97 -0
  28. package/tests/auth-flow.test.ts +75 -0
  29. package/tests/authorize.test.ts +386 -0
  30. package/tests/claims.test.ts +159 -0
  31. package/tests/hasPermission.test.ts +152 -0
  32. package/tests/hearth-client.test.ts +243 -0
  33. package/tests/helpers.ts +90 -0
  34. package/tests/jwks.test.ts +62 -0
  35. package/tests/pkce.test.ts +210 -0
  36. package/tests/react-useHasPermission.test.tsx +92 -0
  37. package/tests/required-action.test.ts +276 -0
  38. package/tests/session-version.test.ts +391 -0
  39. package/tsconfig.json +16 -0
  40. package/vitest.config.ts +8 -0
@@ -0,0 +1,391 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createHearth } from "../src/hearth.js";
3
+ import { SessionVersionCache } from "../src/session-version-cache.js";
4
+ import {
5
+ SessionVersionCacheStaleError,
6
+ SessionVersionRevokedError,
7
+ } from "../src/errors.js";
8
+ import type { SessionVersionConfig } from "../src/types.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Flush all outstanding microtasks (promise chains) without advancing fake
16
+ * timers. Five rounds handle promise chains up to depth 5.
17
+ */
18
+ async function flushAsync(): Promise<void> {
19
+ for (let i = 0; i < 5; i++) await Promise.resolve();
20
+ }
21
+
22
+ function forgeJwt(claims: Record<string, unknown>): string {
23
+ const header = Buffer.from(
24
+ JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
25
+ "utf8",
26
+ ).toString("base64url");
27
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
28
+ return `${header}.${body}.fakesig`;
29
+ }
30
+
31
+ const BASE_SV_CONFIG: SessionVersionConfig = {
32
+ enabled: true,
33
+ pollIntervalMs: 5_000,
34
+ staleThresholdMs: 60_000,
35
+ onStale: "reject",
36
+ serviceToken: "svc-token",
37
+ };
38
+
39
+ function mockFetch(body: unknown, status = 200): void {
40
+ // HTTP 204 No Content must have a null body per the Fetch spec.
41
+ const hasBody = status !== 204;
42
+ vi.mocked(fetch).mockResolvedValueOnce(
43
+ new Response(hasBody ? JSON.stringify(body) : null, {
44
+ status,
45
+ headers: hasBody ? { "Content-Type": "application/json" } : {},
46
+ }),
47
+ );
48
+ }
49
+
50
+ function snapshotResponse(versions: Record<string, number>, seq = 10) {
51
+ return { realm: "r1", current_seq: seq, versions };
52
+ }
53
+
54
+ function deltaResponse(deltas: Array<{ session_id: string; min_sv: number }>, nextSeq = 12) {
55
+ return {
56
+ realm: "r1",
57
+ next_seq: nextSeq,
58
+ deltas: deltas.map((d, i) => ({
59
+ seq: 11 + i,
60
+ session_id: d.session_id,
61
+ min_sv: d.min_sv,
62
+ bumped_at: 1700000900,
63
+ })),
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // SessionVersionCache — unit tests (no real timers)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("SessionVersionCache", () => {
72
+ beforeEach(() => {
73
+ vi.stubGlobal("fetch", vi.fn());
74
+ vi.useFakeTimers();
75
+ });
76
+ afterEach(() => {
77
+ vi.unstubAllGlobals();
78
+ vi.useRealTimers();
79
+ });
80
+
81
+ it("fetches snapshot on start() and seeds local versions", async () => {
82
+ mockFetch(snapshotResponse({ sess_01: 1, sess_02: 3 }));
83
+
84
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
85
+ cache.start();
86
+ // Let the async snapshot fetch resolve.
87
+ await flushAsync();
88
+
89
+ // sess_01 at sv=1 should pass (token sv >= minSv).
90
+ expect(() => cache.validateSv(1n, "sess_01")).not.toThrow();
91
+ // sess_02 bumped to min=3; sv=2 should be rejected.
92
+ expect(() => cache.validateSv(2n, "sess_02")).toThrow(SessionVersionRevokedError);
93
+ // sess_02 at sv=3 should pass.
94
+ expect(() => cache.validateSv(3n, "sess_02")).not.toThrow();
95
+ });
96
+
97
+ it("applies delta entries and advances the sequence cursor", async () => {
98
+ // Snapshot seeds sess_01 at min=1.
99
+ mockFetch(snapshotResponse({ sess_01: 1 }));
100
+ // Delta bumps sess_01 to min=4.
101
+ mockFetch(deltaResponse([{ session_id: "sess_01", min_sv: 4 }]));
102
+
103
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
104
+ cache.start();
105
+ await flushAsync();
106
+
107
+ // Advance time past poll interval, let poll fire.
108
+ vi.advanceTimersByTime(BASE_SV_CONFIG.pollIntervalMs + 1);
109
+ await flushAsync();
110
+
111
+ // sv=3 must be rejected now.
112
+ expect(() => cache.validateSv(3n, "sess_01")).toThrow(SessionVersionRevokedError);
113
+ // sv=4 is exactly the new minimum — should pass.
114
+ expect(() => cache.validateSv(4n, "sess_01")).not.toThrow();
115
+
116
+ cache.stop();
117
+ });
118
+
119
+ it("handles HTTP 204 (no deltas) by updating lastRefreshed", async () => {
120
+ mockFetch(snapshotResponse({}));
121
+ mockFetch(null, 204); // no-content on poll
122
+
123
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
124
+ cache.start();
125
+ await flushAsync();
126
+
127
+ vi.advanceTimersByTime(BASE_SV_CONFIG.pollIntervalMs + 1);
128
+ await flushAsync();
129
+
130
+ // Cache was refreshed → age should be small.
131
+ expect(cache.age()).toBeLessThan(1_000);
132
+ cache.stop();
133
+ });
134
+
135
+ it("re-fetches snapshot when poll returns HTTP 400 (sequence too old)", async () => {
136
+ mockFetch(snapshotResponse({ sess_A: 2 })); // initial snapshot
137
+ mockFetch(null, 400); // poll says seq too old
138
+ mockFetch(snapshotResponse({ sess_A: 5 })); // re-snapshot after 400
139
+
140
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
141
+ cache.start();
142
+ await flushAsync();
143
+
144
+ vi.advanceTimersByTime(BASE_SV_CONFIG.pollIntervalMs + 1);
145
+ await flushAsync();
146
+
147
+ // After re-snapshot, sess_A min=5; sv=4 must be rejected.
148
+ expect(() => cache.validateSv(4n, "sess_A")).toThrow(SessionVersionRevokedError);
149
+ expect(() => cache.validateSv(5n, "sess_A")).not.toThrow();
150
+ cache.stop();
151
+ });
152
+
153
+ it("skips validation when sv claim is absent (backward compat, RFC § 8.2)", async () => {
154
+ mockFetch(snapshotResponse({}));
155
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
156
+ cache.start();
157
+ await flushAsync();
158
+
159
+ // No sv → no-op, must not throw.
160
+ expect(() => cache.validateSv(undefined, "sess_X")).not.toThrow();
161
+ expect(() => cache.validateSv(1n, undefined)).not.toThrow();
162
+ cache.stop();
163
+ });
164
+
165
+ it("defaults unknown sessions to minSv=1 (first bump hasn't arrived yet)", async () => {
166
+ mockFetch(snapshotResponse({})); // empty snapshot
167
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
168
+ cache.start();
169
+ await flushAsync();
170
+
171
+ // sv=1 ≥ default minSv=1 → ok.
172
+ expect(() => cache.validateSv(1n, "brand_new_session")).not.toThrow();
173
+ cache.stop();
174
+ });
175
+
176
+ it("throws SessionVersionCacheStaleError when cache age exceeds staleThresholdMs", async () => {
177
+ mockFetch(snapshotResponse({ sess_S: 1 }));
178
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
179
+ cache.start();
180
+ await flushAsync();
181
+
182
+ // Advance time past the stale threshold.
183
+ vi.advanceTimersByTime(BASE_SV_CONFIG.staleThresholdMs + 1_000);
184
+
185
+ const err = (() => {
186
+ try { cache.validateSv(1n, "sess_S"); }
187
+ catch (e) { return e; }
188
+ })();
189
+ expect(err).toBeInstanceOf(SessionVersionCacheStaleError);
190
+ expect((err as SessionVersionCacheStaleError).onStale).toBe("reject");
191
+ cache.stop();
192
+ });
193
+
194
+ it("throws SessionVersionCacheStaleError (never seeded) before first snapshot resolves", () => {
195
+ // fetch never resolves
196
+ vi.mocked(fetch).mockReturnValue(new Promise(() => undefined));
197
+
198
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
199
+ cache.start();
200
+
201
+ // age() returns Infinity when lastRefreshed===0.
202
+ expect(cache.age()).toBe(Number.POSITIVE_INFINITY);
203
+ expect(() => cache.validateSv(1n, "sess_never")).toThrow(SessionVersionCacheStaleError);
204
+ cache.stop();
205
+ });
206
+
207
+ it("warns when staleThresholdMs <= pollIntervalMs", async () => {
208
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
209
+ mockFetch(snapshotResponse({}));
210
+
211
+ const badCfg: SessionVersionConfig = {
212
+ ...BASE_SV_CONFIG,
213
+ pollIntervalMs: 10_000,
214
+ staleThresholdMs: 5_000, // less than poll — should warn
215
+ };
216
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", badCfg);
217
+ cache.start();
218
+ await flushAsync();
219
+
220
+ expect(warnSpy).toHaveBeenCalledWith(
221
+ expect.stringContaining("staleThresholdMs must be > pollIntervalMs"),
222
+ );
223
+ cache.stop();
224
+ warnSpy.mockRestore();
225
+ });
226
+
227
+ it("stop() prevents further polls", async () => {
228
+ mockFetch(snapshotResponse({}));
229
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
230
+ cache.start();
231
+ await flushAsync();
232
+
233
+ cache.stop();
234
+ // Advance well past two poll intervals — no more fetch calls.
235
+ vi.advanceTimersByTime(BASE_SV_CONFIG.pollIntervalMs * 3);
236
+ await flushAsync();
237
+
238
+ // fetch was called exactly once (snapshot).
239
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("age() returns Infinity before first successful refresh", () => {
243
+ vi.mocked(fetch).mockReturnValue(new Promise(() => undefined));
244
+ const cache = new SessionVersionCache("https://hearth.example.com", "r1", BASE_SV_CONFIG);
245
+ cache.start();
246
+ expect(cache.age()).toBe(Number.POSITIVE_INFINITY);
247
+ cache.stop();
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // createHearth() integration — sv check wired into hasPermission etc.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe("createHearth() with sessionVersions", () => {
256
+ beforeEach(() => {
257
+ vi.stubGlobal("fetch", vi.fn());
258
+ vi.useFakeTimers();
259
+ });
260
+ afterEach(() => {
261
+ vi.unstubAllGlobals();
262
+ vi.useRealTimers();
263
+ });
264
+
265
+ it("hasPermission passes when sv is valid", async () => {
266
+ mockFetch(snapshotResponse({ sess_01: 1 }));
267
+
268
+ let token = forgeJwt({ permissions: ["docs.read"], sv: 1, sid: "sess_01" });
269
+ const hearth = createHearth({
270
+ baseUrl: "https://hearth.example.com",
271
+ realmId: "r1",
272
+ getToken: () => token,
273
+ sessionVersions: BASE_SV_CONFIG,
274
+ });
275
+ await flushAsync();
276
+
277
+ expect(hearth.hasPermission("docs.read")).toBe(true);
278
+ hearth.stop();
279
+ });
280
+
281
+ it("hasPermission throws SessionVersionRevokedError when sv < minSv", async () => {
282
+ mockFetch(snapshotResponse({ sess_01: 5 })); // min is 5
283
+
284
+ const token = forgeJwt({ permissions: ["docs.read"], sv: 3, sid: "sess_01" });
285
+ const hearth = createHearth({
286
+ baseUrl: "https://hearth.example.com",
287
+ realmId: "r1",
288
+ getToken: () => token,
289
+ sessionVersions: BASE_SV_CONFIG,
290
+ });
291
+ await flushAsync();
292
+
293
+ expect(() => hearth.hasPermission("docs.read")).toThrow(SessionVersionRevokedError);
294
+ hearth.stop();
295
+ });
296
+
297
+ it("hasPermission passes when token has no sv claim (backward compat)", async () => {
298
+ mockFetch(snapshotResponse({ sess_01: 5 }));
299
+
300
+ const token = forgeJwt({ permissions: ["docs.read"] }); // no sv
301
+ const hearth = createHearth({
302
+ baseUrl: "https://hearth.example.com",
303
+ realmId: "r1",
304
+ getToken: () => token,
305
+ sessionVersions: BASE_SV_CONFIG,
306
+ });
307
+ await flushAsync();
308
+
309
+ expect(hearth.hasPermission("docs.read")).toBe(true);
310
+ hearth.stop();
311
+ });
312
+
313
+ it("hasPermission throws SessionVersionCacheStaleError when cache is stale", async () => {
314
+ mockFetch(snapshotResponse({ sess_01: 1 }));
315
+
316
+ const token = forgeJwt({ permissions: ["docs.read"], sv: 1, sid: "sess_01" });
317
+ const hearth = createHearth({
318
+ baseUrl: "https://hearth.example.com",
319
+ realmId: "r1",
320
+ getToken: () => token,
321
+ sessionVersions: BASE_SV_CONFIG,
322
+ });
323
+ await flushAsync();
324
+
325
+ vi.advanceTimersByTime(BASE_SV_CONFIG.staleThresholdMs + 1_000);
326
+
327
+ expect(() => hearth.hasPermission("docs.read")).toThrow(SessionVersionCacheStaleError);
328
+ hearth.stop();
329
+ });
330
+
331
+ it("hasRole, inGroup, inOrg also validate sv", async () => {
332
+ mockFetch(snapshotResponse({ sess_01: 3 }));
333
+
334
+ const token = forgeJwt({
335
+ roles: ["admin"],
336
+ groups: ["eng"],
337
+ oid: "org_1",
338
+ sv: 2, // below minSv=3
339
+ sid: "sess_01",
340
+ });
341
+ const hearth = createHearth({
342
+ baseUrl: "https://hearth.example.com",
343
+ realmId: "r1",
344
+ getToken: () => token,
345
+ sessionVersions: BASE_SV_CONFIG,
346
+ });
347
+ await flushAsync();
348
+
349
+ expect(() => hearth.hasRole("admin")).toThrow(SessionVersionRevokedError);
350
+ expect(() => hearth.inGroup("eng")).toThrow(SessionVersionRevokedError);
351
+ expect(() => hearth.inOrg("org_1")).toThrow(SessionVersionRevokedError);
352
+ hearth.stop();
353
+ });
354
+
355
+ it("sessionVersionCacheAge() returns Infinity when sessionVersions not configured", () => {
356
+ const hearth = createHearth({
357
+ baseUrl: "https://hearth.example.com",
358
+ realmId: "r1",
359
+ getToken: () => null,
360
+ });
361
+ expect(hearth.sessionVersionCacheAge()).toBe(Number.POSITIVE_INFINITY);
362
+ hearth.stop();
363
+ });
364
+
365
+ it("sessionVersionCacheAge() returns a finite value after snapshot is loaded", async () => {
366
+ mockFetch(snapshotResponse({}));
367
+ const hearth = createHearth({
368
+ baseUrl: "https://hearth.example.com",
369
+ realmId: "r1",
370
+ getToken: () => null,
371
+ sessionVersions: BASE_SV_CONFIG,
372
+ });
373
+ await flushAsync();
374
+
375
+ expect(hearth.sessionVersionCacheAge()).toBeLessThan(1_000);
376
+ hearth.stop();
377
+ });
378
+
379
+ it("no sv check when sessionVersions.enabled is false", () => {
380
+ // No fetch mock — any network call would throw.
381
+ const token = forgeJwt({ permissions: ["docs.read"], sv: 99, sid: "sess_X" });
382
+ const hearth = createHearth({
383
+ baseUrl: "https://hearth.example.com",
384
+ realmId: "r1",
385
+ getToken: () => token,
386
+ sessionVersions: { ...BASE_SV_CONFIG, enabled: false },
387
+ });
388
+ expect(hearth.hasPermission("docs.read")).toBe(true);
389
+ hearth.stop();
390
+ });
391
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "jsx": "react-jsx"
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["src/generated", "node_modules", "dist"]
16
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ testTimeout: 30_000,
6
+ hookTimeout: 60_000,
7
+ },
8
+ });