第三方后端服务对接 Herald
Herald 处理用户、登录、权限、计费,并签发 Bearer access/refresh token。你的后端只写业务逻辑,通过 Herald SDK 调用认证和计费接口。
Herald 处理用户、登录、权限、计费,并签发 Bearer access/refresh token。你的后端只写业务逻辑,通过 Herald SDK 调用认证和计费接口。
用户浏览器 → 你的前端 → 你的后端 → Herald SDK → Herald 服务
↓ (校验 token、查权限、扣积分)
OAuth 跳转 → Herald 登录页 → 回调你的后端 → 把 Bearer token 返回给前端Herald 不会设置任何 Cookie。OAuth 回调之后,你的后端把 token 集合以 JSON 形式返回给前端;前端自己持有(access token 存内存,refresh token 存 localStorage),并在每次请求时带上 Authorization: Bearer。你的后端不直连 Herald 的数据库,所有交互走 HTTP API,SDK 内部带缓存。
想要完整可运行的示例?herald-app-example 是一个全 AI 开发、集成了 Herald 认证的 Flutter App。
1. 前置准备
在 Herald 管理后台完成以下操作,不需要写代码:
- 创建一个 realm(租户),记下
realm_id - 在 realm 下创建一个 client app(代表你的服务),记下
client_id - 为这个 realm 生成一个 API Key,保存密钥值——只显示一次,丢了得重新生成。生成时选择对应的 Client App;不选择时默认绑定
admin-api-client - 定义权限点。权限格式是
resource:action,比如product:read、device:manage - 创建角色,把权限点分配给角色
- 创建一个管理员用户,把角色分配给这个用户
第 4 步的权限点怎么设计,设计权限模型 会讲。
2. 后端集成
配置项
在你的服务配置文件中加一个 [herald] 段:
[herald]
base_url = "http://127.0.0.1:13000"
api_key = "sk-your-api-key"
realm_id = "my-app"
client_id = "admin-web-console"| 字段 | 说明 |
|---|---|
base_url | Herald 服务地址,Docker 网络内用容器名 |
api_key | 在 Herald 管理端生成的 API Key |
realm_id | 你的服务所属的 realm |
client_id | 前置准备中创建的 client app 标识 |
配置结构体用 Option<HeraldConfig> 包裹,不配置时整个认证体系不启用:
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HeraldConfig {
pub base_url: String,
pub api_key: String,
pub realm_id: String,
pub client_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Config {
// ... 其他配置
#[serde(default)]
pub herald: Option<HeraldConfig>,
}安装 SDK
在 Cargo.toml 中添加:
[dependencies]
herald-sdk = "0.5"后端是 TypeScript?看下面的 Node.js SDK 一节,npm 上有同名包,方法和这个 crate 一一对应。
初始化
启动时根据配置决定是否创建 SDK 客户端。Option<Arc<Client>> 为 None 时,后续路由不会挂认证中间件:
let herald_client = config.herald.as_ref().map(|herald| {
Arc::new(herald_sdk::Client::new(
herald.base_url.clone(),
herald.api_key.clone(),
None, // 使用默认 5 分钟缓存
))
});第三个参数是缓存时间,None 表示默认 300 秒。SDK 会在 token 过期时自动失效缓存条目。
API Key 是你的后端跟 Herald 之间的身份凭证,存在配置文件或环境变量里,不要硬编码。API Key 同时带有 Client App 范围:绑定 admin-api-client 的 Key 可以访问该 realm 下所有 Client App 资源;绑定普通 Client App 的 Key 只能访问该 Client App 的权限检查、订阅和积分资源。API Key 用于后端到 Herald 的机器调用(SDK),跟 OAuth 返回的用户 Bearer token 是两回事。
Auth Config 端点
前端需要知道 Herald 是否启用。提供一个公开端点:
#[derive(Serialize)]
pub struct AuthConfigResponse {
pub enabled: bool,
pub login_url: Option<String>,
pub herald_login_url: Option<String>,
}
// GET /api/auth/config
pub async fn get_auth_config(State(state): State<Arc<AppState>>) -> Json<AuthConfigResponse> {
let herald = &state.config.herald;
Json(AuthConfigResponse {
enabled: herald.is_some(),
login_url: herald.as_ref().map(|_| "/api/auth/oauth/start".to_string()),
herald_login_url: herald.as_ref().map(|h| {
format!("{}/{}/auth/login", h.base_url.trim_end_matches('/'), h.realm_id)
}),
})
}前端启动时调一次这个接口。enabled: false 时跳过所有认证逻辑,直接渲染页面。
OAuth 后端处理器
SPA 前端用 OAuth 2.1 Authorization Code + PKCE 流程登录。你的后端是一个 BFF(backend-for-frontend):它跑 code 换 token 的交换(code_verifier 留在后端),然后把 token 集合以 JSON 返回给前端。token 由前端持有,你的后端不为浏览器存任何 Herald 会话。
这里涉及的 Herald 端点:
- 授权:
GET /api/oauth/{realm_id}/authorize?client_id=&redirect_uri=&state=&response_type=code&code_challenge=&code_challenge_method=S256 - token:
POST /api/oauth/{realm_id}/token,body 为{grant_type:"authorization_code", code, redirect_uri, client_id, code_verifier},返回{access_token, refresh_token, token_type:"Bearer", expires_in}
需要两个处理器:oauth_start 负责发起登录,oauth_callback 负责接收回调并把 token 交给前端。
oauth_start:发起登录
// GET /api/auth/oauth/start?redirect=/devices
pub async fn oauth_start(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<OAuthStartQuery>,
) -> Result<impl IntoResponse, ApiError> {
let herald = state.config.herald.as_ref()?;
let (app_origin, return_to) = resolve_app_origin_and_return_to(&headers, query.redirect)?;
let redirect_uri = format!("{app_origin}/api/auth/oauth/callback");
let oauth_state = random_token(32);
let code_verifier = random_token(64);
let code_challenge = pkce_challenge(&code_verifier);
let authorize_url = format!(
"{}/api/oauth/{}/authorize?client_id={}&redirect_uri={}&state={}&response_type=code&code_challenge={}&code_challenge_method=S256",
herald.base_url.trim_end_matches('/'),
herald.realm_id,
herald.client_id,
urlencoding::encode(&redirect_uri),
oauth_state,
code_challenge,
);
// 只把临时的 OAuth/PKCE 状态存在一个短期 Cookie 里。
// 这个 Cookie 存的是 {state, code_verifier, return_to, redirect_uri}——它不是
// Herald 会话,也不含任何 access/refresh token。交换完成后会清除。
let oauth_cookie = encode_oauth_cookie(&OAuthCookie {
state: oauth_state,
code_verifier,
return_to,
redirect_uri,
});
Ok((
StatusCode::FOUND,
[
(header::LOCATION, authorize_url),
(header::SET_COOKIE, build_cookie("APP_OAUTH", &oauth_cookie, 300)),
],
))
}这个处理器做了四件事:
- 生成
code_verifier(随机字符串)和code_challenge(SHA256 哈希后 Base64url 编码) - 构造 Herald 授权 URL,把用户重定向过去
- 把
{state, code_verifier, return_to, redirect_uri}存到一个短期APP_OAUTHCookie 里。这只是 PKCE/OAuth 状态——不是会话,也不是 token return_to记录用户原始页面,回调时用
oauth_callback:换 token,把 token 返回给前端
// GET /api/auth/oauth/callback?code=xxx&state=yyy
//
// 两件事:(1)在后端做 code 换 token,
// (2)把得到的 token 集合以 JSON 形式交给前端。不设置任何 Herald Cookie。
pub async fn oauth_callback(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<OAuthCallbackQuery>,
) -> Result<impl IntoResponse, ApiError> {
let herald = state.config.herald.as_ref()?;
// 1. 从 APP_OAUTH Cookie 取出临时 PKCE 状态,校验 state(防 CSRF)
let oauth_cookie_value = get_cookie(&headers, "APP_OAUTH").ok_or(ApiError::unauthorized)?;
let oauth_cookie = decode_oauth_cookie(&oauth_cookie_value)?;
if oauth_cookie.state != query.state {
return Err(ApiError::unauthorized());
}
// 2. 用授权码 + code_verifier 换 token 集合
let token = exchange_oauth_code(
herald.base_url.trim_end_matches('/'),
&herald.realm_id,
&herald.client_id,
&query.code,
&oauth_cookie.redirect_uri,
&oauth_cookie.code_verifier,
).await?;
// 3. 把 token 交给前端。两种常见形态:
// (a) 带 SPA 可读的短期 query fragment 重定向回 SPA,或
// (b) 服务端返回同源的 HTML/JSON 让 SPA 读取。
// 下面用形态 (b):返回 SPA 在回调路由里 fetch 的 JSON。
let mut response_headers = HeaderMap::new();
// 交换已完成,清除临时 PKCE 状态 Cookie。
response_headers.append(
header::SET_COOKIE,
HeaderValue::from_str(&clear_cookie("APP_OAUTH"))?,
);
Ok((
StatusCode::OK,
response_headers,
Json(TokenResponse {
access_token: token.access_token,
refresh_token: token.refresh_token,
token_type: "Bearer".to_string(),
expires_in: token.expires_in,
}),
))
}
#[derive(Serialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
token_type: String,
expires_in: u64,
}exchange_oauth_code 向 Herald 的 token 端点发 POST 请求,返回完整 token 集合:
async fn exchange_oauth_code(
herald_base_url: &str, realm_id: &str, client_id: &str,
code: &str, redirect_uri: &str, code_verifier: &str,
) -> Result<TokenSet, ApiError> {
let url = format!("{herald_base_url}/api/oauth/{realm_id}/token");
let response = reqwest::Client::new()
.post(url)
.json(&serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": code_verifier,
}))
.send().await?
.json::<TokenSet>()
.await?;
// TokenSet = { access_token, refresh_token, token_type: "Bearer", expires_in }
Ok(response)
}PKCE 的安全性在于 code_verifier 只存在你的后端的临时 PKCE 状态 Cookie 里,不经过前端 URL。授权码是一次性的——Herald 用 GETDEL 原子操作读取并删除,同一个 code 换第二次会报错。
Herald 在 JSON 响应里返回 token;你的后端把这份 JSON 转发给前端。前端把 access token 存内存、refresh token 存 localStorage,并在每次调你后端 API 时带 Authorization: Bearer <access_token>。Herald 本身不在任何地方设置 Cookie。
临时 PKCE 状态 Cookie 辅助函数
这些辅助函数只处理授权→回调跳转期间那个短期的 APP_OAUTH Cookie,里面装的是 OAuth/PKCE 状态。它们不用于任何认证会话——这个模型里没有会话 Cookie。
// 授权→回调跳转期间存放临时 OAuth/PKCE 状态的短期 Cookie。
// 在 oauth_callback 里 code 交换完成后被清除。不含任何 token。
fn build_cookie(name: &str, value: &str, max_age_seconds: i64) -> String {
format!("{name}={value}; Path=/; Max-Age={max_age_seconds}; HttpOnly; SameSite=Lax")
}
fn clear_cookie(name: &str) -> String {
format!("{name}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
}
fn get_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let cookies = headers.get(header::COOKIE)?.to_str().ok()?;
cookies.split(';').find_map(|cookie| {
let (cookie_name, value) = cookie.trim().split_once('=')?;
(cookie_name == name && !value.is_empty()).then(|| value.to_string())
})
}
fn pkce_challenge(code_verifier: &str) -> String {
let digest = sha2::Sha256::digest(code_verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}Token 刷新端点
前端的 access token 过期时,它调你后端的刷新端点;后端用 grant_type=refresh_token 把 refresh token 转发给 Herald 的 token 端点。Herald 校验后签发新的 access token 和轮换后的 refresh token,并做复用检测:如果再次提交已经轮换过的 refresh token,Herald 会吊销整个 refresh token 家族。
// POST /api/auth/refresh body: { refresh_token: string }
pub async fn refresh_token(
State(state): State<Arc<AppState>>,
Json(body): Json<RefreshRequest>,
) -> Result<Json<TokenResponse>, ApiError> {
let herald = state.config.herald.as_ref()?;
let url = format!("{}/api/oauth/{}/token", herald.base_url.trim_end_matches('/'), herald.realm_id);
let token: TokenSet = reqwest::Client::new()
.post(url)
.json(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": body.refresh_token,
"client_id": herald.client_id,
}))
.send().await?
.json().await?;
Ok(Json(TokenResponse {
access_token: token.access_token,
refresh_token: token.refresh_token,
token_type: token.token_type,
expires_in: token.expires_in,
}))
}刷新失败(refresh token 无效/过期/复用)时返回 401;前端清掉本地存的 token 并跳转登录。
认证中间件
中间件做三件事:从 Authorization: Bearer 头里提取 token、把请求路径映射成权限规则、调 Herald 校验。
use axum::extract::State;
use axum::http::{Method, Request, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use herald_sdk::{Client, Error, PermissionCheckRequest, Rule};
use std::sync::Arc;
#[derive(Clone)]
pub struct HeraldAuthState {
pub herald_sdk: Arc<Client>,
pub client_id: Arc<str>,
}
pub async fn herald_auth_middleware(
State(auth_state): State<HeraldAuthState>,
mut request: Request,
next: Next,
) -> Response {
// 1. 从 Authorization: Bearer 头里提取 token
let Some(token) = extract_auth_token(&request) else {
return ApiError::unauthorized().into_response();
};
// 2. 根据请求路径生成权限规则
let Some(rule) = extract_permission(request.uri().path(), request.method()) else {
return ApiError::forbidden().into_response();
};
// 3. 调 Herald 校验
let response = auth_state.herald_sdk
.check_permission(PermissionCheckRequest {
token,
rules: Some(vec![rule]),
client_id: auth_state.client_id.to_string(),
})
.await;
match response {
// 允许:把 user_id 注入请求扩展
Ok(permission) if permission.allowed => {
let Some(user_id) = permission.user_id else {
return ApiError::unauthorized().into_response();
};
request.extensions_mut().insert(CurrentUser { user_id });
next.run(request).await
}
// token 无效/缺失:allowed=false, user_id=None
Ok(permission) if permission.user_id.is_none() => {
ApiError::unauthorized().into_response()
}
// 用户已认证但没有权限:allowed=false, user_id=Some(...)
Ok(_) => ApiError::forbidden().into_response(),
// Herald 返回错误:区分 401/403/其他
Err(error) => classify_auth_error(&error).into_response(),
}
}
fn classify_auth_error(error: &Error) -> ApiError {
match error {
Error::Unauthorized(_) => ApiError::unauthorized(),
Error::Forbidden(_) => ApiError::forbidden(),
_ => ApiError::service_unavailable("auth service unavailable"),
}
}
// 从 Authorization 头读取 Bearer token。不涉及任何 Cookie。
fn extract_auth_token(request: &Request) -> Option<String> {
let header_value = request.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
let token = header_value.strip_prefix("Bearer ")?.trim();
(!token.is_empty()).then(|| token.to_string())
}三种拒绝场景的处理逻辑不同:
| 情况 | Herald 返回 | 你的后端返回 | 前端行为 |
|---|---|---|---|
| Bearer token 缺失或无效 | allowed=false, user_id=None | 401 | 尝试刷新;刷新失败则跳转登录 |
| 用户已认证但没权限 | allowed=false, user_id=Some(...) | 403 | 显示无权限提示 |
| Herald 服务不可用 | 网络错误或 500 | 503 | 提示服务暂时不可用 |
503 而不是放行——认证服务挂掉时继续处理请求等于绕过了认证。
设计权限模型
权限分两个维度:资源(resource)和操作(action)。你自己定义,Herald 只管存储和校验。
路径到权限的映射在中间件里做。这段代码和 Herald 没有关系——Herald 只回答"这个用户能不能做 product:read",路径映射是你的中间件决定的。
pub fn extract_permission(path: &str, method: &Method) -> Option<Rule> {
// Strip /api prefix if present — the middleware runs inside a nest("/api", ...)
let path = path.strip_prefix("/api").unwrap_or(path);
let resource = if path.starts_with("/admin/product")
|| path.starts_with("/admin/valid")
|| path.starts_with("/admin/file")
{
"product"
} else if path.starts_with("/admin/device")
|| path.starts_with("/admin/property")
|| path.starts_with("/admin/event")
|| path.starts_with("/admin/alarm-rule")
|| path.starts_with("/admin/alarm")
{
"device"
} else if path.starts_with("/admin/ca") || path.starts_with("/admin/ota") {
"cert"
} else {
return None;
};
let action = match *method {
Method::GET => "read",
Method::POST | Method::PUT | Method::PATCH | Method::DELETE => "write",
_ => return None,
};
Some(Rule {
resource: resource.to_string(),
action: action.to_string(),
})
}路径映射设计好之后,去 Herald 管理后台把 product:read、product:write、device:read、device:write 配成权限点,创建角色,给用户分配角色。
Herald 内置了 action 层级。manage 覆盖 view、create 和 manage 本身。create 只覆盖 create。view 只覆盖 view。自定义 action(比如 admin)只匹配自身,不参与层级。所以如果你给用户分配了 product:manage,中间件请求 product:read 时也会通过。
路由挂载
路由分三组:auth 端点、公开路由、需要认证的管理路由。
pub fn create_router(
config: Arc<Config>,
herald_client: Option<Arc<herald_sdk::Client>>,
) -> Router {
// auth 端点:公开访问,不需要 Herald 认证
let auth_routes = Router::new()
.route("/auth/config", get(get_auth_config))
.route("/auth/oauth/start", get(oauth_start))
.route("/auth/oauth/callback", get(oauth_callback))
.route("/auth/refresh", post(refresh_token));
// 公开路由:健康检查、webhook 等
let public_routes = Router::new()
.route("/health", get(health_check))
.route("/webhook/device", post(device_webhook));
// 管理路由:需要 Herald 认证
let admin_routes = Router::new()
.route("/admin/product", get(list_products).post(create_product))
.route("/admin/device", get(list_devices));
// 条件挂载:只在 Herald 配置存在时加认证中间件
let admin_routes = match (config.herald.as_ref(), herald_client) {
(Some(herald_config), Some(herald_sdk)) => {
admin_routes.layer(axum::middleware::from_fn_with_state(
HeraldAuthState {
herald_sdk,
client_id: herald_config.client_id.clone().into(),
},
herald_auth_middleware,
))
}
(_, _) => admin_routes,
};
Router::new()
.merge(auth_routes)
.merge(public_routes)
.merge(admin_routes)
}match 分支是关键。不配置 [herald] 时,herald_client 为 None,admin 路由不加中间件,所有管理接口直接可访问。本地开发或内网隔离环境下不用配置 Herald。
3. 前端集成
前端负责 token 存储,并在每次请求带 Authorization: Bearer。它要处理四件事:探测认证状态、登录后持有 token、附加 Bearer 头、401 时刷新。这一节用原生 fetch 手写这些管线。另一种前端形态是浏览器从你自己的登录页直接调 Herald,那种形态有专门的 SDK,见 浏览器 SDK (herald-auth-web) 和 White-label / 自定义用户 UI。
探测认证状态
interface AuthConfig {
enabled: boolean
login_url: string | null
herald_login_url?: string | null
}
let cachedAuthConfig: AuthConfig | null = null
async function getAuthConfig(): Promise<AuthConfig> {
if (cachedAuthConfig) return cachedAuthConfig
const res = await fetch('/api/auth/config')
cachedAuthConfig = await res.json()
return cachedAuthConfig
}启动时调一次 /api/auth/config。enabled: false 时跳过后续认证逻辑。
Token 存储
OAuth 回调返回 token 集合后,把 access token 存内存(模块级变量),refresh token 存 localStorage。access token 不要进 localStorage——刷新页面就丢,这是有意为之,缩小暴露面。
// 内存里的 access token;刷新页面就丢,正合意图。
let accessToken: string | null = null
const REFRESH_KEY = 'herald.refresh_token'
interface TokenSet {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
// 在 OAuth 回调路由 fetch 完 /api/auth/oauth/callback 后调用。
function storeTokens(tokens: TokenSet): void {
accessToken = tokens.access_token
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
}
function getAccessToken(): string | null {
return accessToken
}
function clearTokens(): void {
accessToken = null
localStorage.removeItem(REFRESH_KEY)
}
// 启动时:如果已有 refresh token,主动刷一次拿新的 access token。
async function bootstrapTokens(): Promise<void> {
const refresh = localStorage.getItem(REFRESH_KEY)
if (!refresh) return
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) throw new Error('refresh failed')
storeTokens(await res.json())
} catch {
clearTokens()
}
}附加 Bearer 头 + 401 刷新
一个 fetch 包装函数在每次请求注入 Authorization: Bearer。遇到 401 时做一次刷新(轮换 refresh token),然后用新 token 重试一次原请求。刷新失败则清掉 token 并跳转登录。
let refreshing: Promise<boolean> | null = null
async function refreshAccessToken(): Promise<boolean> {
if (refreshing) return refreshing
const refresh = localStorage.getItem(REFRESH_KEY)
if (!refresh) return false
refreshing = (async () => {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) return false
storeTokens(await res.json())
return true
} catch {
return false
} finally {
refreshing = null
}
})()
return refreshing
}
export async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const token = getAccessToken()
const headers = new Headers(init.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
let response = await fetch(input, { ...init, headers })
if (response.status !== 401) return response
// 401:刷新一次,然后用新 token 重试一次。
const ok = await refreshAccessToken()
if (!ok) {
clearTokens()
handle401()
return response
}
const newToken = getAccessToken()
if (newToken) headers.set('Authorization', `Bearer ${newToken}`)
return fetch(input, { ...init, headers })
}refresh token 轮换 + 复用检测:每次成功刷新都返回新的 refresh token 并使旧的失效。如果被盗/旧的 refresh token 被重放,Herald 检测到复用会吊销整个家族,于是下一次刷新会失败,前端清掉 token 并跳转登录。
检查登录状态
export async function checkAuth(): Promise<boolean> {
const config = await getAuthConfig()
if (!config.enabled) return true
// 通过带 Bearer 头的包装函数,用一个轻量级管理接口探测登录状态。
const response = await authedFetch('/api/admin/product?page=1&page_size=1')
return response.status !== 401
}不是专门调一个"检查登录"接口,而是用一个实际的管理接口做探测。省掉一个额外的 API。
401 处理
let isRedirecting = false
export function handle401(): void {
if (isRedirecting) return
isRedirecting = true
const loginUrl = new URL(cachedAuthConfig?.login_url || '/', window.location.origin)
loginUrl.searchParams.set('redirect', window.location.href)
window.location.href = loginUrl.toString()
}isRedirecting 防止多个并发 401 触发多次跳转。跳转 URL 带上当前页面地址,OAuth 回调后会重定向回来。
如果你用 axios 之类的客户端而不是 fetch 包装,把同样的逻辑接进它的请求拦截器(注入 Authorization: Bearer)和响应拦截器(401 时刷新一次、重试一次,否则 handle401)。
Google One Tap
Google One Tap 让已登录 Google 的用户在你自己的页面上一键登录,全程不跳转到 Herald 或 Google 登录页。Herald 在服务端校验 Google ID Token(签名、签发者、受众、过期时间),并签发与跳转式登录完全一致的 Bearer token 家族,因此 One Tap 与跳转式登录会落到同一个 Herald 账号上。集成分六步:
- 从
GET /api/public-config/{realmId}读取 Google 的clientId——在oauthProviders数组里取name === "google"的条目,使用其clientId。如果你的页面已经为登录表单加载了 public-config,直接复用其中缓存的clientId,不必再次请求。 - 加载 GIS SDK(
https://accounts.google.com/gsi/client),调用google.accounts.id.initialize({ client_id: clientId, callback })。 - 调用
google.accounts.id.prompt()弹出浮层。 - 用户选择账号后,Google 调用
callback并传入credential字段(即 ID Token JWT)。 POST /api/oauth/{realmId}/google/one-tap,body 为{ credential, clientId, downstreamState? }。注意realmId是路径参数,不在 body 中。- 按响应分支处理:
- 直接会话模式(不带
downstreamState):响应是 Bearer token 集合。用 Token 存储 里的storeTokens写入——响应用的是 camelCase 键(accessToken、…),需适配成 helper 期望的 snake_caseTokenSet(见下方代码)。 - 下游授权码模式(带
downstreamState):响应为{ redirectUri },含?code=ac_...&state=...。按第 2 节的 Code+PKCE 流程继续(oauth_callback/exchange_oauth_code)。
- 直接会话模式(不带
// 1. 从 Herald 公共配置中读取 Google clientId。
async function getGoogleClientId(realmId: string): Promise<string> {
const res = await fetch(`/api/public-config/${realmId}`)
const config = await res.json()
const google = config.oauthProviders.find(
(p: { name: string; clientId?: string }) => p.name === 'google',
)
if (!google?.clientId) throw new Error('Google provider not configured')
return google.clientId
}
// GIS SDK 形态(由下面的 <script> 标签加载)。
declare const google: {
accounts: {
id: {
initialize: (config: {
client_id: string
callback: (response: { credential: string }) => void
}) => void
prompt: () => void
}
}
}
// 2-4. 初始化 SDK 并弹出 One Tap 浮层。
export function startGoogleOneTap(
realmId: string,
clientId: string,
downstreamState?: string,
): void {
google.accounts.id.initialize({
client_id: clientId,
callback: (response) => {
// 5-6. 把 ID Token 发给 Herald 并处理响应。
void sendOneTapCredential(realmId, clientId, response.credential, downstreamState)
},
})
google.accounts.id.prompt() // 3. 弹出浮层
}
async function sendOneTapCredential(
realmId: string,
clientId: string,
credential: string,
downstreamState?: string,
): Promise<void> {
const res = await fetch(`/api/oauth/${realmId}/google/one-tap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, clientId, downstreamState }),
})
if (!res.ok) throw new Error('Google One Tap login failed')
const data = await res.json()
if (data.redirectUri) {
// 下游授权码模式:交给 Code+PKCE 流程继续处理。
window.location.href = data.redirectUri as string
} else {
// 直接会话模式:把 camelCase 的 One Tap 响应适配为 snake_case 的 TokenSet。
storeTokens({
access_token: data.accessToken,
refresh_token: data.refreshToken,
token_type: data.tokenType,
expires_in: data.expiresIn,
})
}
}加载 SDK 只需一个 script 标签(GIS 不是 npm 包):
<script src="https://accounts.google.com/gsi/client" async defer></script>集成方职责与降级:
- 在 Google Cloud Console 的 OAuth 客户端 Authorized JavaScript origins 中登记每一个运行此代码的源。不登记的话 GIS SDK 会拒绝加载、浮层不弹出——这是集成方的职责,不由 Herald 负责。
- 如果用户未登录 Google 或源未授权,浮层不会出现。这是正常降级——始终保留一个常规登录入口(OAuth 跳转流程)与 One Tap 并存。
- token 持有方式与本节其余部分一致:access token 存内存,refresh token 存
localStorage,authedFetch负责附加 Bearer 头并在 401 时刷新。One Tap 只是换了一个获取首份 token 集合的入口。 - 实际响应形态以后端 OpenAPI 为准。不带
downstreamState时后端返回直接会话 token 集合(accessToken/refreshToken/userId/expiresIn/refreshExpiresIn/tokenType,对应OneTapDirectResponse);带downstreamState时返回{ redirectUri }(OneTapCodeResponse)。请以 OpenAPI 文档中端点声明的实际 200 响应为准。
4. 通过 SDK 管理资源
API Key 在 Herald 中也是一种 Principal(主体),跟用户一样可以分配角色和权限。你给 API Key 分配了什么权限,通过这个 Key 发出的 SDK 调用就能做什么。权限受 Client App 范围限制。
管理 Realm
let realm = herald_client.create_realm(CreateRealmSdkRequest {
name: "my-app".to_string(),
description: Some("我的应用".to_string()),
admin_user: AdminUserSdkInput {
email: "admin@example.com".to_string(),
password: "secure-password".to_string(),
},
}).await?;
let realms = herald_client.list_realms().await?;
let realm = herald_client.get_realm("my-realm").await?;管理用户
let user = herald_client.create_user("my-realm", CreateUserSdkRequest {
email: "user@example.com".to_string(),
password: "secure-password".to_string(),
nickname: Some("johndoe".to_string()),
}).await?;
let users = herald_client.list_users("my-realm").await?;
let user = herald_client.get_user("my-realm", &user_id).await?;管理 Client App
let app = herald_client.create_client_app("my-realm", CreateClientAppSdkRequest {
name: "Mobile App".to_string(),
description: Some("iOS and Android app".to_string()),
redirect_uris: vec!["https://app.example.com/callback".to_string()],
}).await?;
let apps = herald_client.list_client_apps("my-realm").await?;
let app = herald_client.get_client_app("my-realm", "my-mobile-app").await?;建议给后端使用绑定该 Client App 的 API Key;只有需要跨 Client App 管理时才用 admin-api-client Key。
5. 积分系统
如果你的服务按量计费(比如 AI API 调用次数),用 Herald 的积分系统。
// 查余额
let balance = herald_client.get_balance("my-realm", &user_id).await?;
println!("余额: {} {}", balance.balance, balance.unit);
// 扣积分
let result = herald_client.consume_points(
"my-realm",
&user_id,
"my-client-app",
100,
Some("AI API 调用".to_string()),
Some("unique-request-id".to_string()),
).await?;
println!("扣费后余额: {}", result.balance_after);idempotency_key 建议每次请求都传。网络超时重试时,相同的 key 不会重复扣费。如果 SDK 使用普通 Client App API Key,client_app_id 必须是该 Key 绑定的 Client App。
扣减发生在哪个积分账户由 Herald 根据 client_app_id 自动决定:系统找到所有覆盖这个 Client App 的积分账户池,按过期时间从近到远跨池扣减。你的后端不用关心积分账户,但要知道同一个用户在不同 Client App 下的可用余额可能不同,取决于哪些积分账户覆盖了那个应用。积分账户的背景见计费架构。
余额是 Herald 实时从积分账本派生计算的,不含尚未到生效时间的预发积分——你查到的余额就是当前可消费的额度。
6. 订阅系统
查询 Client App 的订阅状态:
let sub = herald_client.get_subscription("my-realm", "my-client-app").await?;
if sub.status == "active" {
// 用户有付费订阅
}订阅权益由支付平台产品同步到 Herald 的 entitlement_key。支付流程由 Herald 和支付平台处理,你的后端只需要调 SDK 查订阅状态和当前权益。
7. Node.js SDK (TypeScript)
Rust crate 有一个 TypeScript 对应版本,发布在 npm 上,包名同样是 herald-sdk(源码在 sdk/node)。它是 1:1 移植:方法名换成 camelCase,缓存行为一致,线上类型与 crate 的公开结构体对应。零运行时依赖,直接用原生 fetch(Node 18+),ESM 和 CommonJS 双格式构建。后端是 Node 而不是 Rust 时,第 2、4、5、6 节里所有走 herald_sdk::Client 的调用,这里都有同名方法。
安装
npm install herald-sdk初始化
import { HeraldClient } from 'herald-sdk'
const client = new HeraldClient(
'http://127.0.0.1:13000', // base URL,与 Rust 配置里的 [herald].base_url 同值
'sk-your-api-key', // realm API Key,以 X-API-Key 头发送
300, // 权限缓存 TTL 秒数;默认 300
)每个调用都以 X-API-Key 头携带 API Key,访问 Herald 的外部 API(/api/ext/*)。第三个构造参数是权限缓存 TTL,对应 Rust Client::new 的第三个参数。
权限检查
const resp = await client.checkPermission({
accessToken: browserToken, // 前端转发过来的 Bearer token
clientId: 'admin-web-console',
rules: [{ resource: 'product', action: 'read' }],
})
if (resp.allowed) {
// resp.userId 是 Herald 用户 id
}缓存行为与 Rust crate 一致:按完整请求(token + clientId + rules,规则顺序参与区分)缓存结果,TTL 内命中缓存;一个 token 超过 5 分钟没被检查过,下次检查前先清掉它的缓存条目。你在 Herald 里改了用户权限、希望下次检查落到服务端时,主动清掉该 token 的缓存:
client.invalidateCache(browserToken)计费与资源管理
订阅、积分、管理类调用与第 4-6 节一一对应,换成 camelCase:
// 订阅状态(第 6 节)
const sub = await client.getSubscription('my-realm', 'my-client-app')
// 积分(第 5 节)
const balance = await client.getBalance('my-realm', userId)
const result = await client.consumePoints(
'my-realm', userId, 'my-client-app',
100, 'AI API 调用', 'unique-request-id',
)
console.log(result.transactions[0].balanceAfter)
// 发放积分必须指定明确的 Credit Bucket(第 5 节)
await client.grantPoints('my-realm', userId, 'bucket-id', 500, 'welcome bonus')
// Realm / 用户 / Client App 管理(第 4 节)
const realm = await client.createRealm({
name: 'my-app',
description: '我的应用',
adminUser: { email: 'admin@example.com', password: 'secure-password' },
})
const apps = await client.listClientApps('my-realm')扣减结果里每个受影响的 Credit Bucket 对应一条 transaction(单池时长度为 1)。第 5 节的幂等规则原样适用:consume 每次都传幂等 key,扣哪个账户由 clientAppId 决定。
错误处理
调用失败抛 HeraldSdkError,带稳定的 code:
import { HeraldSdkError } from 'herald-sdk'
try {
await client.getBalance('my-realm', userId)
} catch (error) {
if (error instanceof HeraldSdkError && error.code === 'forbidden') {
// 跨 realm 访问或权限不足
}
}code 取值:unauthorized、forbidden、not-found、internal-server-error、api-error、network、parse。收到过响应时,HTTP status 和原始 body 会挂在错误上。
8. 浏览器 SDK (herald-auth-web)
第 2、3 节假设的是 BFF 形态:浏览器只跟你的后端通信。另一种形态是浏览器从你自己的登录页直接调 Herald,即 White-label / 自定义用户 UI 讲的场景。这种形态有专门的 SDK:herald-auth-web(源码在 sdk/web),框架无关,零运行时依赖(原生 fetch、WebCrypto、localStorage)。它把凭证生命周期整个包下来:注册、触发邮箱验证、请求重置密码、登录(带 TOTP / passkey 第二因素)、免密邮箱 OTP、静默刷新 access token、登出、查状态。
SDK 包装的是直接签发的 CustomUserUi 凭证类(POST /api/auth/{realmId}/login)。它不执行第 2 节的 PKCE 交换;给 login 传 OAuth 上下文时,后端回答 redirectTo,后续交换由你的代码自己完成。
安装
npm install herald-auth-web包是 ESM-only,面向打包器。没有构建步骤的页面可以用发布的 IIFE 压缩包,暴露 Herald 全局变量:
<script src="https://unpkg.com/herald-auth-web"></script>
<script>
const client = Herald.createHeraldClient({
baseUrl: 'https://auth.example.com',
realmId: '<your-realm>',
clientId: '<your-client-app>',
})
</script>先登记页面来源
Herald 的 CORS 策略把请求来源与 Client App 的 allowed_origins 精确匹配。接 SDK 之前,先把页面来源(协议 + 主机 + 端口,如 https://app.example.com)加到控制台里。来源没登记时表现为 HeraldError { kind: 'network' }:浏览器区分不了 CORS 拒绝和普通网络失败。
创建客户端并登录
import { createHeraldClient } from 'herald-auth-web'
const client = createHeraldClient({
baseUrl: 'https://auth.example.com',
realmId: 'my-realm',
clientId: 'my-client-app',
onSessionChange: (event) => {
if (event.type === 'session-expired') {
// 跳转到你的登录页
}
},
})会话事件有 authenticated、session-expired、logged-out 三种。不想用事件可以轮询:await client.getStatus() 返回会话快照(authenticated、userId、permissions、scopes);await client.logout() 清掉 token 并在服务端结束会话。
login 对控制流结果返回可判别的联合类型,不靠抛异常:
const result = await client.login({ email: 'user@example.com', password: '••••••••' })
switch (result.kind) {
case 'success':
// 已登录;result.session
break
case 'requires-second-factor': {
// result.secondFactors 是 ['totp', 'passkey'] 的子集;result.tempToken
const final = await client.verifyTotp({ tempToken: result.tempToken, code: '123456' })
break
}
case 'consent-required':
// 渲染 result.agreements,然后带着它们重调 login:
// await client.login({ email, password, agreements: result.agreements })
break
case 'oauth-redirect':
window.location.href = result.redirectTo
break
}verifyTotp 没有验证器时可以用 backupCode 代替 code。注册和账号恢复是同样的套路:
await client.register({ email: 'user@example.com', password: '••••••••' })
await client.triggerVerifyEmail({ email: 'user@example.com' })
await client.requestPasswordReset({ email: 'user@example.com' })后端发的验证或重置链接会落到你预先登记的 Client App 页面上,SDK 只负责触发发送。
Passkey 登录
Passkey 登录是两次 SDK 调用,中间夹一次浏览器 WebAuthn 断言:
import { performPasskeyAssertion } from 'herald-auth-web'
// 1FA passkey 登录
const begin = await client.passkey.loginBegin({})
const assertion = await performPasskeyAssertion(begin.options)
const result = await client.passkey.loginFinish({ authToken: begin.authToken, assertion })
// 2FA:requires-second-factor 结果之后,改传它的 tempToken
// const begin = await client.passkey.loginBegin({ tempToken })Passkey 的 RP 隔离要求页面来源与 Client App 预登记的来源一致,也就是必须出现在 allowed_origins 里的那个来源。
免密邮箱 OTP 登录
邮箱 OTP 是独立的免密第一因素,不是第二因素。send 把两个 409 控制流结果解析成 { kind: 'conflict' } 而不是抛异常:consent_required(自动注册的同意门)和 email_not_registered(自动注册关闭):
const sent = await client.loginWithEmailOtp.send({ email: 'user@example.com' })
if (sent.kind === 'conflict' && sent.code === 'consent_required') {
// 渲染 sent.agreements,然后带着接受的条目重发
await client.loginWithEmailOtp.send({
email: 'user@example.com',
agreements: sent.agreements.map(({ agreementType, versionId }) => ({ agreementType, versionId })),
})
}
// verify 成功时写入签发的 token 集合,行为与 login 一致。
const result = await client.loginWithEmailOtp.verify({ email: 'user@example.com', code: '123456' })token 存储
access token 只存内存;页面刷新即丢失,SDK 在下次请求时静默刷新。refresh token 走可插拔的 TokenStorage,默认 localStorage,每次刷新轮换、复用时吊销整个家族,模型与 token 生命周期一节相同。
refresh token 放 localStorage 能被 XSS 读到。这与 Herald 自生前端的风险姿态一致,靠轮换、复用检测和短期 access token 缓解。要求更高时注入 memoryStorage()(刷新后不保留)或自定义适配器:
import { createHeraldClient, memoryStorage } from 'herald-auth-web'
const client = createHeraldClient({
baseUrl: 'https://auth.example.com',
realmId: 'my-realm',
clientId: 'my-client-app',
storage: memoryStorage(),
})SSR 或 Node 环境没有 localStorage,不注入适配器时 createHeraldClient 抛 HeraldError { kind: 'ssr-no-storage' }。
SDK 自己的请求自动注入 Authorization: Bearer 并在 401 时静默刷新。要给你自己后端的调用附带 token,读 client.tokens.getAccessToken()。
Turnstile 与错误
realm 强制 Cloudflare Turnstile 时,把 token 放进每个方法 payload 的 turnstileToken 字段。
所有方法失败时抛 HeraldError,带稳定的 kind:validation(400)、unauthorized(401)、forbidden(403)、not-found(404)、rate-limited(429)、api(其他非 2xx)、network(fetch 失败或 CORS),外加刷新拿不到可用 token 时的 session-expired:
import { HeraldError } from 'herald-auth-web'
try {
await client.login({ email, password })
} catch (e) {
if (e instanceof HeraldError) {
switch (e.kind) {
case 'unauthorized': // 凭证错误
case 'rate-limited': // 429
case 'validation': // 400
case 'network': // fetch 失败 / CORS
}
}
}9. 部署
基于 Token 的部署
认证用的是 Bearer token,不是共享 Cookie。Herald 不在浏览器里设置任何 Cookie,所以 Herald 和你的服务不需要共享主机或根域名。浏览器只跟你自己的后端(同源)通信,你的后端通过 HTTP(服务端到服务端,走 base_url)调用 Herald。因为在这种 BFF 模式下浏览器从不直接调 Herald,浏览器到 Herald 之间的 CORS 不需要关心。
三种部署方式依然适用,但现在是为了运维简单而选,不是为了共享 Cookie:
- 同主机不同端口(你的后端
127.0.0.1:3000,Herald127.0.0.1:8080)——开发环境最方便 - 反向代理统一入口(Caddy 或 Nginx 把
/api/auth/*、/api/admin/*转发到你的后端,后端内部访问 Herald)——生产环境推荐,对外只有一个源 - 不同源(Herald 在
auth.example.com,你的应用在app.example.com)——现在不需要配 Cookie Domain 就能用了,因为没有 Cookie 跨边界
只有当浏览器直接调 Herald 时(也就是自建 UI、自己托管登录页对接 Herald API 的场景),浏览器到 Herald 的 CORS 才需要关心。那是另一种集成形态,见 White-label / 自定义用户 UI。
Token 生命周期
没有会话 Cookie。token 有以下属性,由 Herald 强制执行,前端只是存储:
- 短期 access token,由前端存内存。过期时前端刷新。
- 轮换的 refresh token,存
localStorage。每次刷新签发新的 refresh token 并使旧的失效。 - 复用检测:再次提交已经轮换过的 refresh token 会吊销整个 refresh token 家族,强制重新登录。
- 绝对生命周期:超过配置的绝对窗口后,刷新不再延长会话。
token TTL 和刷新轮换的绝对窗口按 Client App 配置。token 模型下的确切字段名和默认值在 Client App 配置里——完整 schema 见配置,token 处理在托管 UI 场景下的表现见 White-label / 自定义用户 UI。下表用行为语言描述三种生命周期策略。
| 策略 | 行为 | 典型用途 |
|---|---|---|
| 严格 | access token 短期,没有刷新窗口 | 高安全的管理操作;每几分钟重新登录 |
| 宽松 | access token 长期,刷新窗口等于其长度 | 内部工具,活跃用户整个工作日保持登录 |
| 渐进 | access token 短期,刷新窗口长 | 管理后台:短操作保持短,长时间使用自动续,刷新窗口关闭后过期 |