@hearth-auth/sdk 2.0.4 → 3.0.0
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 +295 -101
- package/dist/admin.d.ts +82 -47
- package/dist/admin.js +179 -132
- package/dist/admin.js.map +1 -1
- package/dist/claims.d.ts +10 -0
- package/dist/claims.js +16 -0
- package/dist/claims.js.map +1 -1
- package/dist/errors.d.ts +18 -6
- package/dist/errors.js +70 -7
- package/dist/errors.js.map +1 -1
- package/dist/generated/admin/schema.d.ts +3648 -0
- package/dist/generated/admin/schema.js +6 -0
- package/dist/generated/admin/schema.js.map +1 -0
- package/dist/hearth-client.d.ts +129 -10
- package/dist/hearth-client.js +278 -83
- package/dist/hearth-client.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/introspection-client.d.ts +9 -3
- package/dist/introspection-client.js +35 -14
- package/dist/introspection-client.js.map +1 -1
- package/dist/jwks-client.d.ts +8 -2
- package/dist/jwks-client.js +20 -10
- package/dist/jwks-client.js.map +1 -1
- package/dist/middleware.d.ts +117 -0
- package/dist/middleware.js +171 -1
- package/dist/middleware.js.map +1 -1
- package/dist/nextjs/edge.d.ts +26 -0
- package/dist/nextjs/edge.js +31 -0
- package/dist/nextjs/edge.js.map +1 -0
- package/dist/nextjs/index.d.ts +50 -0
- package/dist/nextjs/index.js +55 -0
- package/dist/nextjs/index.js.map +1 -0
- package/dist/session-version-cache.js.map +1 -1
- package/dist/types.d.ts +62 -4
- package/package.json +20 -3
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](../../
|
|
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
|
|
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
|
-
//
|
|
32
|
+
// Server-side client: discovery, token verification, OAuth flows
|
|
27
33
|
const client = new HearthClient({
|
|
28
|
-
|
|
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`
|
|
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
|
-
##
|
|
58
|
+
## Server-side login (authorization code with PKCE)
|
|
45
59
|
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
227
|
+
## UserInfo and live permissions
|
|
210
228
|
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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 {
|
|
385
|
+
import { HearthError, OAuthFlowError, TokenVerificationError } from "@hearth-auth/sdk";
|
|
316
386
|
|
|
317
387
|
try {
|
|
318
|
-
|
|
388
|
+
await client.verifyToken(token);
|
|
319
389
|
} catch (err) {
|
|
320
|
-
if (err instanceof
|
|
321
|
-
|
|
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 {
|
|
402
|
+
import { AdminClient, HearthApiClient } from "@hearth-auth/sdk";
|
|
338
403
|
|
|
339
404
|
const { realm_id, user_id, access_token, refresh_token } =
|
|
340
|
-
await
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 [
|
|
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`.
|