@hearth-auth/sdk

中文文档
4.6.4发布于 2026/11/01TypeScriptES Module构建来源已验证

TypeScript client for the Hearth identity API.

原始使用文档
文档目录43 节
  1. Installation
  2. Quick start
  3. Audience
  4. Server-side login (authorization code with PKCE)
  5. Refreshing tokens
  6. Other grants
  7. Browser apps
  8. RBAC capabilities
  9. hasPermission(permission: string): Promise<boolean>
  10. hasRole(role: string): Promise<boolean>
  11. inGroup(group: string): Promise<boolean>
  12. inOrg(org: string): Promise<boolean>
  13. client.permissions(): Promise<MePermissionsResponse>
  14. React integration
  15. UserInfo and live permissions
  16. Session-version feed
  17. JWKS and discovery
  18. Admin API
  19. Users
  20. Realms
  21. Clients, roles and groups
  22. Organizations
  23. Generated client
  24. Error handling
  25. Dev bootstrap (development only)
  26. Type reference
  27. Troubleshooting
  28. Permission delivery modes (HEA-922/923)
  29. Embedded (default)
  30. Decision (per-request server check)
  31. Introspection (live RBAC via /introspect)
  32. Server middleware (Express and Fastify)
  33. Next.js
  34. Pages Router API routes
  35. App Router Route Handlers
  36. middleware.ts (Edge Runtime)
  37. Agent Authentication (M5)
  38. Agent CRUD + API keys
  39. DPoP-bound tokens (RFC 9449)
  40. RFC 8693 Token Exchange (OBO / act chain)
  41. Attenuating Authorization Tokens — AATs (Phase D)
  42. Transaction tokens (single-use A2A, 60s TTL)
  43. Draft-standard tracking

Hearth TypeScript SDK

TypeScript client for the Hearth identity API.

SDK Specification: This SDK must conform to the Hearth SDK Common Specification.

Installation

npm install @hearth-auth/sdk
# or
yarn add @hearth-auth/sdk
# or
pnpm add @hearth-auth/sdk

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.

Import pathWhat it gives you
@hearth-auth/sdkHearthClient, token verification, OAuth flows, Express/Fastify middleware, admin client, React hooks, browser auth
@hearth-auth/sdk/nextjswithHearthAuth (Pages Router) and getHearthClaims (App Router Route Handlers)
@hearth-auth/sdk/nextjs/edgehearthEdgeMiddleware for middleware.ts on the Edge Runtime

Quick start

import { createHearth, HearthClient } from "@hearth-auth/sdk";

// Server-side client: discovery, token verification, OAuth flows
const client = new HearthClient({
  issuerUrl: "https://hearth.example.com",
  clientId: "<client-id>",
  clientSecret: "<client-secret>", // confidential clients only
  realmId: "<your-realm-id>",
  audience: "https://api.example.com", // expected `aud`; default "hearth"
});

const claims = await client.verifyToken(accessToken); // throws on a bad token
claims.subject();
claims.hasPermission("docs.write");

// RBAC facade — verifies the token, then checks its claims
const hearth = createHearth({
  baseUrl: "https://hearth.example.com",
  realmId: "<your-realm-id>",
  issuerUrl: "https://hearth.example.com/realms/<realm-slug>",
  getToken: () => currentAccessToken,
});
await hearth.hasPermission("docs.write");

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.

createHearth gives you an RBAC facade over the token getToken() returns. Each check verifies that token against the realm JWKS (fetched once, then cached) before it reads a claim.

Audience

verifyToken always checks aud (RFC 9068 §4); the check cannot be turned off. The expected audience is the audience option, default "hearth" (exported as DEFAULT_AUDIENCE): the audience Hearth mints when a client names no resource. An API registered as a protected resource sets its resource URI. The client ID is not the audience: only ID tokens are checked against a client ID (OIDC Core). An empty audience throws ConfigurationError; a token whose aud does not contain the expected audience throws TokenAudienceError. JwksClient takes the same option, and VerifyOptions.audience overrides it for one verify() call.


Server-side login (authorization code with PKCE)

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.

