Reference

API

Implemented HTTP, OIDC/OAuth, session, admin, MFA, account lifecycle, and SCIM APIs.

This document describes the implemented v1 API surface. All browser session endpoints use an HttpOnly cairn_session cookie. CSRF-protected browser mutations use an HttpOnly cairn_csrf cookie plus the returned X-CAIRN-CSRF token. In production, cookies are emitted with the Secure flag. Logout clears both browser cookies.

Public OIDC/OAuth

  • GET /.well-known/openid-configuration: returns issuer metadata for strict code-flow clients, including query response mode, PKCE S256, supported claims, supported ACR values, supported prompt values none, login, and consent, supported display values page, popup, touch, and wap, no claims/request/request_uri parameter support, RFC 9207 authorization-response issuer support, and the RP-initiated logout end_session_endpoint.
  • GET /.well-known/jwks.json: returns active public signing keys; database-backed keys are preferred over legacy static env material. Use cairn-api operations preflight to confirm the active database kid is exposed, decryptable, and part of a healthy signing-key lifecycle.
  • GET /oauth2/authorize: strictly decodes URL-encoded query parameters up to 8 KiB, then validates client, exact redirect URI, response_type=code, syntactically valid OAuth scope tokens including openid, max_age when present, response_mode when present, prompt when present, display when present, rejects duplicate registered authorization request parameters, rejects unsupported claims, request, and request_uri parameters, validates RFC 7636 code_challenge syntax, and requires code_challenge_method=S256; redirects to login or consent when required. Only omitted response_mode and response_mode=query are supported in v1. Standard OIDC display values, voluntary acr_values, ui_locales, and claims_locales preferences are accepted and preserved through login/consent redirects; ID tokens return the actual browser session acr. Locale preferences are treated as client preferences only in v1; the UI uses its default language and no localized claim transformation is performed yet. prompt=none never shows UI and returns login_required or consent_required to the validated redirect URI when interaction is needed. Clients assigned an org-scoped consent policy template with grant_mode="always_required" cannot satisfy consent from an existing grant; they show consent on every interactive authorization and return consent_required for prompt=none. prompt=login forces reauthentication and strips only login from the post-login return URL to avoid a loop. prompt=consent forces the consent screen and strips only consent from the post-consent return URL. Oversized or malformed query encoding, unknown clients, invalid redirect URIs, and duplicate client_id or redirect_uri parameters return local 400 responses. Once the client and redirect URI are valid, authorization request errors, including disabled clients returning unauthorized_client and malformed or negative max_age, are returned to the registered redirect URI with OAuth error, optional error_description, preserved state, and RFC 9207 iss; missing response_type maps to invalid_request, while a supplied unsupported response type maps to unsupported_response_type. Authorization redirects set no-store/no-cache headers because they can carry codes, errors, state, or return URLs.
  • POST /oauth2/token: supports authorization_code, refresh_token, and client_credentials using application/x-www-form-urlencoded request bodies; media-type parameters such as charset=UTF-8 are accepted. Public clients use client_id with no secret for browser/user grants, and confidential clients may use client_secret_basic or client_secret_post. Disabled clients fail client authentication with OAuth invalid_client. Duplicate or malformed Authorization headers return OAuth invalid_request. Authorization-code exchanges require nonblank code, redirect_uri, and code_verifier; missing or blank required parameters return invalid_request, while redirect URI mismatch or failed PKCE verification returns invalid_grant. Authorization-code exchanges return a refresh token only when the stored grant includes offline_access and the client allows the refresh-token grant. Refresh-token exchanges require a nonblank refresh_token; missing or blank values return invalid_request, while unknown, expired, revoked, or reused values return invalid_grant. Refresh-token requests may omit scope to keep the original grant or request a syntactically valid narrower scope set; malformed scopes or scopes outside the original grant return invalid_scope. Client-credentials exchanges require a confidential client with a stored secret, and requested scopes must be valid and registered on the client. Missing, blank, or syntactically invalid grant_type returns invalid_request, unknown syntactically valid grant types return unsupported_grant_type, and registered clients using a supported but disallowed grant return unauthorized_client. The handler reads form bodies with its own 16 KiB limit before parsing, so missing/wrong content type, malformed form bodies, duplicate form parameters, or oversized bodies return OAuth invalid_request JSON with no-store/no-cache headers rather than framework-default errors. invalid_client responses use 401 Unauthorized with a WWW-Authenticate: Basic realm="cairn" challenge.
  • GET/POST /oauth2/userinfo: requires an active opaque bearer access token with a user subject and an active issuing OIDC client; bearer failures return RFC 6750 WWW-Authenticate challenges. GET supports Authorization-header Bearer tokens; POST supports Authorization-header Bearer tokens and application/x-www-form-urlencoded access_token body tokens. Duplicate Authorization headers, oversized or malformed query encoding, and malformed or oversized POST form bodies are treated as malformed bearer requests.
  • POST /oauth2/introspect: requires application/x-www-form-urlencoded, requires client authentication, accepts token_type_hint=access_token or token_type_hint=refresh_token, and returns token activity metadata for opaque access or refresh tokens issued to that client. Unknown hints are ignored and the service searches all supported token types. Missing or blank token, duplicate or malformed Authorization headers, missing/wrong content type, malformed form bodies, duplicate form parameters, or form bodies larger than 16 KiB return OAuth invalid_request JSON with no-store/no-cache headers from the endpoint’s explicit body parser.
  • POST /oauth2/revoke: requires application/x-www-form-urlencoded, requires client authentication, accepts token_type_hint=access_token or token_type_hint=refresh_token, and revokes opaque access tokens issued to that client; refresh-token revocation revokes the token family and linked access tokens for the same client. Unknown hints are ignored and the service searches all supported token types. Missing or blank token, duplicate or malformed Authorization headers, missing/wrong content type, malformed form bodies, duplicate form parameters, or form bodies larger than 16 KiB return OAuth invalid_request JSON with no-store/no-cache headers from the endpoint’s explicit body parser.
  • GET/POST /oauth2/logout: RP-initiated logout endpoint. The endpoint accepts id_token_hint, logout_hint, client_id, post_logout_redirect_uri, state, and ui_locales. GET query parameters are strictly decoded up to 8 KiB. POST requires application/x-www-form-urlencoded and strictly decodes form bodies up to 16 KiB. Duplicate registered logout parameters, malformed encoding, oversized requests, and wrong POST content types return local 400 responses with no-store/no-cache headers before token or session lookup. A valid RS256 id_token_hint issued by the active Cairn signing key is required in v1 because there is no interactive logout confirmation page yet; expired hints are accepted for logout when their signature, kid, issuer, and audience are valid. When a valid browser session cookie is present, the OP session is revoked, then cairn_session and cairn_csrf are cleared. post_logout_redirect_uri is used only when the validated token audience identifies a client in the active organization, optional client_id matches that audience, and the URI exactly matches one of that client’s registered post_logout_redirect_uris; state is echoed to that redirect when present. Logout responses set no-store/no-cache headers. Invalid logout requests never redirect.

