@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,97 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { ensureBinary, startServer, stopServer, type TestServer } from "./helpers.js";
3
+
4
+ describe("TypeScript SDK: Admin CRUD", () => {
5
+ let server: TestServer;
6
+
7
+ beforeAll(async () => {
8
+ ensureBinary();
9
+ server = await startServer();
10
+ });
11
+
12
+ afterAll(() => {
13
+ if (server) stopServer(server);
14
+ });
15
+
16
+ it("performs full CRUD on users and realms via the admin API", async () => {
17
+ const admin = server.client.admin(server.bootstrap.access_token);
18
+
19
+ // === User CRUD ===
20
+
21
+ // Create
22
+ const user = await admin.createUser({
23
+ email: "crud-test@test.local",
24
+ displayName: "CRUD Test User",
25
+ });
26
+ expect(user.id).toBeTruthy();
27
+ expect(user.email).toBe("crud-test@test.local");
28
+ expect(user.display_name).toBe("CRUD Test User");
29
+ expect(user.status).toBe("USER_STATUS_ACTIVE");
30
+
31
+ // Read
32
+ const fetched = await admin.getUser(user.id);
33
+ expect(fetched.id).toBe(user.id);
34
+ expect(fetched.email).toBe("crud-test@test.local");
35
+
36
+ // Update
37
+ const updated = await admin.updateUser(user.id, {
38
+ displayName: "Updated Name",
39
+ });
40
+ expect(updated.display_name).toBe("Updated Name");
41
+ expect(updated.email).toBe("crud-test@test.local");
42
+
43
+ // List
44
+ const page = await admin.listUsers({ limit: 10 });
45
+ expect(page.items.length).toBeGreaterThanOrEqual(1);
46
+ const found = page.items.find((u) => u.id === user.id);
47
+ expect(found).toBeTruthy();
48
+ expect(found!.display_name).toBe("Updated Name");
49
+
50
+ // Delete
51
+ await admin.deleteUser(user.id);
52
+
53
+ // Verify deleted — should 404
54
+ try {
55
+ await admin.getUser(user.id);
56
+ expect.fail("should have thrown");
57
+ } catch (e: unknown) {
58
+ expect((e as { status: number }).status).toBe(404);
59
+ }
60
+
61
+ // === Realm CRUD ===
62
+
63
+ // Create
64
+ const realm = await admin.createRealm({
65
+ name: "test-realm-crud",
66
+ });
67
+ expect(realm.id).toBeTruthy();
68
+ expect(realm.name).toBe("test-realm-crud");
69
+ expect(realm.status).toBe("REALM_STATUS_ACTIVE");
70
+
71
+ // Read
72
+ const fetchedRealm = await admin.getRealm(realm.id);
73
+ expect(fetchedRealm.id).toBe(realm.id);
74
+ expect(fetchedRealm.name).toBe("test-realm-crud");
75
+
76
+ // Update
77
+ const updatedRealm = await admin.updateRealm(realm.id, {
78
+ name: "updated-realm-name",
79
+ });
80
+ expect(updatedRealm.name).toBe("updated-realm-name");
81
+
82
+ // List
83
+ const realmPage = await admin.listRealms({ limit: 10 });
84
+ expect(realmPage.items.length).toBeGreaterThanOrEqual(1);
85
+
86
+ // Delete
87
+ await admin.deleteRealm(realm.id);
88
+
89
+ // Verify deleted — should 404
90
+ try {
91
+ await admin.getRealm(realm.id);
92
+ expect.fail("should have thrown");
93
+ } catch (e: unknown) {
94
+ expect((e as { status: number }).status).toBe(404);
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { ensureBinary, startServer, stopServer, type TestServer } from "./helpers.js";
3
+
4
+ describe("TypeScript SDK: Auth Code Flow", () => {
5
+ let server: TestServer;
6
+
7
+ beforeAll(async () => {
8
+ ensureBinary();
9
+ server = await startServer();
10
+ });
11
+
12
+ afterAll(() => {
13
+ if (server) stopServer(server);
14
+ });
15
+
16
+ it("completes a full auth code flow: authorize → exchange → userinfo → refresh", async () => {
17
+ const { client, bootstrap } = server;
18
+
19
+ // 1. Register an OAuth client
20
+ const oauthClient = await client.registerClient({
21
+ clientName: "test-app",
22
+ redirectUris: ["http://localhost:3000/callback"],
23
+ });
24
+ expect(oauthClient.client_id).toBeTruthy();
25
+
26
+ // 2. Create a user for the flow
27
+ const admin = client.admin(bootstrap.access_token);
28
+ const user = await admin.createUser({
29
+ email: "alice@test.local",
30
+ displayName: "Alice Test",
31
+ });
32
+ expect(user.id).toBeTruthy();
33
+
34
+ // 3. Authorize — get an auth code
35
+ const authResp = await client.authorize({
36
+ clientId: oauthClient.client_id,
37
+ redirectUri: "http://localhost:3000/callback",
38
+ scope: "openid profile email",
39
+ state: "test-state-123",
40
+ userId: user.id,
41
+ });
42
+ expect(authResp.code).toBeTruthy();
43
+ expect(authResp.state).toBe("test-state-123");
44
+
45
+ // 4. Exchange code for tokens
46
+ const tokens = await client.exchangeCode({
47
+ clientId: oauthClient.client_id,
48
+ code: authResp.code,
49
+ redirectUri: "http://localhost:3000/callback",
50
+ });
51
+ expect(tokens.access_token).toBeTruthy();
52
+ expect(tokens.id_token).toBeTruthy();
53
+ expect(tokens.refresh_token).toBeTruthy();
54
+ expect(tokens.token_type).toBe("Bearer");
55
+ expect(tokens.expires_in).toBeGreaterThan(0);
56
+
57
+ // 5. Validate — call userinfo with the access token
58
+ const userinfo = await client.userinfo(tokens.access_token);
59
+ expect(userinfo.sub).toContain(user.id);
60
+
61
+ // 6. Refresh — exchange the refresh token for new tokens
62
+ const refreshed = await client.refreshTokens(
63
+ oauthClient.client_id,
64
+ tokens.refresh_token,
65
+ );
66
+ expect(refreshed.access_token).toBeTruthy();
67
+ expect(refreshed.refresh_token).toBeTruthy();
68
+ // New access token should be different from original
69
+ expect(refreshed.access_token).not.toBe(tokens.access_token);
70
+
71
+ // 7. Verify the new access token works
72
+ const userinfo2 = await client.userinfo(refreshed.access_token);
73
+ expect(userinfo2.sub).toContain(user.id);
74
+ });
75
+ });
@@ -0,0 +1,386 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { HearthClient } from "../src/hearth-client.js";
3
+ import {
4
+ ConfigurationError,
5
+ AuthorizationModeMismatchError,
6
+ } from "../src/errors.js";
7
+ import { requirePermission } from "../src/middleware.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function forgeJwt(claims: Record<string, unknown>): string {
14
+ const header = Buffer.from(
15
+ JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
16
+ "utf8",
17
+ ).toString("base64url");
18
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
19
+ const sig = Buffer.from("not-a-real-signature").toString("base64url");
20
+ return `${header}.${body}.${sig}`;
21
+ }
22
+
23
+ const DISCOVERY_DOC = {
24
+ issuer: "https://auth.example.com",
25
+ jwks_uri: "https://auth.example.com/jwks",
26
+ introspection_endpoint: "https://auth.example.com/introspect",
27
+ };
28
+
29
+ function mockFetch(body: unknown, status = 200): void {
30
+ vi.mocked(fetch).mockResolvedValueOnce(
31
+ new Response(JSON.stringify(body), {
32
+ status,
33
+ headers: { "Content-Type": "application/json" },
34
+ }),
35
+ );
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // HearthClient.authorize()
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe("HearthClient.authorize()", () => {
43
+ beforeEach(() => {
44
+ vi.stubGlobal("fetch", vi.fn());
45
+ });
46
+ afterEach(() => {
47
+ vi.unstubAllGlobals();
48
+ });
49
+
50
+ it("throws ConfigurationError when realmId is absent", async () => {
51
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
52
+ await expect(
53
+ client.authorize("tok", "docs.read"),
54
+ ).rejects.toThrow(ConfigurationError);
55
+ });
56
+
57
+ it("calls POST /oauth/authorize with correct headers and body", async () => {
58
+ mockFetch({ allowed: true });
59
+ const client = new HearthClient({
60
+ issuerUrl: "https://auth.example.com",
61
+ realmId: "realm_1",
62
+ });
63
+ await client.authorize("my-token", "docs.read");
64
+ expect(vi.mocked(fetch)).toHaveBeenCalledWith(
65
+ "https://auth.example.com/oauth/authorize",
66
+ expect.objectContaining({
67
+ method: "POST",
68
+ headers: expect.objectContaining({
69
+ "Content-Type": "application/json",
70
+ "X-Realm-ID": "realm_1",
71
+ Authorization: "Bearer my-token",
72
+ }),
73
+ }),
74
+ );
75
+ });
76
+
77
+ it("returns true when the server returns allowed: true", async () => {
78
+ mockFetch({ allowed: true });
79
+ const client = new HearthClient({
80
+ issuerUrl: "https://auth.example.com",
81
+ realmId: "realm_1",
82
+ });
83
+ expect(await client.authorize("tok", "docs.read")).toBe(true);
84
+ });
85
+
86
+ it("returns false when the server returns allowed: false", async () => {
87
+ mockFetch({ allowed: false });
88
+ const client = new HearthClient({
89
+ issuerUrl: "https://auth.example.com",
90
+ realmId: "realm_1",
91
+ });
92
+ expect(await client.authorize("tok", "docs.read")).toBe(false);
93
+ });
94
+
95
+ it("is fail-closed: returns false on HTTP 500", async () => {
96
+ mockFetch({ error: "internal" }, 500);
97
+ const client = new HearthClient({
98
+ issuerUrl: "https://auth.example.com",
99
+ realmId: "realm_1",
100
+ });
101
+ expect(await client.authorize("tok", "docs.read")).toBe(false);
102
+ });
103
+
104
+ it("is fail-closed: returns false on network error", async () => {
105
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("ECONNREFUSED"));
106
+ const client = new HearthClient({
107
+ issuerUrl: "https://auth.example.com",
108
+ realmId: "realm_1",
109
+ });
110
+ expect(await client.authorize("tok", "docs.read")).toBe(false);
111
+ });
112
+
113
+ it("passes organizationId and resource in the request body", async () => {
114
+ mockFetch({ allowed: true });
115
+ const client = new HearthClient({
116
+ issuerUrl: "https://auth.example.com",
117
+ realmId: "realm_1",
118
+ });
119
+ await client.authorize("tok", "docs.write", {
120
+ organizationId: "org_42",
121
+ resource: "doc:abc",
122
+ });
123
+ const call = vi.mocked(fetch).mock.calls[0];
124
+ const body = JSON.parse(call[1]?.body as string);
125
+ expect(body).toMatchObject({
126
+ permission: "docs.write",
127
+ organization_id: "org_42",
128
+ resource: "doc:abc",
129
+ });
130
+ });
131
+
132
+ it("omits organizationId/resource when not provided", async () => {
133
+ mockFetch({ allowed: true });
134
+ const client = new HearthClient({
135
+ issuerUrl: "https://auth.example.com",
136
+ realmId: "realm_1",
137
+ });
138
+ await client.authorize("tok", "docs.read");
139
+ const call = vi.mocked(fetch).mock.calls[0];
140
+ const body = JSON.parse(call[1]?.body as string);
141
+ expect(body).not.toHaveProperty("organization_id");
142
+ expect(body).not.toHaveProperty("resource");
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // HearthClient.introspect() — mode echo validation
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe("HearthClient.introspect()", () => {
151
+ beforeEach(() => {
152
+ vi.stubGlobal("fetch", vi.fn());
153
+ });
154
+ afterEach(() => {
155
+ vi.unstubAllGlobals();
156
+ });
157
+
158
+ it("returns the introspection result when no expectedMode is configured", async () => {
159
+ // discovery + introspect
160
+ mockFetch(DISCOVERY_DOC);
161
+ mockFetch({ active: true, sub: "user_1", mode: "embedded" });
162
+ const client = new HearthClient({
163
+ issuerUrl: "https://auth.example.com",
164
+ clientId: "cid",
165
+ clientSecret: "csec",
166
+ });
167
+ const result = await client.introspect("tok");
168
+ expect(result.active).toBe(true);
169
+ expect(result.sub).toBe("user_1");
170
+ });
171
+
172
+ it("returns the result when mode matches expectedMode", async () => {
173
+ mockFetch(DISCOVERY_DOC);
174
+ mockFetch({ active: true, mode: "introspection", permissions: ["x.read"] });
175
+ const client = new HearthClient({
176
+ issuerUrl: "https://auth.example.com",
177
+ clientId: "cid",
178
+ clientSecret: "csec",
179
+ expectedMode: "introspection",
180
+ });
181
+ const result = await client.introspect("tok");
182
+ expect(result.active).toBe(true);
183
+ });
184
+
185
+ it("throws AuthorizationModeMismatchError when echoed mode differs from expectedMode", async () => {
186
+ mockFetch(DISCOVERY_DOC);
187
+ mockFetch({ active: true, mode: "embedded" });
188
+ const client = new HearthClient({
189
+ issuerUrl: "https://auth.example.com",
190
+ clientId: "cid",
191
+ clientSecret: "csec",
192
+ expectedMode: "introspection",
193
+ });
194
+ await expect(client.introspect("tok")).rejects.toThrow(
195
+ AuthorizationModeMismatchError,
196
+ );
197
+ });
198
+
199
+ it("skips mode validation when the response has no mode field", async () => {
200
+ mockFetch(DISCOVERY_DOC);
201
+ mockFetch({ active: true, sub: "user_1" });
202
+ const client = new HearthClient({
203
+ issuerUrl: "https://auth.example.com",
204
+ clientId: "cid",
205
+ clientSecret: "csec",
206
+ expectedMode: "introspection",
207
+ });
208
+ const result = await client.introspect("tok");
209
+ expect(result.active).toBe(true);
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // requirePermission() — embedded mode
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("requirePermission() — embedded mode", () => {
218
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
219
+
220
+ it("returns true when JWT permissions claim contains the permission", async () => {
221
+ const token = forgeJwt({ permissions: ["docs.read", "docs.write"] });
222
+ const check = requirePermission("docs.read", { mode: "embedded", client });
223
+ expect(await check(token)).toBe(true);
224
+ });
225
+
226
+ it("returns false when permission is absent from claims", async () => {
227
+ const token = forgeJwt({ permissions: ["docs.write"] });
228
+ const check = requirePermission("docs.read", { mode: "embedded", client });
229
+ expect(await check(token)).toBe(false);
230
+ });
231
+
232
+ it("returns false when the permissions claim is missing entirely", async () => {
233
+ const token = forgeJwt({ sub: "user_1" });
234
+ const check = requirePermission("docs.read", { mode: "embedded", client });
235
+ expect(await check(token)).toBe(false);
236
+ });
237
+
238
+ it("does NOT fall back to network when permissions claim is absent (design constraint)", async () => {
239
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
240
+ const token = forgeJwt({ sub: "user_1" }); // no permissions claim
241
+ const check = requirePermission("docs.read", { mode: "embedded", client });
242
+ await check(token);
243
+ expect(fetchSpy).not.toHaveBeenCalled();
244
+ fetchSpy.mockRestore();
245
+ });
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // requirePermission() — decision mode
250
+ // ---------------------------------------------------------------------------
251
+
252
+ describe("requirePermission() — decision mode", () => {
253
+ beforeEach(() => {
254
+ vi.stubGlobal("fetch", vi.fn());
255
+ });
256
+ afterEach(() => {
257
+ vi.unstubAllGlobals();
258
+ });
259
+
260
+ it("calls /oauth/authorize and returns true when allowed", async () => {
261
+ mockFetch({ allowed: true });
262
+ const client = new HearthClient({
263
+ issuerUrl: "https://auth.example.com",
264
+ realmId: "realm_1",
265
+ });
266
+ const check = requirePermission("docs.write", { mode: "decision", client });
267
+ expect(await check("my-token")).toBe(true);
268
+ expect(vi.mocked(fetch)).toHaveBeenCalledWith(
269
+ "https://auth.example.com/oauth/authorize",
270
+ expect.anything(),
271
+ );
272
+ });
273
+
274
+ it("returns false when /oauth/authorize returns allowed: false", async () => {
275
+ mockFetch({ allowed: false });
276
+ const client = new HearthClient({
277
+ issuerUrl: "https://auth.example.com",
278
+ realmId: "realm_1",
279
+ });
280
+ const check = requirePermission("docs.write", { mode: "decision", client });
281
+ expect(await check("tok")).toBe(false);
282
+ });
283
+
284
+ it("is fail-closed on network error", async () => {
285
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("network failure"));
286
+ const client = new HearthClient({
287
+ issuerUrl: "https://auth.example.com",
288
+ realmId: "realm_1",
289
+ });
290
+ const check = requirePermission("docs.write", { mode: "decision", client });
291
+ expect(await check("tok")).toBe(false);
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // requirePermission() — introspection mode
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("requirePermission() — introspection mode", () => {
300
+ beforeEach(() => {
301
+ vi.stubGlobal("fetch", vi.fn());
302
+ });
303
+ afterEach(() => {
304
+ vi.unstubAllGlobals();
305
+ });
306
+
307
+ it("calls /introspect and returns true when permission is in result", async () => {
308
+ mockFetch(DISCOVERY_DOC);
309
+ mockFetch({
310
+ active: true,
311
+ mode: "introspection",
312
+ permissions: ["docs.read"],
313
+ });
314
+ const client = new HearthClient({
315
+ issuerUrl: "https://auth.example.com",
316
+ clientId: "cid",
317
+ clientSecret: "csec",
318
+ });
319
+ const check = requirePermission("docs.read", {
320
+ mode: "introspection",
321
+ client,
322
+ });
323
+ expect(await check("tok")).toBe(true);
324
+ });
325
+
326
+ it("returns false when permission is absent from introspection result", async () => {
327
+ mockFetch(DISCOVERY_DOC);
328
+ mockFetch({ active: true, mode: "introspection", permissions: ["other"] });
329
+ const client = new HearthClient({
330
+ issuerUrl: "https://auth.example.com",
331
+ clientId: "cid",
332
+ clientSecret: "csec",
333
+ });
334
+ const check = requirePermission("docs.read", {
335
+ mode: "introspection",
336
+ client,
337
+ });
338
+ expect(await check("tok")).toBe(false);
339
+ });
340
+
341
+ it("returns false when token is inactive", async () => {
342
+ mockFetch(DISCOVERY_DOC);
343
+ mockFetch({ active: false });
344
+ const client = new HearthClient({
345
+ issuerUrl: "https://auth.example.com",
346
+ clientId: "cid",
347
+ clientSecret: "csec",
348
+ });
349
+ const check = requirePermission("docs.read", {
350
+ mode: "introspection",
351
+ client,
352
+ });
353
+ expect(await check("tok")).toBe(false);
354
+ });
355
+
356
+ it("throws AuthorizationModeMismatchError when server echoes wrong mode", async () => {
357
+ mockFetch(DISCOVERY_DOC);
358
+ // Server returns "embedded" but middleware expects "introspection"
359
+ mockFetch({ active: true, mode: "embedded", permissions: ["docs.read"] });
360
+ const client = new HearthClient({
361
+ issuerUrl: "https://auth.example.com",
362
+ clientId: "cid",
363
+ clientSecret: "csec",
364
+ });
365
+ const check = requirePermission("docs.read", {
366
+ mode: "introspection",
367
+ client,
368
+ });
369
+ await expect(check("tok")).rejects.toThrow(AuthorizationModeMismatchError);
370
+ });
371
+
372
+ it("accepts result when mode field is absent (server may omit it)", async () => {
373
+ mockFetch(DISCOVERY_DOC);
374
+ mockFetch({ active: true, permissions: ["docs.read"] });
375
+ const client = new HearthClient({
376
+ issuerUrl: "https://auth.example.com",
377
+ clientId: "cid",
378
+ clientSecret: "csec",
379
+ });
380
+ const check = requirePermission("docs.read", {
381
+ mode: "introspection",
382
+ client,
383
+ });
384
+ expect(await check("tok")).toBe(true);
385
+ });
386
+ });