@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
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 |