// GET /login
const { authorizationUrl, state, codeVerifier } = await client.beginLogin(
  "https://app.example.com/callback",
  "openid profile email", // default: "openid"
);
session.oauth = { state, codeVerifier };
res.redirect(authorizationUrl);

// GET /callback
const url = new URL(req.url, "https://app.example.com");
if (url.searchParams.get("state") !== session.oauth.state) throw new Error("state mismatch");
const tokens = await client.completeLogin(
  url.searchParams.get("code")!,
  session.oauth.codeVerifier,
  "https://app.example.com/callback",
);
// tokens.access_token, tokens.expires_in, tokens.refresh_token?, tokens.id_token?

completeLogin(code, verifier, redirectUri) is exchangeCode(code, redirectUri, { codeVerifier }). Call exchangeCode directly when you built the authorization URL yourself.

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.

Refreshing tokens

const refreshed = await client.refreshTokens(tokens.refresh_token!);
// Store refreshed.refresh_token when present — Hearth rotates refresh tokens.

Pass a second argument to request a narrower scope.

Other grants

MethodGrant
clientCredentials(scope?)Client credentials (RFC 6749 §4.4)
startDeviceFlow(scope?), pollDeviceToken(deviceCode, interval)Device authorization (RFC 8628)
requestMagicLink(email), exchangeMagicLink(token)Passwordless magic link (needs realmId)

Every token-endpoint failure throws OAuthFlowError with statusCode and the OAuth errorCode (for example invalid_grant). A network failure or timeout has statusCode 0.

Browser apps

A single-page app has no server session to hold the verifier. Use createHearthAuth, or build the flow from generateCodeVerifier, generateCodeChallenge and buildAuthorizationUrl.

import { createHearthAuth, HearthApiClient } from "@hearth-auth/sdk";

const auth = createHearthAuth(
  new HearthApiClient({ baseUrl: "https://hearth.example.com", realmId: "<realm-id>" }),
  {
    clientId: "<client-id>",
    redirectUri: "https://app.example.com/callback",
    hearthUrl: "https://hearth.example.com",
    realmSlug: "<realm-slug>",
    // storage: "sessionStorage" (default) | "localStorage" | "memory" | { getItem, setItem, removeItem }
    // storageKeyPrefix: "hearth_" (default)
  },
);

await auth.startLogin();
// on the callback page: await auth.handleCallback(code, state);
auth.getAccessToken(); // also getRefreshToken(), getIdToken(), isAuthenticated(), clearTokens()

The access token stays in memory. The refresh token, ID token and the PKCE verifier and state of a login in flight go to the configured storage, sessionStorage by default (scoped to one tab, cleared when it closes). "memory" does not survive the login redirect. At creation the facade removes hearth_refresh_token and hearth_id_token from localStorage unless localStorage is the configured store.


RBAC capabilities

Every predicate verifies the token returned by getToken() before it reads a claim: the EdDSA signature against the realm JWKS (fetched from issuerUrl once, then cached), plus exp, nbf, iat, iss (must equal issuerUrl) and aud (must contain audience, default "hearth"). When the token is absent or does not verify, every predicate resolves false. With sessionVersions enabled, a predicate may reject with SessionVersionRevokedError or SessionVersionCacheStaleError.

const hearth = createHearth({
  baseUrl: "https://hearth.example.com",
  realmId: "<your-realm-id>",
  issuerUrl: "https://hearth.example.com/realms/<realm-slug>", // required: the realm issuer
  audience: "hearth",                                           // optional; this is the default
  getToken: () => auth.getAccessToken(), // may also return a Promise
});

hasPermission(permission: string): Promise<boolean>

Resolves true iff the token verifies and its permissions claim contains permission. Use this for feature gates and API guards.

if (await hearth.hasPermission("docs.versions.read")) {
  renderVersionHistory();
}

hasRole(role: string): Promise<boolean>

Resolves true iff the token verifies and its roles claim contains role. Useful for UI personalization and coarse-grained access.

if (await hearth.hasRole("billing-admin")) {
  renderBillingPanel();
}

inGroup(group: string): Promise<boolean>

Resolves true iff the token verifies and its groups claim contains the group slug.

