# 第三方后端服务对接 Herald (/zh/docs/integration/third-party-backend)



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](https://github.com/timzaak/herald-app-example) 是一个全 AI 开发、集成了 Herald 认证的 Flutter App。

## 1. 前置准备 [#1-前置准备]

在 Herald 管理后台完成以下操作，不需要写代码：

1. 创建一个 realm（租户），记下 `realm_id`
2. 在 realm 下创建一个 client app（代表你的服务），记下 `client_id`
3. 为这个 realm 生成一个 API Key，保存密钥值——只显示一次，丢了得重新生成。生成时**必须**选择绑定的 Client App；需要 realm 级范围时选择内置的 `admin-api-client`（该内置客户端默认停用，先在 Client App 列表启用它）
4. 定义权限点。权限格式是 `resource:action`，比如 `product:read`、`device:manage`
5. 创建角色，把权限点分配给角色
6. 创建一个管理员用户，把角色分配给这个用户

第 4 步的权限点怎么设计，[设计权限模型](#设计权限模型) 会讲。

## 2. 后端集成 [#2-后端集成]

### 配置项 [#配置项]

在你的服务配置文件中加一个 `[herald]` 段：

```toml
[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>` 包裹，不配置时整个认证体系不启用：

```rust
#[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 [#安装-sdk]

在 `Cargo.toml` 中添加：

```toml
[dependencies]
herald-sdk = "0.5"
```

后端是 TypeScript？看下面的 [Node.js SDK](#7-nodejs-sdk-typescript) 一节，npm 上有同名包，方法和这个 crate 一一对应。

### 初始化 [#初始化]

启动时根据配置决定是否创建 SDK 客户端。`Option<Arc<Client>>` 为 `None` 时，后续路由不会挂认证中间件：

```rust
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 端点 [#auth-config-端点]

前端需要知道 Herald 是否启用。提供一个公开端点：

```rust
#[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 后端处理器 [#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：发起登录 [#oauth_start发起登录]

```rust
// 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)),
        ],
    ))
}
```

这个处理器做了四件事：

1. 生成 `code_verifier`（随机字符串）和 `code_challenge`（SHA256 哈希后 Base64url 编码）
2. 构造 Herald 授权 URL，把用户重定向过去
3. 把 `{state, code_verifier, return_to, redirect_uri}` 存到一个短期 `APP_OAUTH` Cookie 里。这只是 PKCE/OAuth 状态——不是会话，也不是 token
4. `return_to` 记录用户原始页面，回调时用

#### oauth\_callback：换 token，把 token 返回给前端 [#oauth_callback换-token把-token-返回给前端]

```rust
// 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 集合：

```rust
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 辅助函数 [#临时-pkce-状态-cookie-辅助函数]

这些辅助函数只处理授权→回调跳转期间那个短期的 `APP_OAUTH` Cookie，里面装的是 OAuth/PKCE 状态。它们不用于任何认证会话——这个模型里没有会话 Cookie。

```rust
// 授权→回调跳转期间存放临时 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 刷新端点 [#token-刷新端点]

前端的 access token 过期时，它调你后端的刷新端点；后端用 `grant_type=refresh_token` 把 refresh token 转发给 Herald 的 token 端点。Herald 校验后签发新的 access token 和轮换后的 refresh token，并做复用检测：如果再次提交已经轮换过的 refresh token，Herald 会吊销整个 refresh token 家族。

```rust
// 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 校验。

```rust
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`"，路径映射是你的中间件决定的。

```rust
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 端点、公开路由、需要认证的管理路由。

