API Reference
BluAuth exposes four distinct surfaces, each with its own conventions:
- Protocol endpoints — OIDC (
/api/oidc/**), legacy OAuth broker (/oauth2/**), and discovery (/.well-known/**). These return spec-compliant payloads, not BluAuth's envelope. - Auth JSON API —
/api/auth/**. Session-cookie authenticated. Returns the BluAuth envelope. - Admin JSON API —
/api/admin/**. Admin-role required. Returns the BluAuth envelope. CSRF-protected via session cookie. - Provider token brokering —
/api/provider-tokens/**. Bearer-token authenticated. Returns the BluAuth envelope.
All JSON APIs wrap success/error responses in the canonical envelope documented in Response envelopes. Protocol endpoints follow their respective RFCs.
Response envelopes
Success
{
"success": true,
"data": { "...": "resource or collection" },
"meta": {
"pagination": { "page": 1, "pageSize": 20, "total": 143, "totalPages": 8 },
"count": 20,
"hasMore": true
}
}
meta is present on list endpoints only. debug is attached in non-production stages and includes timestamp, service, and version.
Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable message",
"status": 400,
"requestId": "req_01H...",
"fields": { "email": { "code": "invalid_format", "message": "Invalid email" } }
}
}
fields is present only on VALIDATION_ERROR responses derived from Zod issues. debug is present in non-production stages with stack, cause, timestamp, service, and version. See the Error Codes reference for every code.
Authentication
| Scheme | Where it applies | Credential |
|---|---|---|
| Session cookie | /api/auth/**, /api/admin/** | Better Auth session cookie issued by /api/auth/sign-in/* or OAuth callback |
| Admin role | /api/admin/** | Session belongs to a user with role = 'admin' (enforced via requireAdminRole) |
| OAuth Bearer | /api/oidc/userinfo, /api/provider-tokens/:provider | Authorization: Bearer <access_token> issued by /api/oidc/token |
| Client authentication | /api/oidc/token, /api/oidc/token/introspect, .../revoke | client_secret_basic, client_secret_post, or private_key_jwt (see token_endpoint_auth_methods_supported) |
| Cross-origin public | /.well-known/**, /api/oidc/authorize, /api/oidc/jwks, /api/oidc/end-session, /api/health, /api/auth/theme, /api/auth/providers, /api/auth/client-policy, /api/auth/invitation-context | None — publicly readable with CORS allow |
OIDC endpoints
All OIDC endpoints return spec-compliant payloads. Errors follow OIDC Core / OAuth 2.0 conventions ({"error": "...", "error_description": "..."}), not the BluAuth envelope.
| Method | Path | Purpose |
|---|---|---|
GET | /.well-known/openid-configuration | Discovery document |
GET | /api/oidc/authorize | Start authorization-code flow |
POST | /api/oidc/token | Exchange code for tokens (or client_credentials) |
POST | /api/oidc/token/introspect | Introspect an access token (RFC 7662) |
POST | /api/oidc/token/revoke | Revoke an access token (RFC 7009) |
GET | POST | /api/oidc/userinfo | Get user claims (Bearer-authed) |
GET | /api/oidc/jwks | Public keys for ID-token signature verification |
GET | /api/oidc/end-session | RP-Initiated logout (OpenID Connect RP-Initiated Logout 1.0) |
GET /.well-known/openid-configuration
Discovery document. Returns:
{
"issuer": "https://auth.blutools.io",
"authorization_endpoint": "https://auth.blutools.io/api/oidc/authorize",
"token_endpoint": "https://auth.blutools.io/api/oidc/token",
"userinfo_endpoint": "https://auth.blutools.io/api/oidc/userinfo",
"jwks_uri": "https://auth.blutools.io/api/oidc/jwks",
"introspection_endpoint": "https://auth.blutools.io/api/oidc/token/introspect",
"revocation_endpoint": "https://auth.blutools.io/api/oidc/token/revoke",
"end_session_endpoint": "https://auth.blutools.io/api/oidc/end-session",
"scopes_supported": ["openid", "profile", "email", "admin"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "client_credentials"],
"subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["ES256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "private_key_jwt"],
"code_challenge_methods_supported": ["S256"]
}
GET /api/oidc/authorize
Start the OIDC authorization-code flow. On success, redirects the user agent to redirect_uri with code and state.
Query parameters:
| Param | Required | Notes |
|---|---|---|
client_id | yes | UUID. Must match an active row in oauth_clients |
redirect_uri | yes | Must match one of the client's registered redirectUris |
response_type | yes | Must be code |
scope | yes | Space-delimited. All requested scopes must be in allowedScopes |
state | yes | Opaque relay value |
nonce | yes | Required — rejected if missing |
code_challenge | yes | Required (PKCE enforced) |
code_challenge_method | yes | Must be S256 |
Behavior:
- If the session is unauthenticated, redirects to
/login?unauthorized=true&client_slug=<slug>and storesreturnToas the OIDC continuation so the user is returned toauthorizeafter login. - If the session requires a password reset, redirects to
/reset-passwordwith continuation state.
POST /api/oidc/token
Exchange an authorization code for tokens, or mint a client_credentials token.
Authentication: client authentication per token_endpoint_auth_methods_supported. Accepts client_secret_basic, client_secret_post, or private_key_jwt.
Body (authorization_code): grant_type=authorization_code&code=<code>&redirect_uri=<uri>&code_verifier=<verifier>
Body (client_credentials): grant_type=client_credentials (public clients cannot use this grant — 400).
Response:
{
"access_token": "...",
"id_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}
client_credentials tokens carry sub = client_id with no user claims.
Responses set Cache-Control: no-store and Pragma: no-cache per OAuth 2.0 §5.1.
POST /api/oidc/token/introspect
RFC 7662. Authenticates the client, then returns the introspection response.
Body: token=<access_token>&token_type_hint=access_token
Response (active):
{
"active": true,
"sub": "<pairwise-subject-or-client-id>",
"client_id": "<client-uuid>",
"scope": "openid profile email",
"token_type": "Bearer",
"exp": 1713283200,
"iat": 1713279600
}
Returns {"active": false} when the token is unknown, expired, or belongs to a different client.
POST /api/oidc/token/revoke
RFC 7009. Always returns HTTP 200 with {"ok": true}, regardless of whether the token existed. Only revokes tokens that belong to the authenticating client.
Body: token=<access_token>&token_type_hint=access_token
GET / POST /api/oidc/userinfo
Returns claims for the authenticated user. Requires Authorization: Bearer <access_token>.
Claims returned reflect scopes granted to the token:
openid—subprofile—name,given_name,family_name,picture,preferred_usernameemail—email,email_verified
GET /api/oidc/jwks
Returns the signing-key JWKS for verifying BluAuth-issued ID tokens. Keys are ES256 on P-256. Rotated monthly by KeyRotationCron.
GET /api/oidc/end-session
RP-Initiated Logout (OpenID Connect RP-Initiated Logout 1.0). Clears the Better Auth session cookie, deletes the session row, then redirects.
Query parameters:
| Param | Required | Notes |
|---|---|---|
id_token_hint | no | Previously issued ID token |
post_logout_redirect_uri | no | Must match some active client's postLogoutRedirectUris — else 400 |
state | no | Opaque value relayed to the RP |
Legacy OAuth broker
Phase-1 compatibility surface. Downstream apps that predate OIDC integration still call these.
| Method | Path | Purpose |
|---|---|---|
GET | /oauth2/login | Start legacy broker flow |
GET | POST | /oauth2/callback | Return from upstream, redirect with session + returnTo |
GET /oauth2/login
Query parameters:
| Param | Notes |
|---|---|
provider | required — provider slug |
returnTo | relative path or a URL matching an active client's redirectUris |
mode | popup opts into window.postMessage completion |
origin | required when mode=popup — target origin for postMessage |
intent | link to attach the upstream account to the current session |
invite to claim an invitation after upstream authentication | |
invitation_token | required when intent=invite |
client_id | required for client-bound consent & scope escalation |
additional_scopes | space-delimited — merges with provider's base scopes |
prompt | OIDC prompt value — downgraded against provider prompt_values_supported |
On success, redirects the user agent to the upstream IdP's authorization URL.
When intent=invite, BluAuth validates the invitation token up front and rejects providers that are not linked to the invited client.
GET/POST /oauth2/callback
Exchanges the upstream code, resolves identity (create or link), issues a BluAuth session, publishes user.created, account.linked, and session.created events, and redirects to returnTo. On popup mode, returns HTML that calls window.opener.postMessage.
When the stored broker state has intent=invite, the callback validates the invitation again, re-checks provider eligibility for the invited client, accepts the invitation for the resolved user, and redirects to the invitation onboarding path instead of the generic returnTo.
Auth JSON API
Session-authenticated. All endpoints return the canonical envelope unless noted.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/auth/providers | public | Providers visible for client_slug (or default) |
GET | /api/auth/client-policy | public | Hosted-login password/signup policy for a client |
GET | /api/auth/invitation-context | public | Resolve invitation status and themed client context |
GET | /api/auth/theme | public | Resolve theme by client_slug or theme_id |
GET | /api/auth/linkable-providers | session | All active providers the user may link |
GET | /api/auth/security-state | session | Session metadata: user, requirePasswordReset, role |
POST | /api/auth/session-context | session | Stamp a clientSlug onto the active session |
POST | /api/auth/change-password | session | Change password for the authenticated user |
POST | /api/auth/accept-invite | public | Accept an invitation; creates or updates a user |
ALL | /api/auth/[...all] | mixed | Better Auth catch-all (sign-in, sign-up, reset, etc.) |
GET /api/auth/providers
Query: client_slug (optional — defaults to default).
Response: { success: true, data: ProviderInfo[] } where each item has { slug, name, type, buttonText, styleVariant, iconUrl }. Cached in Valkey for the PROVIDER_LIST TTL.
GET /api/auth/client-policy
Query: client_slug (optional — defaults to default).
Response: { success: true, data: { clientSlug, passwordAuthEnabled, selfServiceSignupEnabled, requireMfaForPasswordAuth } }.
Use this to drive hosted-login UX. Provider listings and password/signup policy are intentionally separate: /api/auth/providers tells you which upstream providers to render, while this endpoint tells you whether BluAuth should render or accept credential auth for that client_slug.
GET /api/auth/invitation-context
Query: token (required).
Response: { success: true, data: { status, tokenValid, email?, clientSlug?, clientName?, onboardingPath?, requiredProviderKeys, passwordAllowed } }.
This is the public preflight for /invite. It lets the hosted page resolve the correct client branding and invitation state before the user sets a password or begins upstream sign-in.
GET /api/auth/theme
Query: exactly one of client_slug or theme_id. theme_id takes priority.
Response: resolved theme payload — see Design Tokens for the full field list. Cached under CacheKey.theme(clientSlug).
GET /api/auth/linkable-providers
Requires session. Returns all active providers the authenticated user could link.
GET /api/auth/security-state
Returns:
{
"success": true,
"data": {
"authenticated": true,
"isAdmin": false,
"requirePasswordReset": false,
"user": { "id": "...", "email": "...", "name": "...", "role": "user" }
}
}
POST /api/auth/session-context
Body: { "clientSlug": "string" }. Writes clientSlug onto the current sessions row so downstream telemetry and webhook emissions can carry the initiating client.
POST /api/auth/change-password
Body: { "currentPassword": "string", "newPassword": "string (min 8)" }. Verifies against the current credential account, enforces the password policy (incl. pwned-password checks when enabled in service_settings), then clears requirePasswordReset.
Errors: AUTHENTICATION_REQUIRED if current password doesn't match; VALIDATION_ERROR if password policy fails.
POST /api/auth/accept-invite
Body: { "token": "string", "password": "string (min 8)" }. Accepts an invitation on the BluAuth password path; creates the user or updates the existing user's password. Publishes invitation.accepted before issuing the session. Returns { userId, clientSlug, onboardingPath }.
If an invitation requires one or more providers, this endpoint rejects the password path with a validation/business-rule error and the invite must be claimed through the OAuth broker instead.
Invite links can also be claimed through the OAuth broker with intent=invite. In invite mode, BluAuth validates the invitation token, verifies that the chosen provider is linked to the invited client, verifies the provider is one of the invitation's required providers when applicable, resolves the authenticated identity, and then accepts the invitation. If the provider email differs from the invited email, BluAuth can attach the invited address as a verified alias when the invite token proves control of that address.
ALL /api/auth/[...all]
Better Auth catch-all. Handles sign-in/sign-up, forgot-password, reset-password, email-verification, and all standard credential flows. Responses follow Better Auth's shape, not the BluAuth envelope.
Admin JSON API
Admin role required on every route. Returns the canonical envelope. CSRF enforced via session cookie. Rate-limited.
Users — /api/admin/users
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | / | page, pageSize≤100, search | Paginated list; filters deletedAt IS NULL |
POST | / | { email, name?, firstName?, lastName?, password | generatePassword, requirePasswordReset? } | 201 on create; 409 with existing user info if email collides |
GET | /:id | — | Full user payload |
PUT | /:id | partial user fields | Idempotent |
DELETE | /:id | — | Soft-deletes. Fires user.deleted before removal |
POST | /:id/password | { password | generatePassword: true, requirePasswordReset? } | 422 if SSO-only and allowPasswordForSsoUsers=false; returns temporaryPassword when generated |
GET | /:id/merge-preview | targetUserId | Preview entity moves |
POST | /:id/merge | { targetUserId, reason? } | Merges source into target; records an audit log |
Providers — /api/admin/providers
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | / | page, pageSize≤100, search, type | Paginated list |
POST | / | { name, slug, type, clientId, clientSecret, issuerUri?, discoveryUrl?, scopes? } | 201; auto-discovery fetched for type=oidc with discoveryEnabled; secrets encrypted via KMS |
GET | /:id | — | Excludes encrypted secrets |
PUT | /:id | partial provider fields | Refreshes discovery if issuerUri or discoveryUrl changes |
DELETE | /:id | — | Hard-delete. Cache invalidated |
POST | /:id/refresh-discovery | — | Re-fetches .well-known/openid-configuration and rewrites security policy |
Clients — /api/admin/clients
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | / | page, pageSize≤100, search | Paginated list |
POST | / | { name, slug, clientType?, redirectUris, postLogoutRedirectUris?, allowedScopes?, themeId?, allowedProviderTokens? } | 201. Issues a private_key_jwt assertion keypair — private key returned only on this response |
GET | /:id | — | Excludes private key |
PUT | /:id | partial client fields | Idempotent |
DELETE | /:id | — | Soft-deletes |
GET | /:id/providers | — | Providers linked to this client |
POST | /:id/providers | { providerId } | Link a provider |
DELETE | /:id/providers/:providerId | — | Unlink a provider |
POST | /:id/rotate-secret | — | Regenerates the private_key_jwt keypair. Private PEM returned once with warning |
Themes — /api/admin/themes
| Method | Path | Body | Notes |
|---|---|---|---|
GET | / | — | Unpaginated list (admin-only) |
POST | / | theme fields — see design tokens | 201 |
GET | /:id | — | |
PUT | /:id | partial theme fields | Invalidates cached theme for every client referencing it |
DELETE | /:id | — | Fails with RESOURCE_CONFLICT if any client uses it |
POST | /:id/duplicate | — | Clones theme with name = "<name> (copy)" |
Invitations — /api/admin/invitations
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | / | page, pageSize≤100, search, status, clientId | Paginated with client join |
POST | / | { email, clientId, context?, requiredProviderKeys?, ttlDays? (1-30) } | 201. Fires invitation.created. Email-send failure does not fail the request |
DELETE | /:id | — | Revokes a pending invitation. Fires invitation.revoked. 400 if invitation is already accepted/revoked |
Signing Keys — /api/admin/signing-keys
| Method | Path | Notes |
|---|---|---|
GET | / | Lists all keys with kid, algorithm, isActive, createdAt |
POST | /generate | Mints an ES256 keypair in KMS and a corresponding JWKS entry. 201 |
Webhooks — /api/admin/webhooks
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | / | page, pageSize≤100, clientId? | Paginated |
POST | / | { clientId, url, secret, events: string[], isActive? } | 201. secret KMS-encrypted at rest |
GET | /:id | — | Excludes secret |
PUT | /:id | partial fields | Supplying a new secret re-encrypts |
DELETE | /:id | — | Hard-delete |
GET | /:id/deliveries | page, pageSize≤100 | Paginated delivery log incl. statusCode, attempts, nextRetryAt, deliveredAt |
Settings — /api/admin/settings
| Method | Path | Body | Notes |
|---|---|---|---|
GET | / | — | Returns resolved service_settings |
PUT | / | { blockPwnedPasswords?, allowPasswordForSsoUsers? } | At least one field required |
Assets — /api/admin/assets/upload-url
| Method | Path | Body | Notes |
|---|---|---|---|
POST | / | { kind: 'theme_logo' | 'provider_icon' | 'theme_background', fileName, contentType, sizeBytes } | Returns { uploadUrl, cdnUrl, storageKey, expiresIn: 300 }. Upload directly to uploadUrl with PUT |
Email Test — /api/admin/email-test
| Method | Path | Body | Notes |
|---|---|---|---|
POST | / | { themeId: uuid | null, recipientEmail, previewOnly?, emailConcept?: 'password-reset' | 'welcome' | 'invitation' } | With previewOnly: true returns rendered HTML/text; otherwise sends via SES |
Provider token brokering
POST /api/provider-tokens/:provider
Mints / refreshes an upstream provider access token for the authenticated user.
Auth: Authorization: Bearer <bluauth-access-token>. The bearer is a BluAuth-issued access token from /api/oidc/token.
Body: { "requiredScopes": ["https://www.googleapis.com/auth/calendar.readonly", "..."] } (optional).
Response:
{
"success": true,
"data": {
"accessToken": "<upstream-provider-token>",
"expiresIn": 3540,
"provider": "google",
"scopes": ["openid", "email", "https://www.googleapis.com/auth/calendar.readonly"]
}
}
Authorization: the calling client's allowedProviderTokens must include :provider; a per-client grant must exist; granted scopes must cover requiredScopes.
Errors:
INVALID_TOKEN(401) — missing or unknown bearerUNAUTHORIZED_CLIENT(403) — client not permitted for this providerNO_LINKED_ACCOUNT(404) — user has no linked account for:providerINSUFFICIENT_SCOPE(403) — grant exists but does not coverrequiredScopesREAUTH_REQUIRED(502) — upstream refresh token revoked; user must re-loginUPSTREAM_PROVIDER_ERROR(502) — provider returned a transient failure
Responses always set Cache-Control: no-store, private.
See the provider token brokering guide for end-to-end integration patterns.
Operations
GET /api/health
Liveness + dependency check. Returns HTTP 200 when healthy, 503 otherwise.
{
"status": "healthy",
"checks": {
"database": { "status": "ok", "latency": 12 },
"cache": { "status": "ok", "latency": 2 }
},
"timestamp": "2026-04-17T10:30:00.000Z"
}
Cache status can be ok, degraded, or warming. warming does not flip healthy false.
Conventions
- Pagination —
page(≥1) andpageSize(≤100). Responsemeta.pagination = { page, pageSize, total, totalPages }. - Timestamps — ISO-8601 UTC with
Zsuffix. Server usesnowUTC()throughout. - IDs — UUID v4 unless otherwise stated. URL params are parsed via
getRouterParam. - Secrets in responses — returned exactly once (client
clientAssertionPrivateKey,temporaryPassword, upload presigns). Never logged; never re-fetchable. - Cache headers — token and provider-token endpoints set
Cache-Control: no-store. Other endpoints use defaults. - Request IDs — every error response carries
requestId. Log it on the client to correlate with server-side Sentry issues. - CORS —
authorize,userinfo,jwks,end-session,/.well-known/**,/api/auth/providers,/api/auth/client-policy, and/api/auth/invitation-contextallow cross-origin reads.token,token/introspect,token/revokeare server-to-server.