if (await hearth.inGroup("engineering")) {
  renderInternalToolingLink();
}

inOrg(org: string): Promise<boolean>

Resolves true iff the token verifies and its oid claim equals the given org ID.

if (await hearth.inOrg("6f1d0c1e-3b9a-4c2e-9d7f-1a2b3c4d5e6f") /* the org's id */) {
  renderAcmeContent();
}

client.permissions(): Promise<MePermissionsResponse>

Calls GET /v1/me/permissions and returns the freshly-resolved RBAC claim set from the server. Unlike the predicates above, this reflects any role/group assignments made since the JWT was issued.

const { roles, groups, permissions } = await hearth.client.permissions();

Use client.permissions() when you need post-issuance accuracy (e.g., after an admin operation). For every other check, prefer the local predicates — once the JWKS is cached they make no network call.


React integration

The React hooks are exported from the main @hearth-auth/sdk package. No subpath import needed.

import {
  createHearth,
  HearthProvider,
  useHasPermission,
  useHasRole,
  useInGroup,
  useInOrg,
} from "@hearth-auth/sdk";

// 1. Create the facade once at app startup
const hearth = createHearth({
  baseUrl: "https://hearth.example.com",
  realmId: "<your-realm-id>",
  issuerUrl: "https://hearth.example.com/realms/<realm-slug>",
  getToken: () => auth.getAccessToken(), // auth = createHearthAuth(...)
});

// 2. Mount the provider at the root of your React tree
function App() {
  return (
    <HearthProvider client={hearth}>
      <Router />
    </HearthProvider>
  );
}

// 3. Use hooks anywhere in the tree — no prop drilling
function NavBar() {
  const canEdit   = useHasPermission("docs.write");
  const isAdmin   = useHasRole("admin");
  const inEng     = useInGroup("engineering");
  const isAcme    = useInOrg("6f1d0c1e-3b9a-4c2e-9d7f-1a2b3c4d5e6f");

  return (
    <nav>
      {canEdit   && <a href="/editor">Editor</a>}
      {isAdmin   && <a href="/admin">Admin</a>}
      {inEng     && <a href="/internal">Internal tools</a>}
      {isAcme    && <a href="/acme">Acme portal</a>}
    </nav>
  );
}

The hooks return boolean. Each runs the facade's asynchronous check after every render and returns its last result: false until the check resolves, and false when it rejects. All hooks return false when no HearthProvider is mounted, making them safe to call in tests without a provider.


UserInfo and live permissions

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.

const info = await client.userinfo(accessToken);
// info.sub, info.name?, info.email?, info.email_verified?, plus any other released claim

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.

const { roles, groups, permissions } = await client.mePermissions(accessToken);

Session-version feed

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.

const snap = await client.svSnapshot(serviceToken); // { current_seq, versions: { [sessionId]: minSv } }
const delta = await client.svDelta(serviceToken, snap.current_seq, 500); // null when nothing changed

SessionVersionCache runs this loop for you and checks a token's sv claim without a network call.


JWKS and discovery

// The discovery document (cached after the first call)
const discovery = await client.discover();

// Verify an access token: EdDSA signature against the realm JWKS, then exp,
// nbf, iat (not in the future), iss (must equal issuerUrl) and aud (must
// contain `audience`, default "hearth").
const claims = await client.verifyToken(accessToken);
claims.subject();          // sub
claims.scopes();           // scope split into an array
claims.requiredActions();  // required_actions, [] when absent
claims.raw();              // the whole payload, frozen

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.


Admin API