```rust
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. 前端集成 [#3-前端集成]

前端负责 token 存储，并在每次请求带 `Authorization: Bearer`。它要处理四件事：探测认证状态、登录后持有 token、附加 Bearer 头、401 时刷新。这一节用原生 `fetch` 手写这些管线。另一种前端形态是浏览器从你自己的登录页直接调 Herald，那种形态有专门的 SDK，见 [浏览器 SDK (herald-auth-web)](#8-浏览器-sdk-herald-auth-web) 和 [White-label / 自定义用户 UI](/docs/zh/customization/branding)。

### 探测认证状态 [#探测认证状态]

```typescript
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 存储 [#token-存储]

OAuth 回调返回 token 集合后，把 access token 存内存（模块级变量），refresh token 存 `localStorage`。access token 不要进 `localStorage`——刷新页面就丢，这是有意为之，缩小暴露面。

```typescript
// 内存里的 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 刷新 [#附加-bearer-头--401-刷新]

一个 `fetch` 包装函数在每次请求注入 `Authorization: Bearer`。遇到 401 时做一次刷新（轮换 refresh token），然后用新 token 重试一次原请求。刷新失败则清掉 token 并跳转登录。

```typescript
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 并跳转登录。

### 检查登录状态 [#检查登录状态]

```typescript
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 处理 [#401-处理]

```typescript
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 One Tap 让已登录 Google 的用户在你自己的页面上一键登录，全程不跳转到 Herald 或 Google 登录页。Herald 在服务端校验 Google ID Token（签名、签发者、受众、过期时间），并签发与跳转式登录完全一致的 Bearer token 家族，因此 One Tap 与跳转式登录会落到同一个 Herald 账号上。集成分六步：

1. 从 `GET /api/public-config/{realmId}` 读取 Google 的 `clientId`——在 `oauthProviders` 数组里取 `name === "google"` 的条目，使用其 `clientId`。如果你的页面已经为登录表单加载了 public-config，直接复用其中缓存的 `clientId`，不必再次请求。
2. 加载 GIS SDK（`https://accounts.google.com/gsi/client`），调用 `google.accounts.id.initialize({ client_id: clientId, callback })`。
3. 调用 `google.accounts.id.prompt()` 弹出浮层。
4. 用户选择账号后，Google 调用 `callback` 并传入 `credential` 字段（即 ID Token JWT）。
5. `POST /api/oauth/{realmId}/google/one-tap`，body 为 `{ credential, clientId, downstreamState? }`。注意 `realmId` 是路径参数，不在 body 中。
6. 按响应分支处理：
   * **直接会话模式**（不带 `downstreamState`）：响应是 Bearer token 集合。用 [Token 存储](#token-存储) 里的 `storeTokens` 写入——响应用的是 camelCase 键（`accessToken`、…），需适配成 helper 期望的 snake\_case `TokenSet`（见下方代码）。
   * **下游授权码模式**（带 `downstreamState`）：响应为 `{ redirectUri }`，含 `?code=ac_...&state=...`。按第 2 节的 Code+PKCE 流程继续（`oauth_callback` / `exchange_oauth_code`）。

```typescript
// 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 包）：

```html
<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 管理资源 [#4-通过-sdk-管理资源]

API Key 在 Herald 中也是一种 Principal（主体），跟用户一样可以分配角色和权限。你给 API Key 分配了什么权限，通过这个 Key 发出的 SDK 调用就能做什么。权限受 Client App 范围限制。

### 管理 Realm [#管理-realm]

```rust
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?;
```

### 管理用户 [#管理用户]

```rust
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 [#管理-client-app]

```rust
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（新 realm 的该内置客户端默认停用，先启用再发 Key）。

## 5. 积分系统 [#5-积分系统]

如果你的服务按量计费（比如 AI API 调用次数），用 Herald 的积分系统。

```rust
// 查余额
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 下的可用余额可能不同，取决于哪些积分账户覆盖了那个应用。积分账户的背景见[计费架构](/docs/zh/billing/overview#积分账户)。

余额是 Herald 实时从积分账本派生计算的，不含尚未到生效时间的预发积分——你查到的余额就是当前可消费的额度。

## 6. 订阅系统 [#6-订阅系统]

查询 Client App 的订阅状态：

```rust
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) [#7-nodejs-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` 的调用，这里都有同名方法。

### 安装 [#安装]

```bash
npm install herald-sdk
```

### 初始化 [#初始化-1]

```ts
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` 的第三个参数。

### 权限检查 [#权限检查]

```ts
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 的缓存：

```ts
client.invalidateCache(browserToken)
```

### 计费与资源管理 [#计费与资源管理]

订阅、积分、管理类调用与第 4-6 节一一对应，换成 camelCase：

```ts
// 订阅状态（第 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`：

```ts
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) [#8-浏览器-sdk-herald-auth-web]

第 2、3 节假设的是 BFF 形态：浏览器只跟你的后端通信。另一种形态是浏览器从你自己的登录页直接调 Herald，即 [White-label / 自定义用户 UI](/docs/zh/customization/branding) 讲的场景。这种形态有专门的 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`，后续交换由你的代码自己完成。

### 安装 [#安装-1]

```bash
npm install herald-auth-web
```

包是 ESM-only，面向打包器。没有构建步骤的页面可以用发布的 IIFE 压缩包，暴露 `Herald` 全局变量：

```html
<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 拒绝和普通网络失败。

### 创建客户端并登录 [#创建客户端并登录]

```ts
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` 对控制流结果返回可判别的联合类型，不靠抛异常：

```ts
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`。注册和账号恢复是同样的套路：

```ts
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-登录]

Passkey 登录是两次 SDK 调用，中间夹一次浏览器 WebAuthn 断言：

```ts
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-登录]

邮箱 OTP 是独立的免密第一因素，不是第二因素。`send` 把两个 409 控制流结果解析成 `{ kind: 'conflict' }` 而不是抛异常：`consent_required`（自动注册的同意门）和 `email_not_registered`（自动注册关闭）：

```ts
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 存储 [#token-存储-1]

access token 只存内存；页面刷新即丢失，SDK 在下次请求时静默刷新。refresh token 走可插拔的 `TokenStorage`，默认 `localStorage`，每次刷新轮换、复用时吊销整个家族，模型与 [token 生命周期](#token-生命周期)一节相同。

refresh token 放 `localStorage` 能被 XSS 读到。这与 Herald 自生前端的风险姿态一致，靠轮换、复用检测和短期 access token 缓解。要求更高时注入 `memoryStorage()`（刷新后不保留）或自定义适配器：

```ts
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 与错误 [#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`：

```ts
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. 部署 [#9-部署]

### 基于 Token 的部署 [#基于-token-的部署]

认证用的是 Bearer token，不是共享 Cookie。Herald 不在浏览器里设置任何 Cookie，所以 Herald 和你的服务**不需要**共享主机或根域名。浏览器只跟你自己的后端（同源）通信，你的后端通过 HTTP（服务端到服务端，走 `base_url`）调用 Herald。因为在这种 BFF 模式下浏览器从不直接调 Herald，浏览器到 Herald 之间的 CORS 不需要关心。

三种部署方式依然适用，但现在是为了运维简单而选，不是为了共享 Cookie：

* 同主机不同端口（你的后端 `127.0.0.1:3000`，Herald `127.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](/docs/zh/customization/branding)。

### Token 生命周期 [#token-生命周期]

没有会话 Cookie。token 有以下属性，由 Herald 强制执行，前端只是存储：

* **短期 access token**，由前端存内存。过期时前端刷新。
* **轮换的 refresh token**，存 `localStorage`。每次刷新签发新的 refresh token 并使旧的失效。
* **复用检测**：再次提交已经轮换过的 refresh token 会吊销整个 refresh token 家族，强制重新登录。
* **绝对生命周期**：超过配置的绝对窗口后，刷新不再延长会话。

token TTL 和刷新轮换的绝对窗口按 Client App 配置。token 模型下的确切字段名和默认值在 Client App 配置里——完整 schema 见[配置](/docs/zh/configuration)，token 处理在托管 UI 场景下的表现见 [White-label / 自定义用户 UI](/docs/zh/customization/branding)。下表用行为语言描述三种生命周期策略。

| 策略 | 行为                        | 典型用途                           |
| -- | ------------------------- | ------------------------------ |
| 严格 | access token 短期，没有刷新窗口    | 高安全的管理操作；每几分钟重新登录              |
| 宽松 | access token 长期，刷新窗口等于其长度 | 内部工具，活跃用户整个工作日保持登录             |
| 渐进 | access token 短期，刷新窗口长     | 管理后台：短操作保持短，长时间使用自动续，刷新窗口关闭后过期 |