Unsupported by design: implicit grant, hybrid grant, and resource-owner password grant.

SCIM v2 Provisioning

SCIM endpoints are served under /scim/v2/* and are disabled until CAIRN_SCIM_BEARER_TOKEN_SHA256 is configured. They use bearer authentication only; browser sessions and CSRF cookies are not accepted for SCIM. The configured token-hash value may contain a bounded comma-separated hash set during token rotation.

  • GET /scim/v2/ServiceProviderConfig: returns supported SCIM capabilities. v1 advertises bounded User/Group PATCH support and bounded Bulk mutations while keeping sort, ETags, cursor pagination, password change, and shared-signal events disabled.
  • GET /scim/v2/Schemas: returns the supported User and Group schemas in a SCIM ListResponse.
  • GET /scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User: returns the supported User schema subset.
  • GET /scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group: returns the supported Group schema subset.
  • GET /scim/v2/ResourceTypes: returns the supported User and Group resource types in a SCIM ListResponse.
  • GET /scim/v2/ResourceTypes/User: returns the supported User resource type.
  • GET /scim/v2/ResourceTypes/Group: returns the supported Group resource type.
  • POST /scim/v2/Bulk: processes 1 to 50 User/Group mutation operations with bounded bulkId: dependency scheduling and returns a SCIM BulkResponse.
  • GET /scim/v2/Users: lists users with bounded pagination and exact filters.
  • POST /scim/v2/Users/.search: runs a SCIM SearchRequest over users using the same bounded pagination, exact filters, and projection behavior as GET /Users.
  • POST /scim/v2/Users: creates a passwordless user.
  • GET /scim/v2/Users/{user_id}: returns an organization-owned SCIM user resource.
  • PUT /scim/v2/Users/{user_id}: full replacement of the supported user fields.
  • PATCH /scim/v2/Users/{user_id}: bounded PatchOp updates for the supported user fields.
  • DELETE /scim/v2/Users/{user_id}: soft-deprovisions the user by setting status to suspended.
  • GET /scim/v2/Groups: lists groups with bounded pagination and exact filters.
  • POST /scim/v2/Groups/.search: runs a SCIM SearchRequest over groups using the same bounded pagination, exact filters, and projection behavior as GET /Groups.
  • POST /scim/v2/Groups: creates a group and optional user memberships.
  • GET /scim/v2/Groups/{group_id}: returns an organization-owned SCIM group resource.
  • PUT /scim/v2/Groups/{group_id}: full replacement of group display/external ID and user membership set.
  • PATCH /scim/v2/Groups/{group_id}: bounded PatchOp updates for group fields and user memberships.
  • DELETE /scim/v2/Groups/{group_id}: deletes a non-protected group.

All SCIM responses use application/scim+json and set no-store/no-cache headers. Mutating bodies accept application/scim+json and application/json, are capped at 256 KiB, and return SCIM Error schema responses for malformed or unsupported input. Query strings are capped at 2 KiB.

GET /Users accepts startIndex with default 1 and max 10000, count with default 100 and max 200, optional exact filter, and one of attributes or excludedAttributes for bounded resource projection. POST /Users/.search accepts the SearchRequest schema with the same startIndex, count, filter, attributes, and excludedAttributes fields in the JSON body; sortBy and sortOrder are rejected because sort is not supported. Supported filters are userName eq "email@example.com", externalId eq "stable-id", and active eq true|false, joined with and. Projection supports the stored User fields and known sub-attributes including name.formatted, emails.value, and meta.location; schemas and id remain present as minimum resource attributes. Unsupported filter/projection attributes, duplicate parameters, mutually exclusive projection parameters, malformed string escapes, unquoted string filter values, unsupported query parameters, unsupported SearchRequest fields, or missing SearchRequest schema return SCIM invalidValue or invalidFilter errors.

GET /Groups and POST /Groups/.search accept the same pagination, exact filter, and projection behavior. Supported filters are displayName eq "Engineering" and externalId eq "stable-group-id", joined with and. Projection supports the stored Group fields and known sub-attributes including members.value, members.$ref, members.display, members.type, and meta.location.

SCIM user resources map userName to normalized email, optional externalId to a tenant-unique external provisioning ID, displayName or name fields to display name, active to the active/suspended lifecycle state, and emails[] to the single primary work email. When emails are supplied, each emails[].value must normalize to the same value as userName; emails[].type, when present, must be work; responses canonicalize the stored work email as primary=true.

PATCH /Users/{user_id} requires the PatchOp schema and 1 to 20 operations. add and replace support userName, externalId, displayName, active, name, name.formatted, name.givenName, name.familyName, emails, emails.value, emails.type, emails.primary, and filtered primary work email paths such as emails[type eq "work"].value, emails[primary eq true].primary, and emails[value eq "current@example.com"].type. emails.type accepts only work, and emails.primary accepts only true, because the local model stores exactly one primary work email. Omitted path is accepted for object-valued add or replace operations. remove is supported only for externalId; attempts to remove required local user fields return SCIM mutability errors. Filtered email paths that do not match the stored primary work email return noTarget.

Creating a SCIM user does not set a password. Provisioned users should be onboarded through invitation, verification, or recovery flows. Replacing, patching, or deleting a user with active=false/soft deprovisioning revokes browser sessions, access tokens, and refresh tokens in the same transaction. SCIM cannot deactivate the final active owner of the built-in administrators group.

SCIM group resources map displayName to the local group display name, optional externalId to a tenant-unique external provisioning ID, and members[].value to existing organization user IDs. Nested group members are rejected; v1 accepts only type="User" when type is supplied. SCIM can read the built-in administrators group but cannot replace, patch, or delete it.

PATCH /Groups/{group_id} requires the PatchOp schema and 1 to 20 operations. add and replace support displayName, externalId, members, members.value, filtered single-member paths such as members[value eq "<user-uuid>"], filtered member value paths such as members[value eq "<user-uuid>"].value, and object-valued operations without path. replace members replaces the full user membership set, while add members appends new user members and leaves existing members in place; members.value uses UUID string values instead of member objects. Filtered member add is idempotent and requires the value to match the path filter; filtered member replace requires an existing selected member and a value that identifies exactly one user. Generated member sub-attributes such as members.display, members.type, and members.$ref are not mutable. remove supports externalId, members, members.value, and filtered member removal paths; filtered member removals are idempotent when the selected user is not currently a member.

POST /Bulk requires the BulkRequest schema and supports the same User and Group POST, PUT, PATCH, and DELETE mutations as the direct endpoints. POST operations must include unique bulkId values. Operations in the same Bulk request may reference a successful POST with bulkId:<bulk-id> JSON string values or path segments, including forward references when the bounded scheduler can resolve the dependency order. failOnErrors is optional and, when supplied, must be 1 to 50. Operations use direct-endpoint validation, tenant scoping, audit events, lifecycle revocation, final-administrator protection, protected-group checks, and no cross-operation rollback. Unknown references, references to failed POST operations, and malformed references return invalidValue; unresolved dependency cycles return per-operation 409 Conflict.

See scim.md for setup and operator smoke tests.

Admin API

Admin APIs require a valid browser session whose user has owner membership in the organization’s built-in administrators group. POST /api/v1/bootstrap is the only exception and creates that initial owner membership atomically.

Mutating JSON APIs require application/json or an application/*+json media type and use a 256 KiB request-body limit. Missing JSON content type, malformed JSON, and oversized JSON bodies return the standard admin { "error": "..." } shape with no-store/no-cache headers before handler logic runs.

Admin list APIs use keyset pagination and return { "items": [...], "next_cursor": "..." }; next_cursor is null when there is no next page. They accept optional limit and cursor query parameters. The default limit is 100 items, regular admin lists are capped at 250 items, and group membership lists are capped at 500 items. Cursors are opaque URL-safe values returned by the previous response. User lists additionally accept q for indexed case-insensitive email/display-name prefix search and status=active|suspended|locked for indexed lifecycle-state filtering. OIDC client lists additionally accept q for indexed case-insensitive client ID/name prefix search, client_type=public|confidential, status=active|disabled, grant_type=authorization_code|refresh_token|client_credentials, and an exact OAuth scope token. Audit-event lists additionally accept bounded indexed filters: action and target as case-insensitive prefix filters, actor_kind=user|client|system, actor_id=<uuid>, and RFC 3339 from/to created-at bounds where from is inclusive and to is exclusive. Audit export uses the same filter and cursor parameters, emits application/x-ndjson, defaults to 1000 rows, is capped by CAIRN_AUDIT_EXPORT_MAX_ROWS, and returns x-cairn-next-cursor when more export rows are available. Malformed query encoding, duplicate parameters, unsupported query parameters, malformed cursors, invalid filters, non-positive limits, and limits above the endpoint cap return the standard admin { "error": "..." } shape with no-store/no-cache headers after admin authorization.

  • POST /api/v1/bootstrap: creates the first admin user, built-in administrators group, and owner membership only while the default organization has zero users.
  • POST /api/v1/session/login: creates a browser session from email/password and writes a session.logged_in audit event after successful completion. When the bounded IP/user-agent tuple has not previously created a session for that user, the session insert also queues a token-free new_login_notification email and links its outbox row from the login audit metadata. Failed credentials and incomplete MFA challenges do not queue notifications.
  • POST /api/v1/session/reauthenticate: verifies the current session user’s password and configured MFA, rotates the browser session, writes a session.reauthenticated audit event, and returns fresh session acr/amr values. If MFA is configured and no second factor is supplied, returns status="mfa_required" with the same methods and optional WebAuthn challenge shape as login.
  • POST /api/v1/session/password/change: CSRF-protected self-service password change for the current user. The body is { "current_password": "...", "new_password": "..." }. The API verifies the current password, rejects passwordless or non-active users, requires recent MFA proof when an active TOTP or passkey credential exists, rejects reuse of the current password, hashes the new password with Argon2, rotates the browser session, revokes all previous browser sessions plus user access and refresh tokens, consumes pending unexpired password-recovery account tokens, queues a token-free password_changed_notification email with bounded request context in the same database transaction, writes account.password_changed, and returns revocation counts plus the new session acr/amr.
  • POST /api/v1/session/logout: revokes the current browser session, clears cairn_session and cairn_csrf, and writes a session.logged_out audit event when a valid session is present.
  • GET /api/v1/session/me: returns the current user and session metadata.
  • GET /api/v1/session/csrf: issues a CSRF token and cairn_csrf cookie for browser API mutations.
  • GET /api/v1/session/browser-sessions: returns up to 100 active, unrevoked, unexpired browser sessions for the current user only. Each row includes the session ID, current marker, acr, amr, RFC 3339 created_at/expires_at, and bounded creation request context (created_ip_address, created_user_agent) when available.
  • DELETE /api/v1/session/browser-sessions/{session_id}: CSRF-protected self-service revocation for another active browser session owned by the current user. The route returns 400 for the current session so callers use logout for that case, returns 404 for expired/revoked/foreign sessions, writes session.revoked_by_user, and does not revoke OAuth tokens.
  • POST /api/v1/consent: CSRF-protected current-user approval for an OIDC consent screen. Requests include client_id, the exact authorize return_to, and approved scopes; the API validates that return_to is this issuer’s canonical /oauth2/authorize request for the same client and scope set, writes the durable consent grant, and writes a short-lived one-use request-bound consent authorization marker for the immediate authorize retry.
  • GET /api/v1/session/mfa/credentials: lists the current user’s non-recovery MFA credentials and the active recovery-code count without returning secret metadata.
  • DELETE /api/v1/session/mfa/credentials/{credential_id}: revokes a current-user TOTP or WebAuthn credential and audits the change. Requires CSRF plus a current browser session whose TOTP, WebAuthn, or recovery-code MFA proof is no more than 15 minutes old.
  • POST /api/v1/session/mfa/recovery-codes/regenerate: requires CSRF plus recent MFA proof, revokes active recovery codes, creates a fresh one-use recovery-code set, audits the change, and returns the new codes once. Requires at least one active TOTP or WebAuthn credential.
  • POST /api/v1/session/mfa/totp/start: starts encrypted TOTP enrollment for the current session user.
  • POST /api/v1/session/mfa/totp/confirm: verifies a pending TOTP enrollment and activates the credential.
  • POST /api/v1/session/mfa/webauthn/start: starts passkey enrollment for the current session user and returns browser WebAuthn creation options plus a one-time challenge ID.
  • POST /api/v1/session/mfa/webauthn/finish: consumes a passkey enrollment challenge, verifies the browser credential, and stores the active passkey.
  • POST /api/v1/session/email-verification/request: queues a verification email for the current user.
  • POST /api/v1/session/email-verification/confirm: consumes a verification token and marks the user email verified.
  • POST /api/v1/session/password-recovery/request: queues a recovery email for an active password-bearing user without disclosing account existence.
  • POST /api/v1/session/password-recovery/complete: consumes a recovery token, consumes any other pending unexpired password-recovery tokens for the same user, sets a new password, verifies email, revokes existing browser sessions plus user access and refresh tokens, queues a token-free password_recovered_notification email with bounded request context in the same database transaction, writes account.password_recovered, and returns revocation counts.
  • GET /api/v1/session/consent-grants: list the current user’s OIDC consent-grant history with client display metadata, granted scopes, and revocation state. Supports bounded keyset pagination and status=all|active|revoked.
  • DELETE /api/v1/session/consent-grants/{grant_id}: CSRF-protected self-service consent revocation for the current user. The selected grant identifies the client; the transaction revokes all active current-user grants for that client, invalidates pending authorization codes, and revokes matching access and refresh tokens.
  • POST /api/v1/invitations: admin-only, queues an invitation for a new or passwordless user.
  • POST /api/v1/invitations/accept: consumes an invitation token, sets the password, and verifies email.
  • GET/POST /api/v1/users: list and create organization users.
  • PUT /api/v1/users/{user_id}/status: set a user status to active, suspended, or locked.
  • POST /api/v1/users/{user_id}/email-verification/request: admin-only CSRF-protected lifecycle action that queues a verification email for an active unverified organization user. It returns the normal lifecycle delivery shape, including preview_url only in development, and returns status="already_verified" when the email is already verified.
  • POST /api/v1/users/{user_id}/password-recovery/request: admin-only CSRF-protected lifecycle action that queues a recovery email for an active password-bearing organization user. It returns the normal lifecycle delivery shape, including preview_url only in development.
  • GET /api/v1/users/{user_id}/security-events: admin-only tenant-scoped review of audit events linked to an organization-owned user. The list includes events where the selected user is the audit target, the user actor, metadata.subject_user_id, or metadata.user_id, and uses the same bounded keyset pagination contract as other admin lists.
  • GET /api/v1/users/{user_id}/browser-sessions: admin-only list of up to 100 active, unrevoked, unexpired browser sessions for an organization-owned user. Rows use the same response shape as the current-user browser-session API and the current marker is true only when the listed row is the admin actor’s current session.
  • DELETE /api/v1/users/{user_id}/browser-sessions/{session_id}: admin-only CSRF-protected revocation for another active browser session owned by the selected organization user. The route returns 400 for the admin actor’s current session, returns 404 for foreign, expired, revoked, or missing sessions/users, writes admin.user_session_revoked, and does not revoke OAuth tokens.
  • GET/POST /api/v1/groups: list and create organization groups.
  • GET /api/v1/groups/{group_id}/memberships: list group memberships.
  • PUT /api/v1/groups/{group_id}/memberships/{user_id}: create or update a group membership with role member or owner.
  • DELETE /api/v1/groups/{group_id}/memberships/{user_id}: remove a group membership.
  • GET/POST /api/v1/oidc/consent-policy-templates: list and create reusable organization-scoped consent policy templates. Create requests require slug, name, and grant_mode, where v1 supports required_once and always_required. Templates never bypass consent; always_required forces a fresh consent decision for every interactive authorization.
  • GET/POST /api/v1/oidc/clients: list and create OIDC clients with exact authorization redirect URIs, exact post-logout redirect URIs, unique valid OAuth scope tokens, and an optional consent_policy_template_id from the active organization. openid is always retained on created clients because v1 clients are OIDC clients, and new clients start with status="active". The list endpoint supports keyset pagination plus indexed search, type, status, grant-type, and scope filters. Admin client responses include status and has_client_secret but never expose the stored client_secret_hash; confidential-client creation returns the raw client_secret once.
  • POST /api/v1/oidc/clients/{client_id}/secret/rotate: admin-only CSRF-protected confidential-client secret rotation for an organization-owned OIDC client. The route replaces the stored secret hash, writes an audit event, and returns the new raw client_secret once with the sanitized client shape. Public clients return 409 Conflict.
  • PUT /api/v1/oidc/clients/{client_id}/status: admin-only CSRF-protected lifecycle update for an organization-owned OIDC client. The JSON body is { "status": "active" } or { "status": "disabled" }. Disabling a client invalidates unexpired pending authorization codes and revokes active access and refresh tokens for that client in the same transaction, writes audit evidence with the affected counts, and blocks authorization, token, UserInfo, introspection, revocation, consent, and logout redirect client use. Reactivating a client does not un-revoke old credentials.
  • GET /api/v1/oidc/clients/{client_id}/consent-grants: list consent-grant history for one organization-owned OIDC client, including consenting user email/display name, granted scopes, and revocation state. The route uses the same bounded keyset list parser as other admin lists, supports status=all|active|revoked, and returns 404 for clients outside the active organization.
  • DELETE /api/v1/oidc/clients/{client_id}/consent-grants/{grant_id}: admin-only CSRF-protected consent revocation. The selected grant identifies the consenting user; the transaction revokes all active consent grants for that user-client pair, invalidates unexpired pending authorization codes for that pair, and revokes matching user access and refresh tokens.
  • GET /api/v1/audit-events: list recent audit events with keyset pagination and optional indexed filters for action, target, actor kind, actor ID, and created-at range.
  • GET /api/v1/audit-events/export: export one bounded page of filtered audit events as NDJSON for operator download or archive ingestion.
  • GET /api/v1/settings: runtime settings safe for admin display.

Token Behavior

  • Access tokens are opaque random secrets stored only as SHA-256 hashes.
  • Authorization codes are one-use and protected by RFC 7636 PKCE S256 syntax and verifier checks.
  • Consent approval writes a durable consent grant plus a five-minute one-use consent authorization marker bound to organization, user, browser session, client, approved scopes, and the canonical authorize request hash. always_required client policies consume that marker on the immediate authorize retry; prompt=none never consumes it and still returns consent_required.
  • Authorization-code and refresh-token exchanges are bound to the client recorded on the original grant. A different client cannot exchange another client’s authorization code or refresh token even with the raw token value.
  • OAuth client authentication and opaque bearer/refresh-token use are tenant-bound to the current issuer organization. Tokens stored for another organization are rejected even if their raw value is presented to this deployment. OAuth invalid_client errors return an RFC 6749-compatible Basic challenge while preserving no-store/no-cache response headers, and client grant policy failures return unauthorized_client instead of unsupported_grant_type.
  • Disabled OIDC clients cannot start authorization, authenticate to OAuth client endpoints, use UserInfo bearer tokens issued to that client, approve consent, or resolve RP-initiated logout redirects. Reactivation only restores future client use; it never revives authorization codes, access tokens, or refresh tokens revoked during disable.
  • Refresh tokens are issued only for offline_access authorization-code grants whose client allows refresh_token; they rotate on every use. A refresh-token request with no scope keeps the original grant, while a request with scope can only narrow to scopes already present on the stored refresh token. The narrowed scope set is persisted on the rotated refresh token and issued access token. Access tokens issued with a refresh token store the refresh family id so refresh-token reuse or revocation revokes both the refresh family and linked access tokens. Authorization-code and refresh-token exchange reject non-active user subjects even if stale unrevoked grant, session, or refresh-token rows exist.
  • ID tokens are RS256 JWTs. The preferred signing path uses the active database-backed key encrypted by CAIRN_KEY_ENCRYPTION_KEY; legacy static CAIRN_SIGNING_* env material remains supported as fallback/import material. User ID tokens include auth_time from the browser session authentication time so max_age requests are auditable by clients, include the session acr/amr values established by password, TOTP, recovery-code, or WebAuthn login, and only emit standard user claims requested by scope.
  • Standard user claims are scope-gated in both ID tokens and /oauth2/userinfo: email emits email and email_verified, profile emits name, and groups emits current tenant-scoped group slugs sorted by slug.
  • /oauth2/introspect accepts token_type_hint=access_token and token_type_hint=refresh_token, checks access and refresh tokens, and returns bounded RFC 7662-style metadata for active tokens: active, client_id, scope, iss, iat, exp, optional sub, and token_type="Bearer" for access tokens. Unsupported hint values are ignored. Inactive, missing, unauthorized, expired, revoked, rotated, or non-active user-subject tokens return only { "active": false }.
  • Account lifecycle tokens are one-use random secrets. Postgres stores only SHA-256 hashes in account_tokens; delivery tokens are encrypted into email_outbox with AES-256-GCM when CAIRN_KEY_ENCRYPTION_KEY is configured. Completing password recovery consumes the submitted recovery token plus any other pending unexpired recovery tokens for that user, revokes old browser sessions plus user OAuth tokens, and queues a token-free password-recovered notification. Self-service password change consumes pending unexpired password-recovery tokens for that user so old recovery links cannot remain useful after an authenticated password rotation, and password-change plus first-seen login-context notifications queue token-free security emails that do not require action URL rendering.
  • Email outbox delivery uses cairn-api email-outbox deliver-once, which claims queued/retry rows, invokes the configured provider, and marks rows sent, retry, or failed. cairn-api operations preflight reports provider, command-path, KEK, batch, retry, timeout, worker, provider-smoke, and redacted queue-health posture before production delivery is enabled; production preflight fails when failed outbox rows are present. Operators can run cairn-api email-outbox smoke-provider <recipient-email> to validate the configured provider with a synthetic token-free email before delivering real lifecycle links; the smoke receipt includes RFC3339 completed_at and optional provider message ID evidence.
  • /oauth2/userinfo returns email_verified from the persisted user record only when the access token includes the email scope.
  • /oauth2/userinfo supports both GET and POST. It accepts bearer tokens through the Authorization header and, for POST only, the RFC 6750 application/x-www-form-urlencoded access_token body parameter. Requests that use more than one bearer token transport method return invalid_request; URI query access_token bearer parameters are intentionally unsupported and rejected as invalid_request. Query strings larger than 8 KiB, malformed query encoding, form bodies larger than 16 KiB, or malformed form encoding are rejected as invalid_request before token lookup. Missing credentials or unsupported authorization schemes return a bare WWW-Authenticate: Bearer realm="cairn" challenge without an error code. Duplicate or malformed Bearer headers/body parameter values return invalid_request; unknown/expired/revoked bearer tokens and tokens for non-active user subjects return invalid_token; client-credentials tokens without a user subject return insufficient_scope. Bearer challenge auth-parameter values are sanitized to the RFC 6750 visible-ASCII character set before insertion into WWW-Authenticate.
  • TOTP MFA secrets are AES-256-GCM encrypted in mfa_credentials.secret_metadata with AAD bound to organization and user. Once active TOTP exists for a user, password login accepts mfa_code, totp_code, or recovery_code; successful TOTP sessions include amr=["pwd","otp"], and successful recovery-code sessions consume the code and include amr=["pwd","recovery"].
  • WebAuthn/passkey enrollment and login store ceremony state in webauthn_challenges, never in browser-controlled state. Challenges are tenant-scoped, expire after 5 minutes, and are consumed once under a row lock. Active passkeys are stored in mfa_credentials.secret_metadata as WebAuthn public credential material plus a stable credential ID; a partial unique index prevents duplicate active passkey IDs per organization. Password login with an active passkey returns status="mfa_required" plus WebAuthn request options, then issues sessions with acr="urn:cairn:acr:password+webauthn" and amr=["pwd","mfa","user"] after a valid assertion.
  • MFA credential listing returns only id, kind, label, status, created_at, last_used_at, and an active recovery-code count. TOTP/passkey revocation, recovery-code regeneration, and self-service password change require recent MFA proof from the current browser session when an active second factor exists; the account UI uses session reauthentication to renew that proof without a full sign-out. Recovery-code regeneration revokes active recovery codes before returning a fresh one-use set. Recovery codes are also revoked when the last active second factor is removed. TOTP confirmation revokes older active recovery codes before returning the new one-use code set.
  • Browser session creation stores bounded IP address and user-agent context for successful login, reauthentication rotation, and authenticated password change rotation. Successful login queues a token-free new_login_notification only when that exact bounded IP/user-agent tuple is not already known for the user. The account API exposes only the current user’s active browser sessions, rejects self-revocation through the browser-session revoke endpoint, and records an audit event when the user revokes another browser session. Organization admins can review and revoke active browser sessions for organization-owned users through tenant-scoped APIs; the admin revoke route rejects the actor’s current browser session so logout remains the self-session termination path. Admins can also review user-linked security activity through an indexed, tenant-scoped audit-event list for actor, target, and user metadata matches.
  • OAuth authorization redirects, logout responses, token, userinfo, introspection, revocation, bearer challenge, OAuth error responses, and all SCIM responses set Cache-Control: no-store and Pragma: no-cache. All /api/v1/* responses also set no-store/no-cache headers because they can carry browser session state, CSRF tokens, admin data, or account lifecycle metadata. Protocol POST endpoints read bounded form bodies explicitly so malformed or oversized body failures are normalized into OAuth, Bearer, logout, or SCIM error shapes instead of returning framework-default errors.

Error Shape

OAuth endpoints return OAuth-compatible bodies:

{
  "error": "invalid_request",
  "error_description": "human readable context"
}

OAuth error_description values are sanitized to the RFC 6749 visible-ASCII character set before they are returned in JSON bodies or redirect query parameters. Characters outside that set, including control characters, double quotes, backslashes, and non-ASCII text, are replaced with spaces.

Admin endpoints return:

{
  "error": "human readable context"
}

Browser CSRF

Cookie-authenticated browser mutations require a double-submit CSRF token and, when the browser sends Origin or Referer, that origin must match CAIRN_PUBLIC_WEB_ORIGIN:

  1. The web client calls GET /api/v1/session/csrf.
  2. The API returns { "csrf_token": "..." } and sets an HttpOnly cairn_csrf cookie.
  3. The web client sends the token in X-CAIRN-CSRF for unsafe methods.
  4. The API rejects empty, malformed, or mismatched tokens before changing state. Valid CSRF tokens are bounded URL-safe random values issued by the API.

This applies to bootstrap, login, session reauthentication, password change, logout, current-user and admin browser-session revocation, consent grants, TOTP/WebAuthn MFA enrollment, MFA credential revocation, recovery-code regeneration, account lifecycle browser endpoints, and admin mutations including user creation, user status changes, admin-initiated verification/recovery emails, OIDC client secret rotation, and OIDC client status changes. Unsafe /api/v1/* requests with a mismatched Origin or Referer are rejected before handler logic runs. OAuth token, introspection, and revocation endpoints do not use browser cookies and are not part of this CSRF flow.

Admin authorization is independent of CSRF. CSRF proves same-site browser intent for unsafe cookie-authenticated requests; the admin guard separately verifies owner membership in the administrators group before organization-admin reads or writes.

The built-in administrators group is protected against owner lockout. The API returns 409 Conflict when a membership update or deletion would leave that group with no owner membership.

User status changes are transactional. Setting a user to suspended or locked revokes that user’s browser sessions, opaque access tokens, and refresh tokens. Reactivating a user does not un-revoke old credentials. Cookie session loading also rejects non-active users as a defense-in-depth check. The API returns 409 Conflict when suspending or locking a user would leave the built-in administrators group with no active owner.

Rate Limiting

Browser login attempts are throttled through persistent Postgres buckets:

  • One bucket is keyed by organization and normalized email.
  • One bucket is keyed by organization and client network address.
  • Raw email and address values are hashed before storage.
  • The login window allows 5 failed attempts in 15 minutes and blocks for 15 minutes after the threshold.

Bootstrap attempts are also limited by organization and client network address while the first admin user is being created.

Session reauthentication attempts, including failed current-password checks during self-service password change, are throttled separately by organization-scoped user ID and client network address.

Password recovery requests are throttled separately through persistent buckets:

  • One bucket is keyed by organization and normalized email.
  • One bucket is keyed by organization and client network address.
  • Raw email and address values are hashed before storage.
  • The recovery window allows 3 attempts per hour and blocks for 1 hour after the threshold.

When a throttle bucket is blocked, the API returns 429 Too Many Requests with Retry-After set to positive delta seconds for the remaining block window.

See account-lifecycle.md for invitation, verification, recovery, and email outbox delivery details. See mfa.md for MFA enrollment and login behavior.