AdminClient wraps the /admin/* endpoints. Construct it with a bearer token that carries the hearth.admin permission. Empty arguments throw ConfigurationError.

import { AdminClient } from "@hearth-auth/sdk";

const admin = new AdminClient("https://hearth.example.com", "<realm-id>", accessToken);

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).

Users

// Create a user
const user = await admin.createUser({
  email: "[email protected]",
  displayName: "Alice",
});

// List users (paginated)
const page = await admin.listUsers({ limit: 50 });
// page.items: User[], page.next_cursor: string | null

// Get a user by ID
const user = await admin.getUser("<user-id>");

// Update a user
const updated = await admin.updateUser("<user-id>", {
  displayName: "Alice Smith",
  status: "USER_STATUS_ACTIVE", // the proto enum name; "active" is refused
});

// Delete a user
await admin.deleteUser("<user-id>");

Realms

// Realms are provisioned via hearth.yaml, not the admin API — there is no
// createRealm() or updateRealm() (the server returns 405).

// List realms (paginated)
const page = await admin.listRealms({ limit: 20 });

// Get a realm by ID
const realm = await admin.getRealm("<realm-id>");

// Delete a realm (cascades users, sessions, clients, assignments)
await admin.deleteRealm("<realm-id>");

Clients, roles and groups

createClient, getClient, updateClient, regenerateClientSecret, deleteClient, listClients, and the same create/get/update/delete/list set for roles and groups.

Organizations

// Create an organization (slug is immutable after creation)
const org = await admin.createOrganization({
  slug: "acme",
  display_name: "Acme Corp",
  mfa_required: true, // members need MFA even where the realm does not
});

// List, get, update, delete
const page = await admin.listOrganizations({ limit: 50 });
const same = await admin.getOrganization(org.id);
await admin.updateOrganization(org.id, { status: "suspended" });
await admin.deleteOrganization(org.id);

// Extra org roles of one member (the user must already be a member)
await admin.addMemberRole(org.id, "<user-id>", "billing");
const roles = await admin.listMemberRoles(org.id, "<user-id>"); // ["billing"]
await admin.removeMemberRole(org.id, "<user-id>", "billing");

Organization, CreateOrganizationParams and UpdateOrganizationParams are exported types.

Generated client

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.


Error handling

Errors raised by the SDK itself extend HearthSdkError:

ErrorWhen
ConfigurationErrorA required setting is missing (clientId, realmId, a discovery endpoint) or audience is empty
DiscoveryErrorThe discovery document cannot be fetched or is invalid
JWKSFetchErrorThe JWKS cannot be fetched
TokenVerificationErrorBase class of every token failure below
TokenExpiredError, TokenNotYetValidErrorexp / nbf or iat outside the clock-skew window
TokenInvalidErrorBad signature, wrong algorithm, malformed JWT
TokenIssuerError, TokenAudienceErroriss mismatch / aud does not contain the configured audience
IntrospectionErrorThe introspection request failed or returned non-JSON
OAuthFlowErrorA token, userinfo, permissions or session-version request failed (statusCode, errorCode)
AuthorizationModeMismatchErrorIntrospection echoed a mode other than expectedMode
RequiredActionError, SessionVersionRevokedError, SessionVersionCacheStaleErrorSee their doc comments

Any JWT-shaped string in an error message is replaced with [redacted], so logging an error does not log a token.

AdminClient and HearthApiClient throw HearthError on a non-2xx response: status is the HTTP status code, body the parsed JSON (or raw text).

import { HearthError, OAuthFlowError, TokenVerificationError } from "@hearth-auth/sdk";

try {
  await client.verifyToken(token);
} catch (err) {
  if (err instanceof TokenVerificationError) return res.status(401).end();
  throw err;
}

Dev bootstrap (development only)

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.

import { AdminClient, HearthApiClient } from "@hearth-auth/sdk";

const { realm_id, user_id, access_token, refresh_token } =
  await HearthApiClient.bootstrap("http://127.0.0.1:8420");

// Use realm_id and access_token to make subsequent requests
const admin = new AdminClient("http://127.0.0.1:8420", realm_id, access_token);

Type reference

// HearthClientConfig — constructor argument for HearthClient
interface HearthClientConfig {
  issuerUrl: string;            // e.g. "https://hearth.example.com"; endpoints are discovered from it
  clientId?: string;            // needed for login flows, introspection
  audience?: string;            // expected `aud` on verifyToken; default "hearth"; always checked
  clientSecret?: string;        // confidential clients only
  realmId?: string;             // sent as X-Realm-ID; needed by authorize, mePermissions, sv feed, magic link
  httpTimeout?: number;         // ms, default 10 000
  jwksTtl?: number;             // ms, default 5 minutes
  introspectionEndpoint?: string;
  expectedMode?: "embedded" | "introspection" | "decision";
}

// HearthOptions — argument to createHearth()
interface HearthOptions {
  baseUrl: string;
  realmId: string;
  issuerUrl: string;            // the realm issuer, e.g. "https://hearth.example.com/realms/acme"
  audience?: string;            // expected `aud`; default "hearth"
  // called on every predicate check
  getToken: () => string | null | undefined | Promise<string | null | undefined>;
  sessionVersions?: SessionVersionConfig;
}

// HearthFacade — returned by createHearth(); each predicate verifies the token first
interface HearthFacade {
  hasPermission(permission: string): Promise<boolean>;
  hasRole(role: string): Promise<boolean>;
  inGroup(group: string): Promise<boolean>;
  inOrg(org: string): Promise<boolean>;
  sessionVersionCacheAge(): number;
  stop(): void;
  client: { permissions(): Promise<MePermissionsResponse> };
}

// AuthConfig — second argument to createHearthAuth(client, config)
interface AuthConfig {
  clientId: string;
  redirectUri: string;
  hearthUrl: string;            // e.g. "https://hearth.example.com"
  realmSlug: string;            // e.g. "acme"
  storage?: "sessionStorage" | "localStorage" | "memory" | TokenStorage; // default "sessionStorage"
  storageKeyPrefix?: string;    // default "hearth_"
}

// HearthBrowserAuth — returned by createHearthAuth()
interface HearthBrowserAuth {
  startLogin(): Promise<void>;
  handleCallback(code: string, state: string): Promise<void>;
  refreshAccessToken(): Promise<void>;
  logout(): Promise<void>;
  getAccessToken(): string | null; // held in memory
  getRefreshToken(): string | null;
  getIdToken(): string | null;
  isAuthenticated(): boolean;
  clearTokens(): void;
}

// AuthorizeParams
interface AuthorizeParams {
  clientId: string;
  redirectUri: string;
  scope: string;
  state: string;
  userId: string;
  responseType?: string;       // default: "code"
  codeChallenge?: string;      // S256 challenge; required for PKCE
  codeChallengeMethod?: string; // "S256"
  nonce?: string;              // echoed in the ID token
}

// TokenExchangeParams
interface TokenExchangeParams {
  clientId: string;
  code: string;
  redirectUri: string;
  codeVerifier?: string; // required when codeChallenge was sent on authorize
}

// TokenResponse
interface TokenResponse {
  access_token: string;
  token_type: string;      // "Bearer"
  expires_in: number;      // seconds
  refresh_token?: string;  // absent for client credentials
  id_token?: string;       // present when `openid` was granted
  scope?: string;
}

// LoginBeginResult — returned by beginLogin()
interface LoginBeginResult {
  authorizationUrl: string;
  state: string;
  codeVerifier: string;
}

// UserInfoResponse
interface UserInfoResponse {
  sub: string;
  name?: string;
  email?: string;
  email_verified?: boolean;
  preferred_username?: string;
  [claim: string]: unknown;
}

// MePermissionsResponse — from GET /v1/me/permissions
interface MePermissionsResponse {
  roles: string[];
  groups: string[];
  permissions: string[];
  scope: string;
}

// User
interface User {
  id: string;
  email: string;
  display_name: string;
  status: string;
  created_at?: number; // Unix epoch seconds
  updated_at?: number;
}

// Realm
interface Realm {
  id: string;
  name: string;
  status: string;
  config: Record<string, unknown> | null;
  created_at?: number;
  updated_at?: number;
}

// OAuthClient — returned by registerClient()
interface OAuthClient {
  client_id: string;
  client_name: string;
  redirect_uris: string[];
  grant_types: string[];
  created_at?: number;
}

// PageResponse<T> — paginated list
interface PageResponse<T> {
  items: T[];
  next_cursor: string | null; // pass as cursor on the next request, or null if last page
}

// HearthError
class HearthError extends Error {
  status: number;   // HTTP status code
  body: unknown;    // parsed JSON error body
}

Troubleshooting

DiscoveryError — verify issuerUrl is reachable and returns a valid /.well-known/openid-configuration.

JWKSFetchError — check network connectivity to the JWKS endpoint. The SDK retries once on a cache miss before returning this error.

TokenExpiredError — the token's exp claim is in the past. Refresh the token or re-authenticate.

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.

TokenAudienceError — the token's aud claim does not contain the configured audience (default "hearth"). Set audience to the resource URI the token was issued for; the client ID is not the audience.

AuthorizationModeMismatchError — the server echoed an access_token_authorization mode that differs from the SDK's expectedMode config or the mode passed to requirePermission. Verify the OAuthClient admin setting matches the resource server's SDK configuration.

See openspec/specs/sdk-support-contract/spec.md Section 5 for the full error taxonomy.


Permission delivery modes (HEA-922/923)

Hearth supports three modes for delivering RBAC data to resource servers. Pick one when registering the OAuth client; the SDK validates you stay consistent.

Embedded (default)

RBAC claims (permissions, roles, groups) are embedded in the JWT at issuance. The checker verifies the token first — EdDSA signature against the realm's JWKS, plus exp, nbf, iat, iss and aud — and only then reads the claim. The JWKS is cached, so after the first request there is no network traffic per check.

A token that does not verify returns false; the checker never trusts an unverified payload.

import { HearthClient, requirePermission } from "@hearth-auth/sdk";

const client = new HearthClient({
  issuerUrl: "https://auth.example.com",
  audience: "https://api.example.com", // expected `aud`; default "hearth"
});

const check = requirePermission("docs.write", { mode: "embedded", client });

// Verifies the token, then reads its permissions claim.
const allowed = await check(accessToken);

Security note. Before v1.1 this checker called decodeJwt and trusted whatever the payload said, so an alg: none token carrying permissions: ["admin.write"] was accepted. If you pinned an older version, upgrade.

Decision (per-request server check)

JWT carries only identity claims. The SDK calls POST /oauth/authorize on every check. Fail-closed: any network or server error returns false.

import { HearthClient, requirePermission } from "@hearth-auth/sdk";

const client = new HearthClient({
  issuerUrl: "https://auth.example.com",
  realmId: "<realm-id>",
});

// Low-level: call authorize() directly
const allowed = await client.authorize(accessToken, "docs.write");

// Middleware factory
const check = requirePermission("docs.write", { mode: "decision", client });
const allowed2 = await check(accessToken);

Introspection (live RBAC via /introspect)

JWT carries only identity claims. The SDK calls POST /introspect and reads live RBAC from the response. Throws AuthorizationModeMismatchError when the server echoes a mode that differs from what the middleware expects.

import { HearthClient, requirePermission } from "@hearth-auth/sdk";

const client = new HearthClient({
  issuerUrl: "https://auth.example.com",
  clientId: "<client-id>",
  clientSecret: "<client-secret>",
  // optional: validate the server echoes the expected mode
  expectedMode: "introspection",
});

const check = requirePermission("docs.write", { mode: "introspection", client });
const allowed = await check(accessToken);

Design constraint: the SDK MUST NOT silently fall back from one mode to another based on whether permissions is present in the JWT. The mode must always be set explicitly. Absence of a permissions claim in embedded mode means the user has no permissions, not that the SDK should try a network call.


Server middleware (Express and Fastify)

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:

OptionMeaning
clientThe HearthClient to verify with. Create one per process so the JWKS cache is shared.
mode"embedded", "introspection" or "decision". Default: client.expectedMode, then "embedded".
requiredDefault true. When false, a request with no token or a token that does not verify goes through without claims.
requiredScope, requiredRoleChecked against the verified JWT in every mode.
requiredPermissionChecked per mode (see below).
organizationId, resourceSent with the decision-mode POST /oauth/authorize call.
import express from "express";
import { HearthClient, hearthMiddleware } from "@hearth-auth/sdk";

const client = new HearthClient({ issuerUrl: "https://hearth.example.com" });
const app = express();

app.get("/docs", hearthMiddleware({ client, requiredPermission: "docs.read" }), (req, res) => {
  res.json({ sub: req.hearthClaims!.subject() });
});
import Fastify from "fastify";
import { hearthFastifyHook } from "@hearth-auth/sdk";

const app = Fastify();
app.addHook("onRequest", hearthFastifyHook({ client, requiredRole: "editor" }));
// request.hearthClaims is set in route handlers

Responses:

SituationStatus
No bearer token (with required), or the token does not verify401
token_type is required_action (even when required is false)401
Introspection mode: the token is no longer active401
Missing scope, role or permission; decision mode denied; introspection failed or echoed another mode403

Every 401 carries WWW-Authenticate: Bearer realm="hearth". Bodies are JSON: { "error": "unauthorized" | "forbidden", "error_description": "..." }.

How requiredPermission is checked:

  • 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.
  • introspection — from the live permissions returned by POST /introspect. Needs clientId and clientSecret on the client.
  • decision — POST /oauth/authorize decides; the JWT claim is ignored. Needs realmId on the client.

A client that cannot serve the mode makes the factory throw ConfigurationError at startup, not on the first request.

For another framework, call authenticateRequest(authorizationHeader, options). It returns { ok: true, claims } or { ok: false, status, headers, body } for you to send.


Next.js

Pages Router API routes

// pages/api/profile.ts
import { withHearthAuth } from "@hearth-auth/sdk/nextjs";
import { hearth } from "../../lib/hearth"; // a module-scope HearthClient

export default withHearthAuth(
  (req, res) => {
    res.json({ sub: req.hearthClaims!.subject() });
  },
  { client: hearth, requiredPermission: "profile.read" },
);

withHearthAuth takes the same options as hearthMiddleware. On a 401 or 403 the handler is not called.

App Router Route Handlers

// app/api/profile/route.ts
import { NextResponse } from "next/server";
import { getHearthClaims } from "@hearth-auth/sdk/nextjs";
import { hearth } from "@/lib/hearth";

export async function GET(request: Request) {
  const claims = await getHearthClaims(request, hearth);
  if (!claims) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  return NextResponse.json({ sub: claims.subject() });
}

getHearthClaims returns null when there is no bearer token, the token does not verify, or it is a required_action token.

middleware.ts (Edge Runtime)

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { HearthClient } from "@hearth-auth/sdk";
import { hearthEdgeMiddleware } from "@hearth-auth/sdk/nextjs/edge";

const guard = hearthEdgeMiddleware({
  client: new HearthClient({ issuerUrl: process.env.HEARTH_ISSUER_URL! }),
  requiredScope: "api",
});

export async function middleware(request: NextRequest) {
  return (await guard(request)) ?? NextResponse.next();
}

export const config = { matcher: ["/api/:path*"] };

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.


Agent Authentication (M5)

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.

Agent CRUD + API keys

const client = new HearthClient({ baseUrl, realmId });

// Create an agent
const agent = await client.post("/v1/agents", {
  realm_id: realmId,
  display_name: "my-agent",
  capabilities: ["urn:hearth:capability:docs:read"],
});

// Issue an API key (long-lived bearer token for the agent)
const { api_key } = await client.post(`/v1/agents/${agent.agent_id}/credentials/keys`, {
  description: "production key",
});

DPoP-bound tokens (RFC 9449)

Bind an access token to an EC key pair so it cannot be replayed by a token thief:

import { generateKeyPairSync, sign, createHash, randomUUID } from "node:crypto";

const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const pub = publicKey.export({ format: "jwk" });

// JWK thumbprint per RFC 7638 (lex-sorted required members)
const canonical = JSON.stringify({ crv: pub.crv, kty: pub.kty, x: pub.x, y: pub.y });
const thumbprint = createHash("sha256").update(canonical).digest("base64url");

function makeDPopProof(htm: string, htu: string, nonce?: string): string {
  const header = { alg: "ES256", jwk: { crv: "EC", kty: "EC", x: pub.x, y: pub.y }, typ: "dpop+jwt" };
  const claims: Record<string, unknown> = {
    htm, htu, iat: Math.floor(Date.now() / 1000), jti: randomUUID(),
  };
  if (nonce) claims.nonce = nonce;
  const b64u = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url");
  const input = `${b64u(header)}.${b64u(claims)}`;
  const sig = sign("SHA256", Buffer.from(input), { key: privateKey, dsaEncoding: "ieee-p1363" });
  return `${input}.${sig.toString("base64url")}`;
}

// 1st request — server always returns DPoP-Nonce
const resp1 = await fetch(tokenUrl, { method: "POST", headers: { DPoP: makeDPopProof("POST", tokenUrl) }, body });
const nonce = resp1.headers.get("dpop-nonce")!;

// 2nd request — include nonce; receive AT with cnf.jkt binding
const resp2 = await fetch(tokenUrl, { method: "POST", headers: { DPoP: makeDPopProof("POST", tokenUrl, nonce) }, body });
const { access_token } = await resp2.json();
// Decoded AT claims will contain: cnf: { jkt: "<thumbprint>" }

RFC 8693 Token Exchange (OBO / act chain)

const body = new URLSearchParams({
  grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
  subject_token: subjectToken,
  subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
  requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
  scope: "openid",
});
const resp = await fetch(`${baseUrl}/token`, { method: "POST", body, headers: { Authorization: `Basic ${creds}` } });
const { access_token } = await resp.json();
// Exchanged token contains: act: { sub: "<actor-client-id>" }  (RFC 8693 §4.1)

Attenuating Authorization Tokens — AATs (Phase D)

// Issue a root AAT for an agent
const rootAat = await client.post("/v1/aats", {
  realm_id: realmId,
  agent_id: agentId,
  tools: [
    { tool_name: "read_docs", constraints: null },
    { tool_name: "search_files", constraints: null },
  ],
  expires_in_secs: 3600,
});

// Derive a child AAT with narrowed scope (child tools ⊆ parent tools)
const childAat = await client.post("/v1/aats/derive", {
  realm_id: realmId,
  parent_token: rootAat.token,
  tools: [{ tool_name: "read_docs", constraints: null }],
  expires_in_secs: 300,
});

Transaction tokens (single-use A2A, 60s TTL)

// Issue a single-use transaction token binding agent-a → agent-b
const txn = await client.post("/v1/transaction-tokens", {
  realm_id: realmId,
  requesting_agent_id: agentAId,
  target_agent_id: agentBId,
  txn_id: `txn-${crypto.randomUUID()}`,
});

// Consume (single-use — second call returns 409)
await client.post("/v1/transaction-tokens/consume", {
  realm_id: realmId,
  token: txn.token,
});

Draft-standard tracking

The following IETF drafts underpin the agent-auth surface. The designated owner for re-checking draft advancement is @therecluse26 (CTO). When a draft advances to RFC or a new revision ships, open a follow-up issue on HEA-1409.

DraftHearth featureCheck when
draft-oauth-ai-agents-on-behalf-of-user-02OBO on_behalf_of claimNew revision or RFC publication
draft-niyikiza-oauth-attenuating-agent-tokensAAT engine (/v1/aats)New revision or RFC publication
draft-oauth-transaction-tokens-for-agentsTransaction tokens (/v1/transaction-tokens)New revision or RFC publication
draft-prakash-aipAgent identity model, Agent CardNew revision or RFC publication
OpenID SSF/CAEPDPoP JKT blocklist + risk signalsWhen CAEP SSF spec stabilizes

版本与发布记录

@hearth-auth/sdk 共发布 105 个版本,首次发布于 2026/06/23;过去 12 个月发布了 105 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
4.6.4latest2026/11/012 天前
4.6.32026/11/012 天前
4.6.22026/11/012 天前
4.6.12026/10/312 天前
4.6.02026/10/313 天前
4.5.182026/10/313 天前

关于 @hearth-auth/sdk 的常见问题

@hearth-auth/sdk 是什么?

的TypeScript客户端 炉 身份的API。

如何安装 @hearth-auth/sdk?

在项目目录运行 npm install @hearth-auth/sdk,或使用 pnpm add / yarn add / bun add。

@hearth-auth/sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@hearth-auth/sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@hearth-auth/sdk 可以商用吗?

@hearth-auth/sdk 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@hearth-auth/sdk 还在维护吗?

最新版本 4.6.4 发布于 2026/11/01(2 天前);至今共发布 105 个版本,其中过去 12 个月发布了 105 个;首次发布于 2026/06/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。