# Integrating a Third-Party Backend with Herald (/en/docs/integration/third-party-backend)



Herald handles users, login, permissions, and billing, and issues Bearer access/refresh tokens. Your backend only writes business logic, calling Herald's authentication and billing interfaces through the SDK.

```
Browser -> Your Frontend -> Your Backend -> Herald SDK -> Herald Service
                |                       (verify token, check perms, deduct points)
              OAuth redirect -> Herald login page -> callback to your backend -> returns Bearer token to frontend
```

Herald never sets a cookie. After the OAuth callback, your backend returns the token set to the frontend as JSON; the frontend holds it (access token in memory, refresh token in `localStorage`) and sends it as `Authorization: Bearer` on every call. Your backend never connects to Herald's database -- all interaction goes through HTTP API, with SDK-side caching.

> Want a complete, runnable example? [herald-app-example](https://github.com/timzaak/herald-app-example) is a fully AI-developed Flutter app that integrates Herald authentication.

## 1. Prerequisites [#1-prerequisites]

Complete these steps in the Herald admin console before writing code:

1. Create a realm (tenant) and note the `realm_id`
2. Create a client app under that realm and note the `client_id`
3. Generate an API Key for the realm. The secret is shown once. Store it somewhere your backend can read at startup -- config file or secret manager, not hardcoded. You must choose the client app this key binds to; pick the built-in `admin-api-client` only for realm-wide scope (it ships disabled on new realms — enable it in the Client App list first)
4. Define permission points in `resource:action` format, e.g. `product:read`, `device:manage`
5. Create roles and assign permission points to them
6. Create an admin user and assign roles

See [Design Your Permission Model](#design-your-permission-model) for step 4.

## 2. Backend Integration [#2-backend-integration]

### Configuration [#configuration]

Add a `[herald]` section to your service config:

```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"
```

| Field       | Description                                               |
| ----------- | --------------------------------------------------------- |
| `base_url`  | Herald address. Use container name inside Docker networks |
| `api_key`   | API key generated in Herald admin console                 |
| `realm_id`  | Your service's realm                                      |
| `client_id` | The client app identifier from prerequisites              |

Wrap the config in `Option<HeraldConfig>`. When absent, the entire auth system is disabled:

```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 {
    // ... other fields
    #[serde(default)]
    pub herald: Option<HeraldConfig>,
}
```

### Install the SDK [#install-the-sdk]

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

TypeScript backend? The [Node.js SDK](#7-nodejs-sdk-typescript) section covers the npm package of the same name; it mirrors this crate method for method.

### Initialization [#initialization]

Create the SDK client at startup based on config. When `Option<Arc<Client>>` is `None`, routes won't get the auth middleware:

```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,  // default 5-minute cache
    ))
});
```

The third argument is cache duration. `None` means 300 seconds. The SDK automatically invalidates cached entries when a token expires.

The API key authenticates your backend to Herald. Store it in config or environment variables, never hardcode it. API keys carry a client app scope: a key bound to `admin-api-client` can access all client apps in the realm; a key bound to an ordinary client app can only access that app's permission checks, subscriptions, and points. The API key is for backend-to-Herald machine calls (SDK); it is separate from the user Bearer tokens returned by OAuth.

### Auth Config Endpoint [#auth-config-endpoint]

Your frontend needs to know whether Herald is enabled. Provide a public endpoint:

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

Frontend calls this once at startup. `enabled: false` means skip all auth logic.

### OAuth Backend Handlers [#oauth-backend-handlers]

SPAs use OAuth 2.1 Authorization Code + PKCE. Your backend is a backend-for-frontend (BFF): it runs the code-for-token exchange (keeping `code_verifier` server-side), then returns the token set to the frontend as JSON. The frontend holds the tokens; your backend never stores a Herald session for the browser.

Herald's endpoints involved here:

* Authorize: `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` with `{grant_type:"authorization_code", code, redirect_uri, client_id, code_verifier}`, returning `{access_token, refresh_token, token_type:"Bearer", expires_in}`

You need two handlers: `oauth_start` to initiate login, `oauth_callback` to receive the redirect and hand tokens to the frontend.

#### oauth\_start: Initiate Login [#oauth_start-initiate-login]

```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,
    );

    // Store ONLY transient OAuth/PKCE state in a short-lived cookie.
    // This cookie holds {state, code_verifier, return_to, redirect_uri} -- it is NOT a
    // Herald session and contains no access/refresh token. It is cleared after the exchange.
    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)),
        ],
    ))
}
```

Four things happen here:

1. Generate `code_verifier` (random string) and `code_challenge` (SHA256 hash, base64url-encoded)
2. Build Herald's authorize URL and redirect the user there
3. Store `{state, code_verifier, return_to, redirect_uri}` in a short-lived `APP_OAUTH` cookie. This is PKCE/OAuth state only -- not a session, not a token
4. `return_to` remembers the user's original page for the callback redirect

#### oauth\_callback: Exchange Code, Return Tokens to Frontend [#oauth_callback-exchange-code-return-tokens-to-frontend]

```rust
// GET /api/auth/oauth/callback?code=xxx&state=yyy
//
// Two responsibilities: (1) do the code-for-token exchange server-side,
// (2) hand the resulting token set to the frontend as JSON. No Herald cookie is set.
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. Retrieve transient PKCE state from the APP_OAUTH cookie, verify 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. Exchange authorization code + code_verifier for the token set
    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. Hand tokens to the frontend. Two common shapes:
    //    (a) redirect to the SPA with a short-lived query fragment, or
    //    (b) serve a small same-origin HTML/JSON the SPA reads.
    //    Below uses shape (b): serve JSON the SPA fetches on the callback route.
    let mut response_headers = HeaderMap::new();
    // Clear the transient PKCE-state cookie now that the exchange is done.
    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` sends a POST to Herald's token endpoint and returns the full token set:

```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's security comes from `code_verifier` only existing in your backend's transient PKCE-state cookie -- it never passes through the frontend URL. Authorization codes are single-use: Herald atomically reads and deletes them with `GETDEL`, so a second exchange attempt fails.

Herald returns the tokens in the JSON response; your backend forwards that JSON to the frontend. The frontend keeps the access token in memory and the refresh token in `localStorage`, and sends `Authorization: Bearer <access_token>` on every API call back to your backend. Herald itself sets no cookie anywhere.

#### Transient PKCE-State Cookie Helpers [#transient-pkce-state-cookie-helpers]

These helpers deal only with the short-lived `APP_OAUTH` cookie that carries OAuth/PKCE state during the redirect. They are not used for any auth session -- there is no session cookie in this model.

```rust
// Short-lived cookie for transient OAuth/PKCE state during the authorize->callback redirect.
// Cleared in oauth_callback after the code exchange. Holds NO 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 Refresh Endpoint [#token-refresh-endpoint]

When the frontend's access token expires, it calls your backend's refresh endpoint, which forwards the refresh token to Herald's token endpoint with `grant_type=refresh_token`. Herald validates it, issues a new access token and a rotated refresh token, and detects reuse: if a previously-rotated refresh token is presented again, Herald revokes the entire refresh-token family.

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

On a failed refresh (invalid/expired/reused refresh token), return 401; the frontend clears its stored tokens and redirects to login.

### Auth Middleware [#auth-middleware]

The middleware extracts the token from the `Authorization: Bearer` header, maps the request path to a permission rule, and calls Herald to check.

```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. Extract token from the Authorization: Bearer header
    let Some(token) = extract_auth_token(&request) else {
        return ApiError::unauthorized().into_response();
    };

    // 2. Map request path to permission rule
    let Some(rule) = extract_permission(request.uri().path(), request.method()) else {
        return ApiError::forbidden().into_response();
    };

    // 3. Check with 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 {
        // Allowed: inject user_id into request extensions
        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 invalid/missing: allowed=false, user_id=None
        Ok(permission) if permission.user_id.is_none() => {
            ApiError::unauthorized().into_response()
        }
        // Authenticated but lacks permission: allowed=false, user_id=Some(...)
        Ok(_) => ApiError::forbidden().into_response(),
        // Herald error: distinguish 401/403/other
        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"),
    }
}

// Reads the Bearer token from the Authorization header. No cookie is involved.
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())
}
```

The three rejection scenarios mean different things:

| Condition                       | Herald returns                     | Your backend returns | Frontend behavior                                      |
| ------------------------------- | ---------------------------------- | -------------------- | ------------------------------------------------------ |
| Bearer token missing or invalid | `allowed=false, user_id=None`      | 401                  | Attempt refresh; on refresh failure, redirect to login |
| Authenticated but no permission | `allowed=false, user_id=Some(...)` | 403                  | Show permission denied                                 |
| Herald service unavailable      | Network error or 500               | 503                  | Show service unavailable                               |

503, not passthrough. If the auth service is down, processing requests means bypassing auth.

### Design Your Permission Model [#design-your-permission-model]

Permissions have two dimensions: **resource** and **action**. You define them; Herald stores and enforces.

Path-to-permission mapping happens in your middleware. Herald only answers one question: can this user do `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(),
    })
}
```

After defining your mapping, configure the resource/action pairs as permission points in the Herald admin console. Create roles, assign permissions, assign roles to users.

Herald has built-in action hierarchy: `manage` covers `view`, `create`, and `manage` itself. `create` only covers `create`. `view` only covers `view`. Custom actions (like `admin`) only match themselves. If a user has `product:manage`, middleware requests for `product:read` will pass.

### Route Wiring [#route-wiring]

Routes split into three groups: auth endpoints, public routes, and protected admin routes.

```rust
pub fn create_router(
    config: Arc<Config>,
    herald_client: Option<Arc<herald_sdk::Client>>,
) -> Router {
    // Auth endpoints: public, no Herald auth needed
    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));

    // Public routes: health checks, webhooks
    let public_routes = Router::new()
        .route("/health", get(health_check))
        .route("/webhook/device", post(device_webhook));

    // Admin routes: require Herald auth
    let admin_routes = Router::new()
        .route("/admin/product", get(list_products).post(create_product))
        .route("/admin/device", get(list_devices));

    // Conditional mounting: only add auth middleware when Herald is configured
    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)
}
```

The `match` branch is the key. Without `[herald]` in config, `herald_client` is `None` and admin routes get no middleware -- all management endpoints are accessible without auth. This is useful for local development or isolated intranet environments.

## 3. Frontend Integration [#3-frontend-integration]

The frontend owns token storage and sends `Authorization: Bearer` on every request. It handles four things: detect auth state, hold tokens after login, attach the Bearer header, and refresh on 401. This section hand-rolls that plumbing with plain `fetch`. The other frontend shape, where the browser calls Herald directly from your own login page, has a dedicated SDK; see [Browser SDK (herald-auth-web)](#8-browser-sdk-herald-auth-web) and [White-label / Custom User UI](/docs/customization/branding).

### Detect Auth State [#detect-auth-state]

```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
}
```

Call `/api/auth/config` once at startup. If `enabled: false`, skip all auth logic.

### Token Storage [#token-storage]

After the OAuth callback returns the token set, keep the access token in memory (a module-level variable) and the refresh token in `localStorage`. The access token never touches `localStorage` -- it clears on reload, limiting exposure.

```typescript
// In-memory access token; lost on reload, which is the point.
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
}

// Called by the OAuth callback route after fetching /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)
}

// On boot: if a refresh token exists, proactively refresh to get a fresh 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()
  }
}
```

### Attach Bearer + Refresh on 401 [#attach-bearer--refresh-on-401]

A `fetch` wrapper injects `Authorization: Bearer` on every call. On a 401 it attempts one refresh (rotating the refresh token), then retries the original request once. If refresh fails, tokens are cleared and the user is sent to login.

```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: try one refresh, then retry once with the new 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 rotation plus reuse detection: every successful refresh returns a new refresh token and invalidates the old one. If a stolen/old refresh token is replayed, Herald detects the reuse and revokes the whole family, so the next refresh fails and the frontend clears tokens and redirects to login.

### Check Login Status [#check-login-status]

```typescript
export async function checkAuth(): Promise<boolean> {
  const config = await getAuthConfig()
  if (!config.enabled) return true

  // Probe with a lightweight admin endpoint through the Bearer-injecting wrapper.
  const response = await authedFetch('/api/admin/product?page=1&page_size=1')
  return response.status !== 401
}
```

No dedicated "check login" endpoint. A real admin endpoint does double duty as a login probe.

### Handle 401 [#handle-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` prevents multiple concurrent 401s from triggering duplicate redirects. The redirect URL includes the current page so OAuth callback returns the user here.

If you use an axios-style client instead of the `fetch` wrapper, wire the same logic into its request interceptor (inject `Authorization: Bearer`) and response interceptor (on 401, refresh once, retry once, else `handle401`).

### Google One Tap [#google-one-tap]

Google One Tap lets a signed-in Google user log into your app from your own page, with no redirect to a Herald or Google login screen. Herald verifies the Google ID Token server-side (signature, issuer, audience, expiry) and issues the same Bearer token family as the redirect flow, so One Tap and the redirect flow resolve to the same Herald account. The integration has six steps:

1. Read the Google `clientId` from `GET /api/public-config/{realmId}` — take the entry whose `name === "google"` in the `oauthProviders` array and use its `clientId`. If your page already loads public-config for the login form, reuse that cached `clientId` instead of fetching again.
2. Load the GIS SDK from `https://accounts.google.com/gsi/client` and call `google.accounts.id.initialize({ client_id: clientId, callback })`.
3. Call `google.accounts.id.prompt()` to show the overlay.
4. When the user chooses an account, Google invokes `callback` with a `credential` field (the ID Token JWT).
5. `POST /api/oauth/{realmId}/google/one-tap` with body `{ credential, clientId, downstreamState? }`. Note that `realmId` is a path parameter, not part of the body.
6. Branch on the response:
   * **Direct session mode** (no `downstreamState`): the response is a Bearer token set. Store it with `storeTokens` from [Token Storage](#token-storage) — the response uses camelCase keys (`accessToken`, …), so adapt them to the snake\_case `TokenSet` the helper expects (see the snippet below).
   * **Downstream authorization-code mode** (`downstreamState` present): the response is `{ redirectUri }` containing `?code=ac_...&state=...`. Continue with the Code+PKCE flow from section 2 (`oauth_callback` / `exchange_oauth_code`).

```typescript
// 1. Resolve the Google clientId from Herald public config.
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 shape (loaded by the <script> tag below).
declare const google: {
  accounts: {
    id: {
      initialize: (config: {
        client_id: string
        callback: (response: { credential: string }) => void
      }) => void
      prompt: () => void
    }
  }
}

// 2-4. Initialize the SDK and show the One Tap overlay.
export function startGoogleOneTap(
  realmId: string,
  clientId: string,
  downstreamState?: string,
): void {
  google.accounts.id.initialize({
    client_id: clientId,
    callback: (response) => {
      // 5-6. Forward the ID Token to Herald and handle the response.
      void sendOneTapCredential(realmId, clientId, response.credential, downstreamState)
    },
  })
  google.accounts.id.prompt() // 3. show overlay
}

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) {
    // Downstream authorization-code mode: hand off to the Code+PKCE flow.
    window.location.href = data.redirectUri as string
  } else {
    // Direct session mode: adapt the camelCase One Tap response to the snake_case TokenSet.
    storeTokens({
      access_token: data.accessToken,
      refresh_token: data.refreshToken,
      token_type: data.tokenType,
      expires_in: data.expiresIn,
    })
  }
}
```

Load the SDK with a single script tag (GIS is not an npm package):

```html
<script src="https://accounts.google.com/gsi/client" async defer></script>
```

**Integrator responsibilities and degradation:**

* Register every origin that runs this code in Google Cloud Console under the OAuth client's *Authorized JavaScript origins*. Without this the GIS SDK refuses to load and the overlay never appears — this is the integrator's responsibility, not Herald's.
* If the user is not signed into Google, or the origin is not authorized, the overlay does not appear. This is normal degradation — always keep a regular login entry (the OAuth redirect flow) alongside One Tap.
* Token handling is identical to the rest of this section: access token in memory, refresh token in `localStorage`, and `authedFetch` attaches the Bearer header and refreshes on 401. One Tap only changes how you obtain the first token set.
* The actual response shape follows the backend OpenAPI spec. When `downstreamState` is omitted the backend returns the direct-session token set (`accessToken` / `refreshToken` / `userId` / `expiresIn` / `refreshExpiresIn` / `tokenType`, matching `OneTapDirectResponse`); when present it returns `{ redirectUri }` (`OneTapCodeResponse`). Confirm the live 200 response declared on the endpoint against the OpenAPI document.

## 4. Resource Management via SDK [#4-resource-management-via-sdk]

API keys are principals in Herald's RBAC system. Assign roles and permissions to a key, and SDK calls through that key can perform the corresponding operations. Roles don't bypass client app scope.

### Manage Realms [#manage-realms]

```rust
let realm = herald_client.create_realm(CreateRealmSdkRequest {
    name: "my-app".to_string(),
    description: Some("My application".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?;
```

### Manage Users [#manage-users]

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

### Manage Client Apps [#manage-client-apps]

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

Prefer an API key bound to the client app your backend serves. Use the `admin-api-client` key only for cross-client-app management (it ships disabled on new realms — enable it before issuing keys against it).

## 5. Points System [#5-points-system]

For usage-based billing (API call counts, token consumption, credits).

```rust
// Check balance
let balance = herald_client.get_balance("my-realm", &user_id).await?;
println!("Balance: {} {}", balance.balance, balance.unit);

// Consume points
let result = herald_client.consume_points(
    "my-realm",
    &user_id,
    "my-client-app",
    100,
    Some("AI API call".to_string()),
    Some("unique-request-id".to_string()),
).await?;
println!("Balance after: {}", result.balance_after);
```

Always pass an `idempotency_key`. On network timeout retries, the same key prevents double-charging. When using an ordinary client app API key, `client_app_id` must be that key's bound client app.

Which account gets debited is decided by Herald from the `client_app_id`: it finds every Credit Account that covers this client app and debits across them, nearest-expiry first. Your backend does not deal with accounts, but be aware that the same user may have different available balances under different client apps, depending on which accounts cover each app. See [Billing Architecture](/docs/billing/overview#credit-account) for the account model.

The balance Herald returns is derived in real time from the points ledger and excludes pre-granted credits that have not reached their effective time -- the balance you read is exactly what can be consumed right now.

## 6. Subscription System [#6-subscription-system]

Query a client app's subscription status:

```rust
let sub = herald_client.get_subscription("my-realm", "my-client-app").await?;
if sub.status == "active" {
    // user has a paid subscription
}
```

Subscription entitlements are synced from payment-provider products into Herald `entitlement_key` values. Payment is handled between Herald and the payment provider; your backend only queries subscription status and the current entitlement via SDK.

## 7. Node.js SDK (TypeScript) [#7-nodejs-sdk-typescript]

The Rust crate has a TypeScript counterpart published as `herald-sdk` on npm (source at `sdk/node`). It is a 1:1 port: same method names in camelCase, same caching behavior, wire types matching the crate's public structs. Zero runtime dependencies on native `fetch`, Node 18+, ESM and CommonJS dual build. When your backend is Node instead of Rust, every `herald_sdk::Client` call shown in sections 2, 4, 5, and 6 has a same-named method here.

### Install [#install]

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

### Initialization [#initialization-1]

```ts
import { HeraldClient } from 'herald-sdk'

const client = new HeraldClient(
  'http://127.0.0.1:13000', // base URL, same value as [herald].base_url in the Rust setup
  'sk-your-api-key',        // realm API key, sent as X-API-Key
  300,                      // permission cache TTL seconds; default 300
)
```

Every call carries the API key in the `X-API-Key` header against Herald's external API (`/api/ext/*`). The third constructor argument is the permission cache TTL, the same knob as the Rust `Client::new` third argument.

### Permission check [#permission-check]

```ts
const resp = await client.checkPermission({
  accessToken: browserToken, // the Bearer token your frontend forwarded
  clientId: 'admin-web-console',
  rules: [{ resource: 'product', action: 'read' }],
})
if (resp.allowed) {
  // resp.userId is the Herald user id
}
```

Caching mirrors the Rust crate: results are cached per exact request (token + clientId + rules, rule order significant) for the TTL, and a token not checked for over 5 minutes has its entries invalidated before the next check. After you change a user's permissions in Herald and want the next check to hit the server, drop the cache for that token:

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

### Billing and resource management [#billing-and-resource-management]

The subscription, points, and admin calls match sections 4-6 one for one, in camelCase:

```ts
// Subscription status (section 6)
const sub = await client.getSubscription('my-realm', 'my-client-app')

// Points (section 5)
const balance = await client.getBalance('my-realm', userId)
const result = await client.consumePoints(
  'my-realm', userId, 'my-client-app',
  100, 'AI API call', 'unique-request-id',
)
console.log(result.transactions[0].balanceAfter)

// Grants must target an explicit Credit Bucket (section 5)
await client.grantPoints('my-realm', userId, 'bucket-id', 500, 'welcome bonus')

// Realm / user / client-app admin (section 4)
const realm = await client.createRealm({
  name: 'my-app',
  description: 'My application',
  adminUser: { email: 'admin@example.com', password: 'secure-password' },
})
const apps = await client.listClientApps('my-realm')
```

The consume result carries one transaction per affected Credit Bucket (length 1 for a single pool). The idempotency rules from section 5 apply unchanged: always pass an idempotency key on consume, and remember the debit target is decided by `clientAppId`.

### Errors [#errors]

Failed calls throw `HeraldSdkError` with a stable `code`:

```ts
import { HeraldSdkError } from 'herald-sdk'

try {
  await client.getBalance('my-realm', userId)
} catch (error) {
  if (error instanceof HeraldSdkError && error.code === 'forbidden') {
    // cross-realm access or insufficient permission
  }
}
```

`code` is one of `unauthorized`, `forbidden`, `not-found`, `internal-server-error`, `api-error`, `network`, `parse`. The HTTP `status` and raw `body` are attached when a response was received.

## 8. Browser SDK (herald-auth-web) [#8-browser-sdk-herald-auth-web]

Sections 2 and 3 assume a BFF: the browser talks only to your backend. The other shape is the browser calling Herald directly from your own login page, the Custom User UI scenario of [White-label / Custom User UI](/docs/customization/branding). That shape has its own SDK: `herald-auth-web` (source at `sdk/web`), framework-agnostic with zero runtime dependencies (native `fetch`, `WebCrypto`, `localStorage`). It wraps the credential lifecycle end to end: register, email verification trigger, password reset request, login with TOTP or passkey second factors, passwordless email-OTP, silent access-token refresh, logout, and status.

The SDK wraps the direct-signed `CustomUserUi` credential class (`POST /api/auth/{realmId}/login`). It does not perform the PKCE exchange from section 2; pass an OAuth context to `login` and the backend answers with `redirectTo`, which your code follows and completes itself.

### Install [#install-1]

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

The package is ESM-only for bundlers. For pages with no build step, a minified IIFE bundle exposing a `Herald` global is published:

```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>
```

### Register your origin first [#register-your-origin-first]

Herald's CORS policy matches the request origin against the Client App's `allowed_origins` exactly. Add your page origin (scheme + host + port, e.g. `https://app.example.com`) in the console before wiring the SDK. An unregistered origin surfaces as `HeraldError { kind: 'network' }`: the browser cannot distinguish a CORS rejection from a generic network failure.

### Create the client and log in [#create-the-client-and-log-in]

```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') {
      // redirect to your login page
    }
  },
})
```

Session events are `authenticated`, `session-expired`, and `logged-out`. For polling instead of events, `await client.getStatus()` returns the session snapshot (`authenticated`, `userId`, `permissions`, `scopes`), and `await client.logout()` clears the tokens and ends the session server-side.

`login` returns a discriminated result instead of throwing for control-flow outcomes:

```ts
const result = await client.login({ email: 'user@example.com', password: '••••••••' })
switch (result.kind) {
  case 'success':
    // logged in; result.session
    break
  case 'requires-second-factor': {
    // result.secondFactors is a subset of ['totp', 'passkey']; result.tempToken
    const final = await client.verifyTotp({ tempToken: result.tempToken, code: '123456' })
    break
  }
  case 'consent-required':
    // render result.agreements, then re-call login with them:
    // await client.login({ email, password, agreements: result.agreements })
    break
  case 'oauth-redirect':
    window.location.href = result.redirectTo
    break
}
```

`verifyTotp` also accepts `backupCode` in place of `code`. Registration and account recovery follow the same pattern:

```ts
await client.register({ email: 'user@example.com', password: '••••••••' })
await client.triggerVerifyEmail({ email: 'user@example.com' })
await client.requestPasswordReset({ email: 'user@example.com' })
```

The backend emails a verification or reset link that lands on your pre-registered Client App page; the SDK only triggers the send.

### Passkey login [#passkey-login]

Passkey login is two SDK calls with a browser WebAuthn assertion in between:

```ts
import { performPasskeyAssertion } from 'herald-auth-web'

// 1FA passkey login
const begin = await client.passkey.loginBegin({})
const assertion = await performPasskeyAssertion(begin.options)
const result = await client.passkey.loginFinish({ authToken: begin.authToken, assertion })

// 2FA: after a requires-second-factor result, pass its tempToken instead
// const begin = await client.passkey.loginBegin({ tempToken })
```

Passkey RP isolation requires your page origin to match the Client App's pre-registered origin, the same origin that must be in `allowed_origins`.

### Passwordless email-OTP [#passwordless-email-otp]

Email-OTP is a first factor of its own, not a second factor. `send` resolves two 409 control-flow outcomes as `{ kind: 'conflict' }` instead of throwing: `consent_required` (auto-register consent gate) and `email_not_registered` (auto-register off):

```ts
const sent = await client.loginWithEmailOtp.send({ email: 'user@example.com' })
if (sent.kind === 'conflict' && sent.code === 'consent_required') {
  // render sent.agreements, then re-send with the accepted pairs
  await client.loginWithEmailOtp.send({
    email: 'user@example.com',
    agreements: sent.agreements.map(({ agreementType, versionId }) => ({ agreementType, versionId })),
  })
}

// On success, verify applies the issued token set, same as login.
const result = await client.loginWithEmailOtp.verify({ email: 'user@example.com', code: '123456' })
```

### Token storage [#token-storage-1]

The access token lives only in memory; a page reload clears it and the SDK silently refreshes on the next request. The refresh token goes through a pluggable `TokenStorage` whose default is `localStorage`, rotated on every refresh with family revocation on reuse, the same model as [Token Lifecycle](#token-lifecycle).

A refresh token in `localStorage` is readable by XSS. That matches the risk posture of Herald's own frontend and is mitigated by rotation, reuse detection, and short-lived access tokens. For higher security, inject `memoryStorage()` (no persistence across reloads) or a custom adapter:

```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(),
})
```

In SSR or Node, where `localStorage` is unavailable, `createHeraldClient` throws `HeraldError { kind: 'ssr-no-storage' }` unless you inject an adapter.

The SDK injects `Authorization: Bearer` and silently refreshes on 401 for its own calls. To attach the token to calls against your own backend, read `client.tokens.getAccessToken()`.

### Turnstile and errors [#turnstile-and-errors]

If the realm enforces Cloudflare Turnstile, pass the token via the `turnstileToken` field of each method payload.

Every method rejects with a `HeraldError` carrying a stable `kind`: `validation` (400), `unauthorized` (401), `forbidden` (403), `not-found` (404), `rate-limited` (429), `api` (other non-2xx), `network` (fetch failed or CORS), plus `session-expired` when a refresh has no usable token left:

```ts
import { HeraldError } from 'herald-auth-web'

try {
  await client.login({ email, password })
} catch (e) {
  if (e instanceof HeraldError) {
    switch (e.kind) {
      case 'unauthorized': // bad credentials
      case 'rate-limited': // 429
      case 'validation':   // 400
      case 'network':      // fetch failed / CORS
    }
  }
}
```

## 9. Deployment [#9-deployment]

### Token-Based Deployment [#token-based-deployment]

Auth is Bearer tokens, not a shared cookie. Herald sets no cookie in the browser, so Herald and your service do **not** need to share a host or root domain. The browser only ever talks to your own backend (same-origin), and your backend calls Herald over HTTP (server-to-server, via `base_url`). Because the browser never calls Herald directly in this BFF pattern, CORS between the browser and Herald is not a concern.

Three deployment patterns still apply, now chosen for operational simplicity rather than cookie sharing:

* **Same host, different ports** (`127.0.0.1:3000` for your backend, `127.0.0.1:8080` for Herald) -- easiest for development
* **Reverse proxy** (Caddy or Nginx routes `/api/auth/*` and `/api/admin/*` to your backend, and your backend reaches Herald internally) -- recommended for production, keeps one public origin
* **Separate origins** (Herald on `auth.example.com`, your app on `app.example.com`) -- now viable without cookie-domain configuration, since no cookie crosses the boundary

CORS between the browser and Herald matters only when the browser calls Herald directly -- i.e. the self-built-UI case where you host your own login page against Herald's APIs. That is a different integration shape, covered in [White-label / Custom User UI](/docs/customization/branding).

### Token Lifecycle [#token-lifecycle]

There is no session cookie. Tokens have these properties, enforced by Herald and merely stored by the frontend:

* **Short-lived access token**, held in memory by the frontend. When it expires, the frontend refreshes.
* **Rotating refresh token**, stored in `localStorage`. Each refresh issues a new refresh token and invalidates the old one.
* **Reuse detection**: replaying an already-rotated refresh token revokes the entire refresh-token family, forcing re-login.
* **Absolute lifetime**: beyond a configured absolute window, no refresh extends the session.

The token TTL and the refresh-rotation absolute window are configured per client app. The exact field names and defaults for the token model live in the client app configuration -- see [Configuration](/docs/configuration) for the full schema and [White-label / Custom User UI](/docs/customization/branding) for how token handling surfaces in a hosted-UI context. The table below describes the three lifecycle strategies in behavioral terms.

| Strategy    | Behavior                                               | Typical use                                                                                             |
| ----------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| Strict      | Short access-token lifetime, no refresh window         | High-security admin actions; re-login every few minutes                                                 |
| Relaxed     | Long access-token lifetime, refresh window equal to it | Internal tools where active users stay signed in for the workday                                        |
| Progressive | Short access-token lifetime, long refresh window       | Admin panels: quick tasks stay short, extended use gets renewed, expires when the refresh window closes |
