灶台TypeScript SDK
的TypeScript客户端 炉 身份的API。
SDK规范: 此SDK必须符合 灶台SDK通用规范.
安装
npm install @hearth-auth/sdk
# or
yarn add @hearth-auth/sdk
# or
pnpm add @hearth-auth/sdk
同行依赖性: 两个可选的。反应(>=17 <20)只需要用于 HearthProvider / useHasPermission 钩子。Next.js (>=14)只在导入时才需要 @hearth-auth/sdk/nextjs 或者 @hearth-auth/sdk/nextjs/edge.
| 导入路径 | 它给了你什么 |
|---|---|
@hearth-auth/sdk | HearthClient,令牌验证,OAuth流,Express/ fasttify中间件,管理客户端,React钩子,浏览器验证 |
@hearth-auth/sdk/nextjs | withHearthAuth (Pages Router)和 getHearthClaims (应用路由器路由处理程序) |
@hearth-auth/sdk/nextjs/edge | hearthEdgeMiddleware 用于 middleware.ts 在边缘运行时 |
快速启动
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 读取其中的每个端点URL {issuerUrl}/.well-known/openid-configuration 第一次使用并缓存它。 httpTimeout (默认为10000毫秒)适用于它发出的每个请求。呼叫 client.invalidateCache() 删除缓存的发现文档、JWKS和自省客户端。
createHearth 为令牌提供了一个RBAC facade getToken() 的回报。在读取声明之前,每次检查都会根据领域JWKS(获取一次,然后缓存)验证该令牌。
观众
verifyToken 总是检查 aud (rfc 9068§4);检查不能关闭。预期的受众是 audience 选项,默认 "hearth" (出口 DEFAULT_AUDIENCE):当客户端没有命名任何资源时,用户灶台将自动生成。注册为受保护资源的API设置其资源URI。客户端ID不是受众:只根据客户端ID检查ID令牌(OIDC Core)。一个空 audience 抛出 ConfigurationError;一个令牌 aud 不包含预期的观众投掷 TokenAudienceError. JwksClient 选择相同的选项,并且 VerifyOptions.audience 凌驾于它之上 verify() 呼叫。
服务器端登录(使用PKCE的授权码)
beginLogin 使用新的PKCE验证器构建授权URL state。将两者都保存在服务器端会话中;回调时,勾选 state 并调用 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) 是 exchangeCode(code, redirectUri, { codeVerifier })。呼叫 exchangeCode 在您自己构建授权URL时直接使用。
发送 client_id 在form body中,加上 client_secret 当配置一个时。公共客户离开 clientSecret 并依赖于PKCE。
刷新令牌
const refreshed = await client.refreshTokens(tokens.refresh_token!);
// Store refreshed.refresh_token when present — Hearth rotates refresh tokens.
传递第二个参数以请求更窄的范围。
其他补助
| 方法 | 格兰特 |
|---|---|
clientCredentials(scope?) | 客户端凭证(RFC 6749§4.4) |
startDeviceFlow(scope?), pollDeviceToken(deviceCode, interval) | 设备授权(RFC 8628) |
requestMagicLink(email), exchangeMagicLink(token) | 无密码魔法链接(需要) realmId) |
每个令牌端点失败都会抛出 OAuthFlowError 与 statusCode 和OAuth errorCode (例如 invalid_grant). 网络故障或超时 statusCode 0.
浏览器应用程序
单页应用程序没有服务器会话来保存验证器。使用 createHearthAuth,或从中构建流 generateCodeVerifier, generateCodeChallenge 和 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()
访问令牌保留在内存中。刷新令牌、ID令牌和PKCE验证器 state 在飞行中登录到配置的 storage, sessionStorage 默认情况下(范围为一个选项卡,关闭时清除)。 "memory" 在登录重定向后无效。在创建时,facade被移除 hearth_refresh_token 和 hearth_id_token 从 localStorage 除非 localStorage 是已配置的存储。
RBAC的能力
返回的令牌 getToken() 在读取声明之前:针对领域JWKS的EdDSA签名(从 issuerUrl 一次,然后缓存),加上 exp, nbf, iat, iss (必须等于 issuerUrl), aud (必须包含 audience、默认 "hearth"). 当令牌不存在或未进行验证时,每个谓词都会解析 false. 与 sessionVersions 启用后,谓词可以拒绝 SessionVersionRevokedError 或者 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>
解决了 true 如果令牌验证并且它 permissions 要求包含 permission。将其用于特性门和API保护。
if (await hearth.hasPermission("docs.versions.read")) {
renderVersionHistory();
}
hasRole(role: string): Promise<boolean>
解决了 true 如果令牌验证并且它 roles 要求包含 role。对于UI个性化和粗粒度访问非常有用。
if (await hearth.hasRole("billing-admin")) {
renderBillingPanel();
}
inGroup(group: string): Promise<boolean>
解决了 true 如果令牌验证并且它 groups 声明包含组段塞。
if (await hearth.inGroup("engineering")) {
renderInternalToolingLink();
}
inOrg(org: string): Promise<boolean>
解决了 true 如果令牌验证并且它 oid claim等于给定的组织ID。
if (await hearth.inOrg("6f1d0c1e-3b9a-4c2e-9d7f-1a2b3c4d5e6f") /* the org's id */) {
renderAcmeContent();
}
client.permissions(): Promise<MePermissionsResponse>
调用 GET /v1/me/permissions 并返回 freshly-resolved 来自服务器的RBAC声明集。与上面的谓词不同,这反映了自JWT发布以来所做的任何角色/组分配。
const { roles, groups, permissions } = await hearth.client.permissions();
使用 client.permissions() 当您需要发布后的准确性时(例如,在管理操作之后)。对于其他检查,首选本地谓词—一旦缓存了JWKS,它们就不会进行网络调用。
反应集成
React钩子是从main中导出的 @hearth-auth/sdk 包中。不需要导入子路径。
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>
);
}
钩子返回 boolean。每个在每次渲染后运行facade的异步检查并返回最后的结果: false 直到检查解决,并且 false 当它拒绝的时候。所有钩子返回 false 当没有 HearthProvider 已挂载,使它们可以在没有提供程序的情况下安全地调用测试。
UserInfo和live权限
userinfo 调用被发现的 userinfo_endpoint。它返回由授予范围过滤的OIDC声明: sub 总是存在; name 需要 profile 范围; email 和 email_verified 需要 email.
const info = await client.userinfo(accessToken);
// info.sub, info.name?, info.email?, info.email_verified?, plus any other released claim
mePermissions 调用 GET /v1/me/permissions 并返回用户现在在服务器上的角色、组和权限,包括发出令牌后所做的更改。它需要 realmId.
const { roles, groups, permissions } = await client.mePermissions(accessToken);
Session-version饲料
svSnapshot 和 svDelta 读取会话版本提要(RFC HEA-930),该提要允许资源服务器查看会话撤销,而无需自省每个令牌。都需要 realmId 和一个服务令牌 hearth.sv_feed 范围。
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 为您运行此循环并检查令牌 sv 没有网络呼叫索赔。
JWKS和发现
// 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
缓存JWKS;在未知的地方 kid 在令牌被拒绝之前,它被再次获取一次。 client.jwksClient() 返回底层 JwksClient 如果你直接需要的话。
管理API
AdminClient 包装了 /admin/* 端点。的承载令牌构造它 hearth.admin 许可。空参数抛出 ConfigurationError.
import { AdminClient } from "@hearth-auth/sdk";
const admin = new AdminClient("https://hearth.example.com", "<realm-id>", accessToken);
每个list方法 { limit?, cursor? } 并返回 { items, next_cursor }。通过 next_cursor 在 cursor 直到它是 null。每个非2xx响应都会抛出 HearthError 与 status 和 body (解析JSON,或者正文不是JSON时的原始文本)。
用户
// 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 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>");
客户端、角色和组
createClient, getClient, updateClient, regenerateClientSecret, deleteClient, listClients角色和组的create/get/update/delete/list设置相同。
组织
// 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 和 UpdateOrganizationParams 是导出的类型。
生成客户端
路由和路径参数来自于从灶台的OpenAPI文档(src/generated/admin/schema.ts,通过 openapi-typescript我打电话过去 openapi-fetch). 用 make sdk-admin-gen 从存储库根;永远不要手工编辑。
错误处理
SDK本身引发的错误会扩展 HearthSdkError:
| 错误 | 当 |
|---|---|
ConfigurationError | 缺少必要的设置(clientId, realmId(发现端点)或 audience 是空的 |
DiscoveryError | 无法获取发现文档或发现文档无效 |
JWKSFetchError | 无法获取JWKS |
TokenVerificationError | 下面每个令牌失败的基类 |
TokenExpiredError, TokenNotYetValidError | exp / nbf 或者 iat 在时钟倾斜的窗外 |
TokenInvalidError | 错误的签名,错误的算法,畸形的JWT |
TokenIssuerError, TokenAudienceError | iss /不匹配 aud 不包含配置的 audience |
IntrospectionError | 自省请求失败或返回非json |
OAuthFlowError | 令牌、用户信息、权限或会话版本请求失败(statusCode, errorCode) |
AuthorizationModeMismatchError | 内省回应了一种模式 expectedMode |
RequiredActionError, SessionVersionRevokedError, SessionVersionCacheStaleError | 查看他们的文档评论 |
错误消息中的任何jwt形状的字符串都被替换为 [redacted],因此记录错误并不记录令牌。
AdminClient 和 HearthApiClient 扔 HearthError 关于非2xx的回应: status 为HTTP状态码, body 解析后的JSON(或原始文本)。
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;
}
开发引导(仅限开发)
引导端点创建领域、管理用户、会话、分配 realm.admin 角色,并返回令牌。它只在灶台运行时可用 --dev。在生产环境中,它返回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);
引用类型
// 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
}
故障排除
DiscoveryError ——验证 issuerUrl 是可到达的,并返回一个有效的 /.well-known/openid-configuration.
JWKSFetchError -检查到JWKS端点的网络连接。在返回此错误之前,SDK会在缓存丢失时重试一次。
TokenExpiredError -令牌的 exp 索赔已成为过去。刷新令牌或重新验证。
TokenInvalidError —JWT签名不匹配JWKS中的任何密钥。如果服务器最近轮换了密钥,SDK将自动重新获取一次;持续失败表示键不匹配。
TokenAudienceError -令牌的 aud 声明中不包含配置的 audience (默认 "hearth"). 集 audience 到发出令牌的资源URI;客户端ID不是受众。
AuthorizationModeMismatchError ——服务器回应道 access_token_authorization 模式
这与SDK不同 expectedMode 配置或 mode 传递给 requirePermission.
验证 OAuthClient admin设置匹配资源服务器的SDK配置。
看到 openspec /规格/ sdk-support-contract / spec.md 第5节给出了完整的错误分类。
权限下发方式(HEA-922/923)
灶台支持将RBAC数据传递到资源服务器的三种模式。选一个 注册OAuth客户端;SDK验证您保持一致。
嵌入式(默认)
RBAC申索(permissions, roles, groups)在发布时嵌入JWT中。这个
检查器首先验证令牌——EdDSA签名与领域的JWKS相对应 exp,
nbf, iat, iss 和 aud ——然后再读声明。的JWKS
缓存,因此在第一个请求之后,每次检查都没有网络流量。
不验证返回的令牌 false;检查者从不相信未经验证的人
有效载荷。
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);
注意安全。 在v1.1之前,这个检查器调用
decodeJwt相信一切 载荷说,所以alg: none令牌携带permissions: ["admin.write"]是 接受。如果你用的是旧版本,那就升级。
决策(每个请求服务器检查)
JWT只携带身份声明。SDK调用 POST /oauth/authorize 在每一张支票上。
失败关闭:任何网络或服务器错误返回 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);
内省(通过/内省实时RBAC)
JWT只携带身份声明。SDK调用 POST /introspect 并读取实时RBAC
响应。抛出 AuthorizationModeMismatchError 当服务器响应一个模式时
与中间件所期望的不同。
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);
设计约束SDK绝对不能从一种模式切换到另一种模式 是否
permissions存在于JWT中。这个mode必须始终显式设置。 缺少permissions声称在embedded模式表示用户没有权限,而不是 SDK应该尝试网络调用。
服务器中间件(Express和fasttify)
hearthMiddleware 和 hearthFastifyHook 验证每个请求上的承载令牌,应用可选的范围、角色和权限保护,并附加已验证的 Claims。两者都有相同的选择:
| 选项 | 意义 |
|---|---|
client | 这个 HearthClient 用…验证为每个进程创建一个缓存,以便共享JWKS缓存。 |
mode | "embedded", "introspection" 或者 "decision"。默认值: client.expectedMode,然后 "embedded". |
required | 默认的 true。当 false,没有令牌或未验证令牌的请求将在没有声明的情况下通过。 |
requiredScope, requiredRole | 在每个模式下根据已验证的JWT进行检查。 |
requiredPermission | 检查每 mode (见下文)。 |
organizationId, resource | 随决策模式发送 POST /oauth/authorize 呼叫。 |
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
回复:
| 情况 | 状态 |
|---|---|
没有不记名令牌 required),或者令牌不进行验证 | 401 |
token_type 是 required_action (即使 required 是 false) | 401 |
| 自省模式:令牌不再活动 | 401 |
| 缺少范围、角色或权限;决策模式否定;自省失败或响应其他模式 | 403 |
每个401都有 WWW-Authenticate: Bearer realm="hearth"。正文是JSON格式: { "error": "unauthorized" | "forbidden", "error_description": "..." }.
如何 requiredPermission 检查:
- 嵌入式 ——摘自
permissions已验证的JWT的声明。没有网络呼叫。没有声明的令牌没有权限;中间件永远不会退回到另一种模式。 - 自省 ——来自现场
permissions返回的POST /introspect。需要clientId和clientSecret在客户端。 - 决定 - - - - - -
POST /oauth/authorize决定;JWT声明被忽略。需要realmId在客户端。
不能服务于该模式的客户端使工厂抛出 ConfigurationError 在启动时,而不是在第一个请求时。
对于另一个框架,调用 authenticateRequest(authorizationHeader, options). 它返回 { ok: true, claims } 或者 { ok: false, status, headers, body } 寄给你。
Next.js
路由器API路由
// 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 的选项相同 hearthMiddleware。在401或403上,不会调用处理程序。
应用路由器路由处理程序
// 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 返回 null 当没有承载令牌时,令牌不进行验证,或者它是一个 required_action 令牌。
middleware.ts (边缘运行时)
// 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*"] };
卫兵决定 undefined 当请求可以继续时,或转换为401/403 JSON Response。它只使用 fetch 和Web Crypto,所以它在Edge Runtime上运行。在模块范围内创建它,以便发现文档和JWKS在隔离的生命周期内保持缓存。
Agent鉴权(M5)
灶台通过一组REST端点和OAuth扩展支持AI代理身份和授权。使与 agent_auth.capabilities.identity = true (加上 advanced = true (aat和交易令牌) hearth.yaml.
Agent CRUD + API密钥
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绑定令牌(RFC 9449)
将访问令牌绑定到EC密钥对,使其不能被令牌窃贼重放:
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令牌交换(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)
衰减授权令牌- AATs(阶段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,
});
交易令牌(单次使用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,
});
标准草案的跟踪
下面的IETF草案支持代理认证表面。复核汇票垫款的指定业主为 @therecluse26 (首席技术官)。当草案提交到RFC或新修订发布时,在 他叫- 1409.
| 草案 | 炉特性 | 检查时 |
|---|---|---|
draft-oauth-ai-agents-on-behalf-of-user-02 | 鄂博 on_behalf_of 索赔 | 新修订或RFC发布 |
draft-niyikiza-oauth-attenuating-agent-tokens | AAT发动机(/v1/aats) | 新修订或RFC发布 |
draft-oauth-transaction-tokens-for-agents | 交易令牌(/v1/transaction-tokens) | 新修订或RFC发布 |
draft-prakash-aip | 代理身份模型,代理卡 | 新修订或RFC发布 |
| OpenID社保基金/制定 | DPoP JKT黑名单+风险信号 | 当CAEP SSF规格稳定时 |