@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.
- package/CHANGELOG.md +12 -0
- package/README.md +680 -0
- package/package.json +44 -0
- package/src/admin.ts +157 -0
- package/src/browser-auth.ts +130 -0
- package/src/claims.ts +180 -0
- package/src/client.ts +251 -0
- package/src/errors.ts +173 -0
- package/src/generated/google/api/annotations_pb.ts +44 -0
- package/src/generated/google/api/http_pb.ts +467 -0
- package/src/generated/hearth/authz/v1/authz_pb.ts +593 -0
- package/src/generated/hearth/cluster/v1/raft_pb.ts +183 -0
- package/src/generated/hearth/events/v1/audit_pb.ts +886 -0
- package/src/generated/hearth/identity/v1/identity_pb.ts +1673 -0
- package/src/generated/hearth/identity/v1/oauth_pb.ts +1138 -0
- package/src/generated/hearth/rbac/v1/rbac_pb.ts +2000 -0
- package/src/hearth-client.ts +288 -0
- package/src/hearth.ts +224 -0
- package/src/index.ts +106 -0
- package/src/introspection-client.ts +83 -0
- package/src/jwks-client.ts +45 -0
- package/src/middleware.ts +82 -0
- package/src/pkce.ts +129 -0
- package/src/react.tsx +57 -0
- package/src/session-version-cache.ts +167 -0
- package/src/types.ts +188 -0
- package/tests/admin-crud.test.ts +97 -0
- package/tests/auth-flow.test.ts +75 -0
- package/tests/authorize.test.ts +386 -0
- package/tests/claims.test.ts +159 -0
- package/tests/hasPermission.test.ts +152 -0
- package/tests/hearth-client.test.ts +243 -0
- package/tests/helpers.ts +90 -0
- package/tests/jwks.test.ts +62 -0
- package/tests/pkce.test.ts +210 -0
- package/tests/react-useHasPermission.test.tsx +92 -0
- package/tests/required-action.test.ts +276 -0
- package/tests/session-version.test.ts +391 -0
- package/tsconfig.json +16 -0
- 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
|
+
});
|