@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hearth-auth/browser` and `@hearth-auth/node` are documented here.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- SDK brought into conformance with the [Hearth SDK Common Specification](../../docs/specs/SDK.md).
|
|
9
|
+
- All 9 required error types from spec §5 are now exported.
|
|
10
|
+
- Full Claims API (spec §4) implemented on verified token objects.
|
|
11
|
+
- JWKS caching follows the 5-rule contract from spec §2.
|
|
12
|
+
- README updated with installation, quickstart, and troubleshooting sections (spec §10).
|
package/README.md
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
# Hearth TypeScript SDK
|
|
2
|
+
|
|
3
|
+
TypeScript client for the [Hearth](https://github.com/hearth-auth/hearth) identity API.
|
|
4
|
+
|
|
5
|
+
> **SDK Specification:** This SDK must conform to the [Hearth SDK Common Specification](../../docs/specs/SDK.md).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @hearth-auth/sdk
|
|
11
|
+
# or
|
|
12
|
+
yarn add @hearth-auth/sdk
|
|
13
|
+
# or
|
|
14
|
+
pnpm add @hearth-auth/sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Peer dependencies:** React (`>=17 <20`) is optional. Only required for the `HearthProvider` / `useHasPermission` hooks.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { createHearth, HearthClient } from "@hearth-auth/sdk";
|
|
25
|
+
|
|
26
|
+
// Low-level HTTP client — auth flows, token exchange, admin ops
|
|
27
|
+
const client = new HearthClient({
|
|
28
|
+
baseUrl: "https://hearth.example.com",
|
|
29
|
+
realmId: "<your-realm-id>",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// RBAC facade — local, synchronous permission checks from the JWT
|
|
33
|
+
const hearth = createHearth({
|
|
34
|
+
baseUrl: "https://hearth.example.com",
|
|
35
|
+
realmId: "<your-realm-id>",
|
|
36
|
+
getToken: () => localStorage.getItem("access_token"),
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`HearthClient` is for server-side or client-side HTTP operations (token exchange, admin CRUD, JWKS). `createHearth` gives you a zero-network RBAC facade that reads claims from the JWT in memory.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Auth code flow (with PKCE)
|
|
45
|
+
|
|
46
|
+
PKCE is the secure default for every OAuth authorization code flow — required for public clients, recommended for confidential clients.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { HearthClient } from "@hearth-auth/sdk";
|
|
50
|
+
import { createHash, randomBytes } from "crypto"; // Node.js built-in
|
|
51
|
+
|
|
52
|
+
const client = new HearthClient({
|
|
53
|
+
baseUrl: "https://hearth.example.com",
|
|
54
|
+
realmId: "<your-realm-id>",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 1. Generate PKCE verifier and challenge
|
|
58
|
+
const codeVerifier = randomBytes(32).toString("hex"); // 64 unreserved chars
|
|
59
|
+
const codeChallenge = createHash("sha256")
|
|
60
|
+
.update(codeVerifier)
|
|
61
|
+
.digest("base64url"); // base64url, no padding
|
|
62
|
+
|
|
63
|
+
// 2. Start the authorization request
|
|
64
|
+
const { code } = await client.authorize({
|
|
65
|
+
clientId: "<client-id>",
|
|
66
|
+
redirectUri: "https://app.example.com/callback",
|
|
67
|
+
scope: "openid profile email",
|
|
68
|
+
state: randomBytes(16).toString("hex"), // CSRF token
|
|
69
|
+
userId: "<authenticated-user-uuid>", // resolved user on your backend
|
|
70
|
+
codeChallenge,
|
|
71
|
+
codeChallengeMethod: "S256",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 3. Exchange the code for tokens
|
|
75
|
+
const tokens = await client.exchangeCode({
|
|
76
|
+
clientId: "<client-id>",
|
|
77
|
+
code,
|
|
78
|
+
redirectUri: "https://app.example.com/callback",
|
|
79
|
+
codeVerifier,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// tokens.access_token — short-lived JWT (check tokens.expires_in)
|
|
83
|
+
// tokens.id_token — OIDC identity token
|
|
84
|
+
// tokens.refresh_token — rotate with refreshTokens()
|
|
85
|
+
|
|
86
|
+
// 4. Refresh before expiry
|
|
87
|
+
const refreshed = await client.refreshTokens("<client-id>", tokens.refresh_token);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## RBAC capabilities
|
|
93
|
+
|
|
94
|
+
All synchronous helpers decode the JWT returned by `getToken()` **locally** — no network call, no cache, no lock. When the token is absent or malformed, every predicate returns `false`.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const hearth = createHearth({
|
|
98
|
+
baseUrl: "https://hearth.example.com",
|
|
99
|
+
realmId: "<your-realm-id>",
|
|
100
|
+
getToken: () => sessionStorage.getItem("access_token"),
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `hasPermission(permission: string): boolean`
|
|
105
|
+
|
|
106
|
+
Returns `true` iff the JWT `permissions` claim contains `permission`. Use this for feature gates and API guards.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
if (hearth.hasPermission("docs.versions.read")) {
|
|
110
|
+
renderVersionHistory();
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `hasRole(role: string): boolean`
|
|
115
|
+
|
|
116
|
+
Returns `true` iff the JWT `roles` claim contains `role`. Useful for UI personalization and coarse-grained access.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
if (hearth.hasRole("billing-admin")) {
|
|
120
|
+
renderBillingPanel();
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `inGroup(group: string): boolean`
|
|
125
|
+
|
|
126
|
+
Returns `true` iff the JWT `groups` claim contains the group slug.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
if (hearth.inGroup("engineering")) {
|
|
130
|
+
renderInternalToolingLink();
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `inOrg(org: string): boolean`
|
|
135
|
+
|
|
136
|
+
Returns `true` iff the JWT `oid` claim equals the given org ID.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
if (hearth.inOrg("org_acme")) {
|
|
140
|
+
renderAcmeContent();
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `client.permissions(): Promise<MePermissionsResponse>`
|
|
145
|
+
|
|
146
|
+
Calls `GET /v1/me/permissions` and returns the **freshly-resolved** RBAC claim set from the server. Unlike the synchronous helpers above, this reflects any role/group assignments made since the JWT was issued.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
const { roles, groups, permissions } = await hearth.client.permissions();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Use `client.permissions()` when you need post-issuance accuracy (e.g., after an admin operation). For every other check, prefer the synchronous local helpers — they're faster and don't touch the network.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## React integration
|
|
157
|
+
|
|
158
|
+
The React hooks are exported from the main `@hearth-auth/sdk` package. No subpath import needed.
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import {
|
|
162
|
+
createHearth,
|
|
163
|
+
HearthProvider,
|
|
164
|
+
useHasPermission,
|
|
165
|
+
useHasRole,
|
|
166
|
+
useInGroup,
|
|
167
|
+
useInOrg,
|
|
168
|
+
} from "@hearth-auth/sdk";
|
|
169
|
+
|
|
170
|
+
// 1. Create the facade once at app startup
|
|
171
|
+
const hearth = createHearth({
|
|
172
|
+
baseUrl: "https://hearth.example.com",
|
|
173
|
+
realmId: "<your-realm-id>",
|
|
174
|
+
getToken: () => localStorage.getItem("access_token"),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 2. Mount the provider at the root of your React tree
|
|
178
|
+
function App() {
|
|
179
|
+
return (
|
|
180
|
+
<HearthProvider client={hearth}>
|
|
181
|
+
<Router />
|
|
182
|
+
</HearthProvider>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Use hooks anywhere in the tree — no prop drilling
|
|
187
|
+
function NavBar() {
|
|
188
|
+
const canEdit = useHasPermission("docs.write");
|
|
189
|
+
const isAdmin = useHasRole("admin");
|
|
190
|
+
const inEng = useInGroup("engineering");
|
|
191
|
+
const isAcme = useInOrg("org_acme");
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<nav>
|
|
195
|
+
{canEdit && <a href="/editor">Editor</a>}
|
|
196
|
+
{isAdmin && <a href="/admin">Admin</a>}
|
|
197
|
+
{inEng && <a href="/internal">Internal tools</a>}
|
|
198
|
+
{isAcme && <a href="/acme">Acme portal</a>}
|
|
199
|
+
</nav>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
All hooks return `false` when no `HearthProvider` is mounted, making them safe to call in tests without a provider.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## UserInfo endpoint
|
|
209
|
+
|
|
210
|
+
Returns OIDC claims filtered by the granted scopes. `sub` is always present; `name` requires `profile` scope; `email` and `email_verified` require `email` scope.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
const info = await client.userinfo(accessToken);
|
|
214
|
+
// info.sub — stable user identifier
|
|
215
|
+
// info.name — display name (if profile scope granted)
|
|
216
|
+
// info.email — email address (if email scope granted)
|
|
217
|
+
// info.email_verified — boolean (if email scope granted)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## JWKS and discovery
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// Retrieve the realm's public signing keys (for local JWT verification)
|
|
226
|
+
const jwks = await client.jwks();
|
|
227
|
+
// jwks.keys — array of JWK entries (kty, crv, x, kid, use, alg)
|
|
228
|
+
|
|
229
|
+
// Retrieve the OIDC discovery document
|
|
230
|
+
const discovery = await client.discovery();
|
|
231
|
+
// Standard OIDC Core 1.0 metadata
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Use the JWKS with a library like `jose` to verify access tokens on your backend:
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
238
|
+
|
|
239
|
+
const JWKS = createRemoteJWKSet(
|
|
240
|
+
new URL("https://hearth.example.com/jwks"),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const { payload } = await jwtVerify(accessToken, JWKS, {
|
|
244
|
+
issuer: "https://hearth.example.com",
|
|
245
|
+
audience: "<client-id>",
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Admin API
|
|
252
|
+
|
|
253
|
+
`AdminClient` wraps the `/admin/*` endpoints. Obtain one from any `HearthClient` instance using a bearer token that carries the `hearth.admin` permission.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const admin = client.admin(accessToken);
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Users
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// Create a user
|
|
263
|
+
const user = await admin.createUser({
|
|
264
|
+
email: "alice@example.com",
|
|
265
|
+
displayName: "Alice",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// List users (paginated)
|
|
269
|
+
const page = await admin.listUsers({ limit: 50 });
|
|
270
|
+
// page.items: User[], page.next_cursor: string | null
|
|
271
|
+
|
|
272
|
+
// Get a user by ID
|
|
273
|
+
const user = await admin.getUser("<user-id>");
|
|
274
|
+
|
|
275
|
+
// Update a user
|
|
276
|
+
const updated = await admin.updateUser("<user-id>", {
|
|
277
|
+
displayName: "Alice Smith",
|
|
278
|
+
status: "active",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Delete a user
|
|
282
|
+
await admin.deleteUser("<user-id>");
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Realms
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// Create a realm
|
|
289
|
+
const realm = await admin.createRealm({ name: "acme-corp" });
|
|
290
|
+
|
|
291
|
+
// List realms (paginated)
|
|
292
|
+
const page = await admin.listRealms({ limit: 20 });
|
|
293
|
+
// page.items: Realm[], page.next_cursor: string | null
|
|
294
|
+
|
|
295
|
+
// Get a realm by ID
|
|
296
|
+
const realm = await admin.getRealm("<realm-id>");
|
|
297
|
+
|
|
298
|
+
// Update a realm
|
|
299
|
+
const updated = await admin.updateRealm("<realm-id>", {
|
|
300
|
+
status: "suspended",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Delete a realm (cascades users, sessions, clients, assignments)
|
|
304
|
+
await admin.deleteRealm("<realm-id>");
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Error handling
|
|
310
|
+
|
|
311
|
+
All methods throw `HearthError` on non-2xx responses.
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
import { HearthClient, HearthError } from "@hearth-auth/sdk";
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const tokens = await client.exchangeCode({ ... });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err instanceof HearthError) {
|
|
320
|
+
console.error(`HTTP ${err.status}:`, err.body);
|
|
321
|
+
} else {
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`HearthError.status` is the HTTP status code. `HearthError.body` is the parsed JSON response body (or the raw string if parsing fails).
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Dev bootstrap (development only)
|
|
332
|
+
|
|
333
|
+
The bootstrap endpoint creates a realm, admin user, session, assigns the `realm.admin` role, and returns tokens. It is available only when Hearth is running with `--dev`. In production, it returns 404.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
import { HearthClient } from "@hearth-auth/sdk";
|
|
337
|
+
|
|
338
|
+
const { realm_id, user_id, access_token, refresh_token } =
|
|
339
|
+
await HearthClient.bootstrap("http://127.0.0.1:8420");
|
|
340
|
+
|
|
341
|
+
// Use realm_id and access_token to make subsequent requests
|
|
342
|
+
const client = new HearthClient({
|
|
343
|
+
baseUrl: "http://127.0.0.1:8420",
|
|
344
|
+
realmId: realm_id,
|
|
345
|
+
});
|
|
346
|
+
const admin = client.admin(access_token);
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Type reference
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// HearthClientConfig — constructor argument for HearthClient
|
|
355
|
+
interface HearthClientConfig {
|
|
356
|
+
baseUrl: string; // Hearth server base URL, e.g. "https://hearth.example.com"
|
|
357
|
+
realmId: string; // Realm UUID to scope all requests to
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// HearthOptions — argument to createHearth()
|
|
361
|
+
interface HearthOptions {
|
|
362
|
+
baseUrl: string;
|
|
363
|
+
realmId: string;
|
|
364
|
+
getToken: () => string | null | undefined; // called on every predicate check
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// HearthFacade — returned by createHearth()
|
|
368
|
+
interface HearthFacade {
|
|
369
|
+
hasPermission(permission: string): boolean;
|
|
370
|
+
hasRole(role: string): boolean;
|
|
371
|
+
inGroup(group: string): boolean;
|
|
372
|
+
inOrg(org: string): boolean;
|
|
373
|
+
client: { permissions(): Promise<MePermissionsResponse> };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// AuthorizeParams
|
|
377
|
+
interface AuthorizeParams {
|
|
378
|
+
clientId: string;
|
|
379
|
+
redirectUri: string;
|
|
380
|
+
scope: string;
|
|
381
|
+
state: string;
|
|
382
|
+
userId: string;
|
|
383
|
+
responseType?: string; // default: "code"
|
|
384
|
+
codeChallenge?: string; // S256 challenge; required for PKCE
|
|
385
|
+
codeChallengeMethod?: string; // "S256"
|
|
386
|
+
nonce?: string; // echoed in the ID token
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// TokenExchangeParams
|
|
390
|
+
interface TokenExchangeParams {
|
|
391
|
+
clientId: string;
|
|
392
|
+
code: string;
|
|
393
|
+
redirectUri: string;
|
|
394
|
+
codeVerifier?: string; // required when codeChallenge was sent on authorize
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// TokenResponse
|
|
398
|
+
interface TokenResponse {
|
|
399
|
+
access_token: string;
|
|
400
|
+
id_token: string;
|
|
401
|
+
token_type: string; // "Bearer"
|
|
402
|
+
expires_in: number; // seconds
|
|
403
|
+
refresh_token: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// UserInfoResponse
|
|
407
|
+
interface UserInfoResponse {
|
|
408
|
+
sub: string;
|
|
409
|
+
name?: string;
|
|
410
|
+
email?: string;
|
|
411
|
+
email_verified?: boolean;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// MePermissionsResponse — from GET /v1/me/permissions
|
|
415
|
+
interface MePermissionsResponse {
|
|
416
|
+
roles: string[];
|
|
417
|
+
groups: string[];
|
|
418
|
+
permissions: string[];
|
|
419
|
+
scope: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// User
|
|
423
|
+
interface User {
|
|
424
|
+
id: string;
|
|
425
|
+
email: string;
|
|
426
|
+
display_name: string;
|
|
427
|
+
status: string;
|
|
428
|
+
created_at?: number; // Unix epoch seconds
|
|
429
|
+
updated_at?: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Realm
|
|
433
|
+
interface Realm {
|
|
434
|
+
id: string;
|
|
435
|
+
name: string;
|
|
436
|
+
status: string;
|
|
437
|
+
config: Record<string, unknown> | null;
|
|
438
|
+
created_at?: number;
|
|
439
|
+
updated_at?: number;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// OAuthClient — returned by registerClient()
|
|
443
|
+
interface OAuthClient {
|
|
444
|
+
client_id: string;
|
|
445
|
+
client_name: string;
|
|
446
|
+
redirect_uris: string[];
|
|
447
|
+
grant_types: string[];
|
|
448
|
+
created_at?: number;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// PageResponse<T> — paginated list
|
|
452
|
+
interface PageResponse<T> {
|
|
453
|
+
items: T[];
|
|
454
|
+
next_cursor: string | null; // pass as cursor on the next request, or null if last page
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// HearthError
|
|
458
|
+
class HearthError extends Error {
|
|
459
|
+
status: number; // HTTP status code
|
|
460
|
+
body: unknown; // parsed JSON error body
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
## Troubleshooting
|
|
466
|
+
|
|
467
|
+
**`DiscoveryError`** — verify `issuerUrl` is reachable and returns a valid `/.well-known/openid-configuration`.
|
|
468
|
+
|
|
469
|
+
**`JWKSFetchError`** — check network connectivity to the JWKS endpoint. The SDK retries once on a cache miss before returning this error.
|
|
470
|
+
|
|
471
|
+
**`TokenExpiredError`** — the token's `exp` claim is in the past. Refresh the token or re-authenticate.
|
|
472
|
+
|
|
473
|
+
**`TokenInvalidError`** — JWT signature does not match any key in the JWKS. If the server recently rotated keys the SDK will re-fetch once automatically; persistent failures indicate a key mismatch.
|
|
474
|
+
|
|
475
|
+
**`TokenAudienceError`** — the token's `aud` claim does not contain the configured audience. Verify `clientId` matches the audience your authorization server issues.
|
|
476
|
+
|
|
477
|
+
**`AuthorizationModeMismatchError`** — the server echoed an `access_token_authorization` mode
|
|
478
|
+
that differs from the SDK's `expectedMode` config or the `mode` passed to `requirePermission`.
|
|
479
|
+
Verify the `OAuthClient` admin setting matches the resource server's SDK configuration.
|
|
480
|
+
|
|
481
|
+
See [docs/specs/SDK.md](../../docs/specs/SDK.md) Section 5 for the full error taxonomy.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Permission delivery modes (HEA-922/923)
|
|
486
|
+
|
|
487
|
+
Hearth supports three modes for delivering RBAC data to resource servers. Pick one when
|
|
488
|
+
registering the OAuth client; the SDK validates you stay consistent.
|
|
489
|
+
|
|
490
|
+
### Embedded (default)
|
|
491
|
+
|
|
492
|
+
RBAC claims (`permissions`, `roles`, `groups`) are embedded in the JWT at issuance. Zero
|
|
493
|
+
network traffic on every request — stateless and fastest.
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
import { requirePermission } from "@hearth-auth/sdk";
|
|
497
|
+
|
|
498
|
+
const check = requirePermission("docs.write", {
|
|
499
|
+
mode: "embedded",
|
|
500
|
+
client: new HearthClient({ issuerUrl: "https://auth.example.com" }),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// returns true/false synchronously from the JWT; no network call
|
|
504
|
+
const allowed = await check(accessToken);
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Decision (per-request server check)
|
|
508
|
+
|
|
509
|
+
JWT carries only identity claims. The SDK calls `POST /oauth/authorize` on every check.
|
|
510
|
+
Fail-closed: any network or server error returns `false`.
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
import { HearthClient, requirePermission } from "@hearth-auth/sdk";
|
|
514
|
+
|
|
515
|
+
const client = new HearthClient({
|
|
516
|
+
issuerUrl: "https://auth.example.com",
|
|
517
|
+
realmId: "<realm-id>",
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Low-level: call authorize() directly
|
|
521
|
+
const allowed = await client.authorize(accessToken, "docs.write");
|
|
522
|
+
|
|
523
|
+
// Middleware factory
|
|
524
|
+
const check = requirePermission("docs.write", { mode: "decision", client });
|
|
525
|
+
const allowed2 = await check(accessToken);
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Introspection (live RBAC via /introspect)
|
|
529
|
+
|
|
530
|
+
JWT carries only identity claims. The SDK calls `POST /introspect` and reads live RBAC from
|
|
531
|
+
the response. Throws `AuthorizationModeMismatchError` when the server echoes a mode that
|
|
532
|
+
differs from what the middleware expects.
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
import { HearthClient, requirePermission } from "@hearth-auth/sdk";
|
|
536
|
+
|
|
537
|
+
const client = new HearthClient({
|
|
538
|
+
issuerUrl: "https://auth.example.com",
|
|
539
|
+
clientId: "<client-id>",
|
|
540
|
+
clientSecret: "<client-secret>",
|
|
541
|
+
// optional: validate the server echoes the expected mode
|
|
542
|
+
expectedMode: "introspection",
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const check = requirePermission("docs.write", { mode: "introspection", client });
|
|
546
|
+
const allowed = await check(accessToken);
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
> **Design constraint**: the SDK MUST NOT silently fall back from one mode to another based on
|
|
550
|
+
> whether `permissions` is present in the JWT. The `mode` must always be set explicitly.
|
|
551
|
+
> Absence of a `permissions` claim in `embedded` mode means the user has no permissions, not
|
|
552
|
+
> that the SDK should try a network call.
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## Agent Authentication (M5)
|
|
557
|
+
|
|
558
|
+
Hearth supports AI agent identity and authorization via a set of REST endpoints and OAuth extensions. Enable with `agent_auth.capabilities.identity = true` (plus `advanced = true` for AATs and transaction tokens) in your `hearth.yaml`.
|
|
559
|
+
|
|
560
|
+
### Agent CRUD + API keys
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
const client = new HearthClient({ baseUrl, realmId });
|
|
564
|
+
|
|
565
|
+
// Create an agent
|
|
566
|
+
const agent = await client.post("/v1/agents", {
|
|
567
|
+
realm_id: realmId,
|
|
568
|
+
display_name: "my-agent",
|
|
569
|
+
capabilities: ["urn:hearth:capability:docs:read"],
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Issue an API key (long-lived bearer token for the agent)
|
|
573
|
+
const { api_key } = await client.post(`/v1/agents/${agent.agent_id}/credentials/keys`, {
|
|
574
|
+
description: "production key",
|
|
575
|
+
});
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### DPoP-bound tokens (RFC 9449)
|
|
579
|
+
|
|
580
|
+
Bind an access token to an EC key pair so it cannot be replayed by a token thief:
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
import { generateKeyPairSync, sign, createHash, randomUUID } from "node:crypto";
|
|
584
|
+
|
|
585
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
586
|
+
const pub = publicKey.export({ format: "jwk" });
|
|
587
|
+
|
|
588
|
+
// JWK thumbprint per RFC 7638 (lex-sorted required members)
|
|
589
|
+
const canonical = JSON.stringify({ crv: pub.crv, kty: pub.kty, x: pub.x, y: pub.y });
|
|
590
|
+
const thumbprint = createHash("sha256").update(canonical).digest("base64url");
|
|
591
|
+
|
|
592
|
+
function makeDPopProof(htm: string, htu: string, nonce?: string): string {
|
|
593
|
+
const header = { alg: "ES256", jwk: { crv: "EC", kty: "EC", x: pub.x, y: pub.y }, typ: "dpop+jwt" };
|
|
594
|
+
const claims: Record<string, unknown> = {
|
|
595
|
+
htm, htu, iat: Math.floor(Date.now() / 1000), jti: randomUUID(),
|
|
596
|
+
};
|
|
597
|
+
if (nonce) claims.nonce = nonce;
|
|
598
|
+
const b64u = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url");
|
|
599
|
+
const input = `${b64u(header)}.${b64u(claims)}`;
|
|
600
|
+
const sig = sign("SHA256", Buffer.from(input), { key: privateKey, dsaEncoding: "ieee-p1363" });
|
|
601
|
+
return `${input}.${sig.toString("base64url")}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 1st request — server always returns DPoP-Nonce
|
|
605
|
+
const resp1 = await fetch(tokenUrl, { method: "POST", headers: { DPoP: makeDPopProof("POST", tokenUrl) }, body });
|
|
606
|
+
const nonce = resp1.headers.get("dpop-nonce")!;
|
|
607
|
+
|
|
608
|
+
// 2nd request — include nonce; receive AT with cnf.jkt binding
|
|
609
|
+
const resp2 = await fetch(tokenUrl, { method: "POST", headers: { DPoP: makeDPopProof("POST", tokenUrl, nonce) }, body });
|
|
610
|
+
const { access_token } = await resp2.json();
|
|
611
|
+
// Decoded AT claims will contain: cnf: { jkt: "<thumbprint>" }
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### RFC 8693 Token Exchange (OBO / act chain)
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
const body = new URLSearchParams({
|
|
618
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
619
|
+
subject_token: subjectToken,
|
|
620
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
621
|
+
requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
622
|
+
scope: "openid",
|
|
623
|
+
});
|
|
624
|
+
const resp = await fetch(`${baseUrl}/token`, { method: "POST", body, headers: { Authorization: `Basic ${creds}` } });
|
|
625
|
+
const { access_token } = await resp.json();
|
|
626
|
+
// Exchanged token contains: act: { sub: "<actor-client-id>" } (RFC 8693 §4.1)
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Attenuating Authorization Tokens — AATs (Phase D)
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// Issue a root AAT for an agent
|
|
633
|
+
const rootAat = await client.post("/v1/aats", {
|
|
634
|
+
realm_id: realmId,
|
|
635
|
+
agent_id: agentId,
|
|
636
|
+
tools: [
|
|
637
|
+
{ tool_name: "read_docs", constraints: null },
|
|
638
|
+
{ tool_name: "search_files", constraints: null },
|
|
639
|
+
],
|
|
640
|
+
expires_in_secs: 3600,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Derive a child AAT with narrowed scope (child tools ⊆ parent tools)
|
|
644
|
+
const childAat = await client.post("/v1/aats/derive", {
|
|
645
|
+
realm_id: realmId,
|
|
646
|
+
parent_token: rootAat.token,
|
|
647
|
+
tools: [{ tool_name: "read_docs", constraints: null }],
|
|
648
|
+
expires_in_secs: 300,
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Transaction tokens (single-use A2A, 60s TTL)
|
|
653
|
+
|
|
654
|
+
```typescript
|
|
655
|
+
// Issue a single-use transaction token binding agent-a → agent-b
|
|
656
|
+
const txn = await client.post("/v1/transaction-tokens", {
|
|
657
|
+
realm_id: realmId,
|
|
658
|
+
requesting_agent_id: agentAId,
|
|
659
|
+
target_agent_id: agentBId,
|
|
660
|
+
txn_id: `txn-${crypto.randomUUID()}`,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Consume (single-use — second call returns 409)
|
|
664
|
+
await client.post("/v1/transaction-tokens/consume", {
|
|
665
|
+
realm_id: realmId,
|
|
666
|
+
token: txn.token,
|
|
667
|
+
});
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### Draft-standard tracking
|
|
671
|
+
|
|
672
|
+
The following IETF drafts underpin the agent-auth surface. The designated owner for re-checking draft advancement is **[@therecluse26](https://github.com/therecluse26)** (CTO). When a draft advances to RFC or a new revision ships, open a follow-up issue on [HEA-1409](/HEA/issues/HEA-1409).
|
|
673
|
+
|
|
674
|
+
| Draft | Hearth feature | Check when |
|
|
675
|
+
|-------|----------------|-----------|
|
|
676
|
+
| `draft-oauth-ai-agents-on-behalf-of-user-02` | OBO `on_behalf_of` claim | New revision or RFC publication |
|
|
677
|
+
| `draft-niyikiza-oauth-attenuating-agent-tokens` | AAT engine (`/v1/aats`) | New revision or RFC publication |
|
|
678
|
+
| `draft-oauth-transaction-tokens-for-agents` | Transaction tokens (`/v1/transaction-tokens`) | New revision or RFC publication |
|
|
679
|
+
| `draft-prakash-aip` | Agent identity model, Agent Card | New revision or RFC publication |
|
|
680
|
+
| OpenID SSF/CAEP | DPoP JKT blocklist + risk signals | When CAEP SSF spec stabilizes |
|