@hearth-auth/sdk 2.0.4 → 3.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  TypeScript client for the [Hearth](https://github.com/hearth-auth/hearth) identity API.
4
4
 
5
- > **SDK Specification:** This SDK must conform to the [Hearth SDK Common Specification](../../docs/specs/SDK.md).
5
+ > **SDK Specification:** This SDK must conform to the [Hearth SDK Common Specification](../../openspec/specs/sdk-support-contract/spec.md).
6
6
 
7
7
  ## Installation
8
8
 
@@ -14,7 +14,13 @@ yarn add @hearth-auth/sdk
14
14
  pnpm add @hearth-auth/sdk
15
15
  ```
16
16
 
17
- **Peer dependencies:** React (`>=17 <20`) is optional. Only required for the `HearthProvider` / `useHasPermission` hooks.
17
+ **Peer dependencies:** both optional. React (`>=17 <20`) is needed only for the `HearthProvider` / `useHasPermission` hooks. Next.js (`>=14`) is needed only if you import `@hearth-auth/sdk/nextjs` or `@hearth-auth/sdk/nextjs/edge`.
18
+
19
+ | Import path | What it gives you |
20
+ |---|---|
21
+ | `@hearth-auth/sdk` | `HearthClient`, token verification, OAuth flows, Express/Fastify middleware, admin client, React hooks, browser auth |
22
+ | `@hearth-auth/sdk/nextjs` | `withHearthAuth` (Pages Router) and `getHearthClaims` (App Router Route Handlers) |
23
+ | `@hearth-auth/sdk/nextjs/edge` | `hearthEdgeMiddleware` for `middleware.ts` on the Edge Runtime |
18
24
 
19
25
  ---
20
26
 
@@ -23,12 +29,18 @@ pnpm add @hearth-auth/sdk
23
29
  ```typescript
24
30
  import { createHearth, HearthClient } from "@hearth-auth/sdk";
25
31
 
26
- // Low-level HTTP client — auth flows, token exchange, admin ops
32
+ // Server-side client: discovery, token verification, OAuth flows
27
33
  const client = new HearthClient({
28
- baseUrl: "https://hearth.example.com",
34
+ issuerUrl: "https://hearth.example.com",
35
+ clientId: "<client-id>",
36
+ clientSecret: "<client-secret>", // confidential clients only
29
37
  realmId: "<your-realm-id>",
30
38
  });
31
39
 
40
+ const claims = await client.verifyToken(accessToken); // throws on a bad token
41
+ claims.subject();
42
+ claims.hasPermission("docs.write");
43
+
32
44
  // RBAC facade — local, synchronous permission checks from the JWT
33
45
  const hearth = createHearth({
34
46
  baseUrl: "https://hearth.example.com",
@@ -37,56 +49,62 @@ const hearth = createHearth({
37
49
  });
38
50
  ```
39
51
 
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.
52
+ `HearthClient` reads every endpoint URL from `{issuerUrl}/.well-known/openid-configuration` on first use and caches it. `httpTimeout` (default 10 000 ms) applies to every request it makes. Call `client.invalidateCache()` to drop the cached discovery document, JWKS and introspection client.
53
+
54
+ `createHearth` gives you a zero-network RBAC facade that reads claims from the JWT in memory.
41
55
 
42
56
  ---
43
57
 
44
- ## Auth code flow (with PKCE)
58
+ ## Server-side login (authorization code with PKCE)
45
59
 
46
- PKCE is the secure default for every OAuth authorization code flow — required for public clients, recommended for confidential clients.
60
+ `beginLogin` builds the authorization URL with a fresh PKCE verifier and `state`. Keep both in your server-side session; on the callback, check `state` and call `completeLogin`.
47
61
 
48
62
  ```typescript
49
- import {
50
- HearthApiClient,
51
- generateCodeVerifier,
52
- generateCodeChallenge,
53
- } from "@hearth-auth/sdk";
63
+ // GET /login
64
+ const { authorizationUrl, state, codeVerifier } = await client.beginLogin(
65
+ "https://app.example.com/callback",
66
+ "openid profile email", // default: "openid"
67
+ );
68
+ session.oauth = { state, codeVerifier };
69
+ res.redirect(authorizationUrl);
70
+
71
+ // GET /callback
72
+ const url = new URL(req.url, "https://app.example.com");
73
+ if (url.searchParams.get("state") !== session.oauth.state) throw new Error("state mismatch");
74
+ const tokens = await client.completeLogin(
75
+ url.searchParams.get("code")!,
76
+ session.oauth.codeVerifier,
77
+ "https://app.example.com/callback",
78
+ );
79
+ // tokens.access_token, tokens.expires_in, tokens.refresh_token?, tokens.id_token?
80
+ ```
54
81
 
55
- const client = new HearthApiClient({
56
- baseUrl: "https://hearth.example.com",
57
- realmId: "<your-realm-id>",
58
- });
82
+ `completeLogin(code, verifier, redirectUri)` is `exchangeCode(code, redirectUri, { codeVerifier })`. Call `exchangeCode` directly when you built the authorization URL yourself.
59
83
 
60
- // 1. Generate PKCE pair using the SDK helper (works in Node.js 19+ and browsers)
61
- const codeVerifier = generateCodeVerifier();
62
- const codeChallenge = await generateCodeChallenge(codeVerifier);
84
+ Both send `client_id` in the form body, plus `client_secret` when one is configured. A public client leaves `clientSecret` unset and relies on PKCE.
63
85
 
64
- // 2. Start the authorization request
65
- const { code } = await client.authorize({
66
- clientId: "<client-id>",
67
- redirectUri: "https://app.example.com/callback",
68
- scope: "openid profile email",
69
- state: crypto.randomUUID(), // CSRF token
70
- userId: "<authenticated-user-uuid>", // resolved user on your backend
71
- codeChallenge,
72
- codeChallengeMethod: "S256",
73
- });
86
+ ### Refreshing tokens
74
87
 
75
- // 3. Exchange the code for tokens
76
- const tokens = await client.exchangeCode({
77
- clientId: "<client-id>",
78
- code,
79
- redirectUri: "https://app.example.com/callback",
80
- codeVerifier,
81
- });
88
+ ```typescript
89
+ const refreshed = await client.refreshTokens(tokens.refresh_token!);
90
+ // Store refreshed.refresh_token when present — Hearth rotates refresh tokens.
91
+ ```
82
92
 
83
- // tokens.access_token — short-lived JWT (check tokens.expires_in)
84
- // tokens.id_token — OIDC identity token
85
- // tokens.refresh_token — rotate with refreshTokens()
93
+ Pass a second argument to request a narrower scope.
86
94
 
87
- // 4. Refresh before expiry
88
- const refreshed = await client.refreshTokens("<client-id>", tokens.refresh_token);
89
- ```
95
+ ### Other grants
96
+
97
+ | Method | Grant |
98
+ |---|---|
99
+ | `clientCredentials(scope?)` | Client credentials (RFC 6749 §4.4) |
100
+ | `startDeviceFlow(scope?)`, `pollDeviceToken(deviceCode, interval)` | Device authorization (RFC 8628) |
101
+ | `requestMagicLink(email)`, `exchangeMagicLink(token)` | Passwordless magic link (needs `realmId`) |
102
+
103
+ Every token-endpoint failure throws `OAuthFlowError` with `statusCode` and the OAuth `errorCode` (for example `invalid_grant`). A network failure or timeout has `statusCode` 0.
104
+
105
+ ### Browser apps
106
+
107
+ A single-page app has no server session to hold the verifier. Use `createHearthAuth`, or build the flow from `generateCodeVerifier`, `generateCodeChallenge` and `buildAuthorizationUrl`.
90
108
 
91
109
  ---
92
110
 
@@ -206,57 +224,65 @@ All hooks return `false` when no `HearthProvider` is mounted, making them safe t
206
224
 
207
225
  ---
208
226
 
209
- ## UserInfo endpoint
227
+ ## UserInfo and live permissions
210
228
 
211
- Returns OIDC claims filtered by the granted scopes. `sub` is always present; `name` requires `profile` scope; `email` and `email_verified` require `email` scope.
229
+ `userinfo` calls the discovered `userinfo_endpoint`. It returns OIDC claims filtered by the granted scopes: `sub` is always present; `name` needs the `profile` scope; `email` and `email_verified` need `email`.
212
230
 
213
231
  ```typescript
214
232
  const info = await client.userinfo(accessToken);
215
- // info.sub — stable user identifier
216
- // info.name — display name (if profile scope granted)
217
- // info.email — email address (if email scope granted)
218
- // info.email_verified — boolean (if email scope granted)
233
+ // info.sub, info.name?, info.email?, info.email_verified?, plus any other released claim
219
234
  ```
220
235
 
221
- ---
222
-
223
- ## JWKS and discovery
236
+ `mePermissions` calls `GET /v1/me/permissions` and returns the user's roles, groups and permissions as they are now on the server, including changes made after the token was issued. It needs `realmId`.
224
237
 
225
238
  ```typescript
226
- // Retrieve the realm's public signing keys (for local JWT verification)
227
- const jwks = await client.jwks();
228
- // jwks.keys — array of JWK entries (kty, crv, x, kid, use, alg)
229
-
230
- // Retrieve the OIDC discovery document
231
- const discovery = await client.discovery();
232
- // Standard OIDC Core 1.0 metadata
239
+ const { roles, groups, permissions } = await client.mePermissions(accessToken);
233
240
  ```
234
241
 
235
- Use the JWKS with a library like `jose` to verify access tokens on your backend:
242
+ ### Session-version feed
243
+
244
+ `svSnapshot` and `svDelta` read the session-version feed (RFC HEA-930) that lets a resource server see session revocations without introspecting every token. Both need `realmId` and a service token with the `hearth.sv_feed` scope.
236
245
 
237
246
  ```typescript
238
- import { createRemoteJWKSet, jwtVerify } from "jose";
247
+ const snap = await client.svSnapshot(serviceToken); // { current_seq, versions: { [sessionId]: minSv } }
248
+ const delta = await client.svDelta(serviceToken, snap.current_seq, 500); // null when nothing changed
249
+ ```
239
250
 
240
- const JWKS = createRemoteJWKSet(
241
- new URL("https://hearth.example.com/jwks"),
242
- );
251
+ `SessionVersionCache` runs this loop for you and checks a token's `sv` claim without a network call.
243
252
 
244
- const { payload } = await jwtVerify(accessToken, JWKS, {
245
- issuer: "https://hearth.example.com",
246
- audience: "<client-id>",
247
- });
253
+ ---
254
+
255
+ ## JWKS and discovery
256
+
257
+ ```typescript
258
+ // The discovery document (cached after the first call)
259
+ const discovery = await client.discover();
260
+
261
+ // Verify an access token: EdDSA signature against the realm JWKS, then exp,
262
+ // nbf, iss (must equal issuerUrl) and aud (must contain clientId, when set).
263
+ const claims = await client.verifyToken(accessToken);
264
+ claims.subject(); // sub
265
+ claims.scopes(); // scope split into an array
266
+ claims.requiredActions(); // required_actions, [] when absent
267
+ claims.raw(); // the whole payload, frozen
248
268
  ```
249
269
 
270
+ The JWKS is cached; on an unknown `kid` it is fetched again once before the token is refused. `client.jwksClient()` returns the underlying `JwksClient` if you need it directly.
271
+
250
272
  ---
251
273
 
252
274
  ## Admin API
253
275
 
254
- `AdminClient` wraps the `/admin/*` endpoints. Obtain one from any `HearthClient` instance using a bearer token that carries the `hearth.admin` permission.
276
+ `AdminClient` wraps the `/admin/*` endpoints. Construct it with a bearer token that carries the `hearth.admin` permission. Empty arguments throw `ConfigurationError`.
255
277
 
256
278
  ```typescript
257
- const admin = client.admin(accessToken);
279
+ import { AdminClient } from "@hearth-auth/sdk";
280
+
281
+ const admin = new AdminClient("https://hearth.example.com", "<realm-id>", accessToken);
258
282
  ```
259
283
 
284
+ Every list method takes `{ limit?, cursor? }` and returns `{ items, next_cursor }`. Pass `next_cursor` back as `cursor` until it is `null`. Every non-2xx response throws `HearthError` with `status` and `body` (parsed JSON, or the raw text when the body is not JSON).
285
+
260
286
  ### Users
261
287
 
262
288
  ```typescript
@@ -276,7 +302,7 @@ const user = await admin.getUser("<user-id>");
276
302
  // Update a user
277
303
  const updated = await admin.updateUser("<user-id>", {
278
304
  displayName: "Alice Smith",
279
- status: "active",
305
+ status: "USER_STATUS_ACTIVE", // the proto enum name; "active" is refused
280
306
  });
281
307
 
282
308
  // Delete a user
@@ -287,46 +313,85 @@ await admin.deleteUser("<user-id>");
287
313
 
288
314
  ```typescript
289
315
  // Realms are provisioned via hearth.yaml, not the admin API — there is no
290
- // createRealm() (the server returns 405). Only read paths are exposed.
316
+ // createRealm() or updateRealm() (the server returns 405).
291
317
 
292
318
  // List realms (paginated)
293
319
  const page = await admin.listRealms({ limit: 20 });
294
- // page.items: Realm[], page.next_cursor: string | null
295
320
 
296
321
  // Get a realm by ID
297
322
  const realm = await admin.getRealm("<realm-id>");
298
323
 
299
- // Update a realm
300
- const updated = await admin.updateRealm("<realm-id>", {
301
- status: "suspended",
302
- });
303
-
304
324
  // Delete a realm (cascades users, sessions, clients, assignments)
305
325
  await admin.deleteRealm("<realm-id>");
306
326
  ```
307
327
 
328
+ ### Clients, roles and groups
329
+
330
+ `createClient`, `getClient`, `updateClient`, `regenerateClientSecret`, `deleteClient`, `listClients`, and the same create/get/update/delete/list set for roles and groups.
331
+
332
+ ### Organizations
333
+
334
+ ```typescript
335
+ // Create an organization (slug is immutable after creation)
336
+ const org = await admin.createOrganization({
337
+ slug: "acme",
338
+ display_name: "Acme Corp",
339
+ mfa_required: true, // members need MFA even where the realm does not
340
+ });
341
+
342
+ // List, get, update, delete
343
+ const page = await admin.listOrganizations({ limit: 50 });
344
+ const same = await admin.getOrganization(org.id);
345
+ await admin.updateOrganization(org.id, { status: "suspended" });
346
+ await admin.deleteOrganization(org.id);
347
+
348
+ // Extra org roles of one member (the user must already be a member)
349
+ await admin.addMemberRole(org.id, "<user-id>", "billing");
350
+ const roles = await admin.listMemberRoles(org.id, "<user-id>"); // ["billing"]
351
+ await admin.removeMemberRole(org.id, "<user-id>", "billing");
352
+ ```
353
+
354
+ `Organization`, `CreateOrganizationParams` and `UpdateOrganizationParams` are exported types.
355
+
356
+ ### Generated client
357
+
358
+ Routes and path parameters come from a client generated from Hearth's OpenAPI document (`src/generated/admin/schema.ts`, by `openapi-typescript`, called through `openapi-fetch`). Regenerate it with `make sdk-admin-gen` from the repository root; never edit it by hand.
359
+
308
360
  ---
309
361
 
310
362
  ## Error handling
311
363
 
312
- All methods throw `HearthError` on non-2xx responses.
364
+ Errors raised by the SDK itself extend `HearthSdkError`:
365
+
366
+ | Error | When |
367
+ |---|---|
368
+ | `ConfigurationError` | A required setting is missing (`clientId`, `realmId`, a discovery endpoint) |
369
+ | `DiscoveryError` | The discovery document cannot be fetched or is invalid |
370
+ | `JWKSFetchError` | The JWKS cannot be fetched |
371
+ | `TokenVerificationError` | Base class of every token failure below |
372
+ | `TokenExpiredError`, `TokenNotYetValidError` | `exp` / `nbf` outside the clock-skew window |
373
+ | `TokenInvalidError` | Bad signature, wrong algorithm, malformed JWT |
374
+ | `TokenIssuerError`, `TokenAudienceError` | `iss` / `aud` mismatch |
375
+ | `IntrospectionError` | The introspection request failed or returned non-JSON |
376
+ | `OAuthFlowError` | A token, userinfo, permissions or session-version request failed (`statusCode`, `errorCode`) |
377
+ | `AuthorizationModeMismatchError` | Introspection echoed a mode other than `expectedMode` |
378
+ | `RequiredActionError`, `SessionVersionRevokedError`, `SessionVersionCacheStaleError` | See their doc comments |
379
+
380
+ Any JWT-shaped string in an error message is replaced with `[redacted]`, so logging an error does not log a token.
381
+
382
+ `AdminClient` and `HearthApiClient` throw `HearthError` on a non-2xx response: `status` is the HTTP status code, `body` the parsed JSON (or raw text).
313
383
 
314
384
  ```typescript
315
- import { HearthClient, HearthError } from "@hearth-auth/sdk";
385
+ import { HearthError, OAuthFlowError, TokenVerificationError } from "@hearth-auth/sdk";
316
386
 
317
387
  try {
318
- const tokens = await client.exchangeCode({ ... });
388
+ await client.verifyToken(token);
319
389
  } catch (err) {
320
- if (err instanceof HearthError) {
321
- console.error(`HTTP ${err.status}:`, err.body);
322
- } else {
323
- throw err;
324
- }
390
+ if (err instanceof TokenVerificationError) return res.status(401).end();
391
+ throw err;
325
392
  }
326
393
  ```
327
394
 
328
- `HearthError.status` is the HTTP status code. `HearthError.body` is the parsed JSON response body (or the raw string if parsing fails).
329
-
330
395
  ---
331
396
 
332
397
  ## Dev bootstrap (development only)
@@ -334,17 +399,13 @@ try {
334
399
  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.
335
400
 
336
401
  ```typescript
337
- import { HearthClient } from "@hearth-auth/sdk";
402
+ import { AdminClient, HearthApiClient } from "@hearth-auth/sdk";
338
403
 
339
404
  const { realm_id, user_id, access_token, refresh_token } =
340
- await HearthClient.bootstrap("http://127.0.0.1:8420");
405
+ await HearthApiClient.bootstrap("http://127.0.0.1:8420");
341
406
 
342
407
  // Use realm_id and access_token to make subsequent requests
343
- const client = new HearthClient({
344
- baseUrl: "http://127.0.0.1:8420",
345
- realmId: realm_id,
346
- });
347
- const admin = client.admin(access_token);
408
+ const admin = new AdminClient("http://127.0.0.1:8420", realm_id, access_token);
348
409
  ```
349
410
 
350
411
  ---
@@ -354,8 +415,14 @@ const admin = client.admin(access_token);
354
415
  ```typescript
355
416
  // HearthClientConfig — constructor argument for HearthClient
356
417
  interface HearthClientConfig {
357
- baseUrl: string; // Hearth server base URL, e.g. "https://hearth.example.com"
358
- realmId: string; // Realm UUID to scope all requests to
418
+ issuerUrl: string; // e.g. "https://hearth.example.com"; endpoints are discovered from it
419
+ clientId?: string; // needed for login flows, introspection; pins `aud` on verifyToken
420
+ clientSecret?: string; // confidential clients only
421
+ realmId?: string; // sent as X-Realm-ID; needed by authorize, mePermissions, sv feed, magic link
422
+ httpTimeout?: number; // ms, default 10 000
423
+ jwksTtl?: number; // ms, default 5 minutes
424
+ introspectionEndpoint?: string;
425
+ expectedMode?: "embedded" | "introspection" | "decision";
359
426
  }
360
427
 
361
428
  // HearthOptions — argument to createHearth()
@@ -398,10 +465,18 @@ interface TokenExchangeParams {
398
465
  // TokenResponse
399
466
  interface TokenResponse {
400
467
  access_token: string;
401
- id_token: string;
402
- token_type: string; // "Bearer"
403
- expires_in: number; // seconds
404
- refresh_token: string;
468
+ token_type: string; // "Bearer"
469
+ expires_in: number; // seconds
470
+ refresh_token?: string; // absent for client credentials
471
+ id_token?: string; // present when `openid` was granted
472
+ scope?: string;
473
+ }
474
+
475
+ // LoginBeginResult — returned by beginLogin()
476
+ interface LoginBeginResult {
477
+ authorizationUrl: string;
478
+ state: string;
479
+ codeVerifier: string;
405
480
  }
406
481
 
407
482
  // UserInfoResponse
@@ -410,6 +485,8 @@ interface UserInfoResponse {
410
485
  name?: string;
411
486
  email?: string;
412
487
  email_verified?: boolean;
488
+ preferred_username?: string;
489
+ [claim: string]: unknown;
413
490
  }
414
491
 
415
492
  // MePermissionsResponse — from GET /v1/me/permissions
@@ -479,7 +556,7 @@ class HearthError extends Error {
479
556
  that differs from the SDK's `expectedMode` config or the `mode` passed to `requirePermission`.
480
557
  Verify the `OAuthClient` admin setting matches the resource server's SDK configuration.
481
558
 
482
- See [docs/specs/SDK.md](../../docs/specs/SDK.md) Section 5 for the full error taxonomy.
559
+ See [openspec/specs/sdk-support-contract/spec.md](../../openspec/specs/sdk-support-contract/spec.md) Section 5 for the full error taxonomy.
483
560
 
484
561
  ---
485
562
 
@@ -565,6 +642,123 @@ const allowed = await check(accessToken);
565
642
 
566
643
  ---
567
644
 
645
+ ## Server middleware (Express and Fastify)
646
+
647
+ `hearthMiddleware` and `hearthFastifyHook` verify the bearer token on each request, apply optional scope, role and permission guards, and attach the verified `Claims`. Both take the same options:
648
+
649
+ | Option | Meaning |
650
+ |---|---|
651
+ | `client` | The `HearthClient` to verify with. Create one per process so the JWKS cache is shared. |
652
+ | `mode` | `"embedded"`, `"introspection"` or `"decision"`. Default: `client.expectedMode`, then `"embedded"`. |
653
+ | `required` | Default `true`. When `false`, a request with no token or a token that does not verify goes through without claims. |
654
+ | `requiredScope`, `requiredRole` | Checked against the verified JWT in every mode. |
655
+ | `requiredPermission` | Checked per `mode` (see below). |
656
+ | `organizationId`, `resource` | Sent with the decision-mode `POST /oauth/authorize` call. |
657
+
658
+ ```typescript
659
+ import express from "express";
660
+ import { HearthClient, hearthMiddleware } from "@hearth-auth/sdk";
661
+
662
+ const client = new HearthClient({ issuerUrl: "https://hearth.example.com", clientId: "my-api" });
663
+ const app = express();
664
+
665
+ app.get("/docs", hearthMiddleware({ client, requiredPermission: "docs.read" }), (req, res) => {
666
+ res.json({ sub: req.hearthClaims!.subject() });
667
+ });
668
+ ```
669
+
670
+ ```typescript
671
+ import Fastify from "fastify";
672
+ import { hearthFastifyHook } from "@hearth-auth/sdk";
673
+
674
+ const app = Fastify();
675
+ app.addHook("onRequest", hearthFastifyHook({ client, requiredRole: "editor" }));
676
+ // request.hearthClaims is set in route handlers
677
+ ```
678
+
679
+ Responses:
680
+
681
+ | Situation | Status |
682
+ |---|---|
683
+ | No bearer token (with `required`), or the token does not verify | 401 |
684
+ | `token_type` is `required_action` (even when `required` is `false`) | 401 |
685
+ | Introspection mode: the token is no longer active | 401 |
686
+ | Missing scope, role or permission; decision mode denied; introspection failed or echoed another mode | 403 |
687
+
688
+ Every 401 carries `WWW-Authenticate: Bearer realm="hearth"`. Bodies are JSON: `{ "error": "unauthorized" | "forbidden", "error_description": "..." }`.
689
+
690
+ How `requiredPermission` is checked:
691
+
692
+ - **embedded** — from the `permissions` claim of the verified JWT. No network call. A token without the claim has no permissions; the middleware never falls back to another mode.
693
+ - **introspection** — from the live `permissions` returned by `POST /introspect`. Needs `clientId` and `clientSecret` on the client.
694
+ - **decision** — `POST /oauth/authorize` decides; the JWT claim is ignored. Needs `realmId` on the client.
695
+
696
+ A client that cannot serve the mode makes the factory throw `ConfigurationError` at startup, not on the first request.
697
+
698
+ For another framework, call `authenticateRequest(authorizationHeader, options)`. It returns `{ ok: true, claims }` or `{ ok: false, status, headers, body }` for you to send.
699
+
700
+ ---
701
+
702
+ ## Next.js
703
+
704
+ ### Pages Router API routes
705
+
706
+ ```typescript
707
+ // pages/api/profile.ts
708
+ import { withHearthAuth } from "@hearth-auth/sdk/nextjs";
709
+ import { hearth } from "../../lib/hearth"; // a module-scope HearthClient
710
+
711
+ export default withHearthAuth(
712
+ (req, res) => {
713
+ res.json({ sub: req.hearthClaims!.subject() });
714
+ },
715
+ { client: hearth, requiredPermission: "profile.read" },
716
+ );
717
+ ```
718
+
719
+ `withHearthAuth` takes the same options as `hearthMiddleware`. On a 401 or 403 the handler is not called.
720
+
721
+ ### App Router Route Handlers
722
+
723
+ ```typescript
724
+ // app/api/profile/route.ts
725
+ import { NextResponse } from "next/server";
726
+ import { getHearthClaims } from "@hearth-auth/sdk/nextjs";
727
+ import { hearth } from "@/lib/hearth";
728
+
729
+ export async function GET(request: Request) {
730
+ const claims = await getHearthClaims(request, hearth);
731
+ if (!claims) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
732
+ return NextResponse.json({ sub: claims.subject() });
733
+ }
734
+ ```
735
+
736
+ `getHearthClaims` returns `null` when there is no bearer token, the token does not verify, or it is a `required_action` token.
737
+
738
+ ### `middleware.ts` (Edge Runtime)
739
+
740
+ ```typescript
741
+ // middleware.ts
742
+ import { NextResponse, type NextRequest } from "next/server";
743
+ import { HearthClient } from "@hearth-auth/sdk";
744
+ import { hearthEdgeMiddleware } from "@hearth-auth/sdk/nextjs/edge";
745
+
746
+ const guard = hearthEdgeMiddleware({
747
+ client: new HearthClient({ issuerUrl: process.env.HEARTH_ISSUER_URL! }),
748
+ requiredScope: "api",
749
+ });
750
+
751
+ export async function middleware(request: NextRequest) {
752
+ return (await guard(request)) ?? NextResponse.next();
753
+ }
754
+
755
+ export const config = { matcher: ["/api/:path*"] };
756
+ ```
757
+
758
+ The guard resolves to `undefined` when the request may proceed, or to a 401/403 JSON `Response`. It uses only `fetch` and Web Crypto, so it runs on the Edge Runtime. Create it at module scope so the discovery document and JWKS stay cached for the life of the isolate.
759
+
760
+ ---
761
+
568
762
  ## Agent Authentication (M5)
569
763
 
570
764
  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`.