Integrating a Third-Party Backend with Herald
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.
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 frontendHerald 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 is a fully AI-developed Flutter app that integrates Herald authentication.
1. Prerequisites
Complete these steps in the Herald admin console before writing code:
- Create a realm (tenant) and note the
realm_id - Create a client app under that realm and note the
client_id - 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. Choose the client app this backend serves; if left empty, Herald binds the key to
admin-api-client - Define permission points in
resource:actionformat, e.g.product:read,device:manage - Create roles and assign permission points to them
- Create an admin user and assign roles
See Design Your Permission Model for step 4.
2. Backend Integration
Configuration
Add a [herald] section to your service config:
[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:
#[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
[dependencies]
herald-sdk = "0.5"TypeScript backend? The Node.js SDK section covers the npm package of the same name; it mirrors this crate method for method.
Initialization
Create the SDK client at startup based on config. When Option<Arc<Client>> is None, routes won't get the auth middleware:
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
Your frontend needs to know whether Herald is enabled. Provide a public endpoint:
#[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
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}/tokenwith{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
// 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:
- Generate
code_verifier(random string) andcode_challenge(SHA256 hash, base64url-encoded) - Build Herald's authorize URL and redirect the user there
- Store
{state, code_verifier, return_to, redirect_uri}in a short-livedAPP_OAUTHcookie. This is PKCE/OAuth state only -- not a session, not a token return_toremembers the user's original page for the callback redirect
oauth_callback: Exchange Code, Return Tokens to Frontend
// 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:
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
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.
// 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
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.
// 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
The middleware extracts the token from the Authorization: Bearer header, maps the request path to a permission rule, and calls Herald to check.
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
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?
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
Routes split into three groups: auth endpoints, public routes, and protected admin routes.
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
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) and White-label / Custom User UI.
Detect Auth State
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
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.
// 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
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.
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
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
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 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:
- Read the Google
clientIdfromGET /api/public-config/{realmId}— take the entry whosename === "google"in theoauthProvidersarray and use itsclientId. If your page already loads public-config for the login form, reuse that cachedclientIdinstead of fetching again. - Load the GIS SDK from
https://accounts.google.com/gsi/clientand callgoogle.accounts.id.initialize({ client_id: clientId, callback }). - Call
google.accounts.id.prompt()to show the overlay. - When the user chooses an account, Google invokes
callbackwith acredentialfield (the ID Token JWT). POST /api/oauth/{realmId}/google/one-tapwith body{ credential, clientId, downstreamState? }. Note thatrealmIdis a path parameter, not part of the body.- Branch on the response:
- Direct session mode (no
downstreamState): the response is a Bearer token set. Store it withstoreTokensfrom Token Storage — the response uses camelCase keys (accessToken, …), so adapt them to the snake_caseTokenSetthe helper expects (see the snippet below). - Downstream authorization-code mode (
downstreamStatepresent): the response is{ redirectUri }containing?code=ac_...&state=.... Continue with the Code+PKCE flow from section 2 (oauth_callback/exchange_oauth_code).
- Direct session mode (no
// 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):
<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, andauthedFetchattaches 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
downstreamStateis omitted the backend returns the direct-session token set (accessToken/refreshToken/userId/expiresIn/refreshExpiresIn/tokenType, matchingOneTapDirectResponse); when present it returns{ redirectUri }(OneTapCodeResponse). Confirm the live 200 response declared on the endpoint against the OpenAPI document.
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
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
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
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.
5. Points System
For usage-based billing (API call counts, token consumption, credits).
// 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 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
Query a client app's subscription status:
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)
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
npm install herald-sdkInitialization
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
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:
client.invalidateCache(browserToken)Billing and resource management
The subscription, points, and admin calls match sections 4-6 one for one, in camelCase:
// 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
Failed calls throw HeraldSdkError with a stable code:
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)
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. 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
npm install herald-auth-webThe package is ESM-only for bundlers. For pages with no build step, a minified IIFE bundle exposing a Herald global is published:
<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
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
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:
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:
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 is two SDK calls with a browser WebAuthn assertion in between:
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
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):
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
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.
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:
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
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:
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
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:3000for your backend,127.0.0.1:8080for 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 onapp.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.
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 for the full schema and White-label / Custom User UI 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 |