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 valuesnone,login, andconsent, supported display valuespage,popup,touch, andwap, noclaims/request/request_uriparameter support, RFC 9207 authorization-response issuer support, and the RP-initiated logoutend_session_endpoint.GET /.well-known/jwks.json: returns active public signing keys; database-backed keys are preferred over legacy static env material. Usecairn-api operations preflightto confirm the active databasekidis 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 includingopenid,max_agewhen present,response_modewhen present,promptwhen present,displaywhen present, rejects duplicate registered authorization request parameters, rejects unsupportedclaims,request, andrequest_uriparameters, validates RFC 7636code_challengesyntax, and requirescode_challenge_method=S256; redirects to login or consent when required. Only omittedresponse_modeandresponse_mode=queryare supported in v1. Standard OIDCdisplayvalues, voluntaryacr_values,ui_locales, andclaims_localespreferences are accepted and preserved through login/consent redirects; ID tokens return the actual browser sessionacr. 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=nonenever shows UI and returnslogin_requiredorconsent_requiredto the validated redirect URI when interaction is needed. Clients assigned an org-scoped consent policy template withgrant_mode="always_required"cannot satisfy consent from an existing grant; they show consent on every interactive authorization and returnconsent_requiredforprompt=none.prompt=loginforces reauthentication and strips onlyloginfrom the post-login return URL to avoid a loop.prompt=consentforces the consent screen and strips onlyconsentfrom the post-consent return URL. Oversized or malformed query encoding, unknown clients, invalid redirect URIs, and duplicateclient_idorredirect_uriparameters return local 400 responses. Once the client and redirect URI are valid, authorization request errors, including disabled clients returningunauthorized_clientand malformed or negativemax_age, are returned to the registered redirect URI with OAutherror, optionalerror_description, preservedstate, and RFC 9207iss; missingresponse_typemaps toinvalid_request, while a supplied unsupported response type maps tounsupported_response_type. Authorization redirects set no-store/no-cache headers because they can carry codes, errors, state, or return URLs.POST /oauth2/token: supportsauthorization_code,refresh_token, andclient_credentialsusingapplication/x-www-form-urlencodedrequest bodies; media-type parameters such ascharset=UTF-8are accepted. Public clients useclient_idwith no secret for browser/user grants, and confidential clients may useclient_secret_basicorclient_secret_post. Disabled clients fail client authentication with OAuthinvalid_client. Duplicate or malformedAuthorizationheaders return OAuthinvalid_request. Authorization-code exchanges require nonblankcode,redirect_uri, andcode_verifier; missing or blank required parameters returninvalid_request, while redirect URI mismatch or failed PKCE verification returnsinvalid_grant. Authorization-code exchanges return a refresh token only when the stored grant includesoffline_accessand the client allows the refresh-token grant. Refresh-token exchanges require a nonblankrefresh_token; missing or blank values returninvalid_request, while unknown, expired, revoked, or reused values returninvalid_grant. Refresh-token requests may omitscopeto keep the original grant or request a syntactically valid narrower scope set; malformed scopes or scopes outside the original grant returninvalid_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 invalidgrant_typereturnsinvalid_request, unknown syntactically valid grant types returnunsupported_grant_type, and registered clients using a supported but disallowed grant returnunauthorized_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 OAuthinvalid_requestJSON with no-store/no-cache headers rather than framework-default errors.invalid_clientresponses use401 Unauthorizedwith aWWW-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 6750WWW-Authenticatechallenges. GET supports Authorization-header Bearer tokens; POST supports Authorization-header Bearer tokens andapplication/x-www-form-urlencodedaccess_tokenbody tokens. DuplicateAuthorizationheaders, oversized or malformed query encoding, and malformed or oversized POST form bodies are treated as malformed bearer requests.POST /oauth2/introspect: requiresapplication/x-www-form-urlencoded, requires client authentication, acceptstoken_type_hint=access_tokenortoken_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 blanktoken, duplicate or malformedAuthorizationheaders, missing/wrong content type, malformed form bodies, duplicate form parameters, or form bodies larger than 16 KiB return OAuthinvalid_requestJSON with no-store/no-cache headers from the endpoint’s explicit body parser.POST /oauth2/revoke: requiresapplication/x-www-form-urlencoded, requires client authentication, acceptstoken_type_hint=access_tokenortoken_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 blanktoken, duplicate or malformedAuthorizationheaders, missing/wrong content type, malformed form bodies, duplicate form parameters, or form bodies larger than 16 KiB return OAuthinvalid_requestJSON with no-store/no-cache headers from the endpoint’s explicit body parser.GET/POST /oauth2/logout: RP-initiated logout endpoint. The endpoint acceptsid_token_hint,logout_hint,client_id,post_logout_redirect_uri,state, andui_locales. GET query parameters are strictly decoded up to 8 KiB. POST requiresapplication/x-www-form-urlencodedand 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 RS256id_token_hintissued 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, thencairn_sessionandcairn_csrfare cleared.post_logout_redirect_uriis used only when the validated token audience identifies a client in the active organization, optionalclient_idmatches that audience, and the URI exactly matches one of that client’s registeredpost_logout_redirect_uris;stateis 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 supportedUserandGroupresource types in a SCIM ListResponse.GET /scim/v2/ResourceTypes/User: returns the supportedUserresource type.GET /scim/v2/ResourceTypes/Group: returns the supportedGroupresource type.POST /scim/v2/Bulk: processes 1 to 50 User/Group mutation operations with boundedbulkId: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 asGET /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 tosuspended.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 asGET /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-inadministratorsgroup, 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 asession.logged_inaudit 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-freenew_login_notificationemail 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 asession.reauthenticatedaudit event, and returns fresh sessionacr/amrvalues. If MFA is configured and no second factor is supplied, returnsstatus="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-freepassword_changed_notificationemail with bounded request context in the same database transaction, writesaccount.password_changed, and returns revocation counts plus the new sessionacr/amr.POST /api/v1/session/logout: revokes the current browser session, clearscairn_sessionandcairn_csrf, and writes asession.logged_outaudit 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 andcairn_csrfcookie 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,currentmarker,acr,amr, RFC 3339created_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 returns400for the current session so callers use logout for that case, returns404for expired/revoked/foreign sessions, writessession.revoked_by_user, and does not revoke OAuth tokens.POST /api/v1/consent: CSRF-protected current-user approval for an OIDC consent screen. Requests includeclient_id, the exact authorizereturn_to, and approvedscopes; the API validates thatreturn_tois this issuer’s canonical/oauth2/authorizerequest 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-freepassword_recovered_notificationemail with bounded request context in the same database transaction, writesaccount.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 andstatus=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 toactive,suspended, orlocked.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, includingpreview_urlonly in development, and returnsstatus="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, includingpreview_urlonly 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, ormetadata.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 thecurrentmarker 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 returns400for the admin actor’s current session, returns404for foreign, expired, revoked, or missing sessions/users, writesadmin.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 rolememberorowner.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 requireslug,name, andgrant_mode, where v1 supportsrequired_onceandalways_required. Templates never bypass consent;always_requiredforces 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 optionalconsent_policy_template_idfrom the active organization.openidis always retained on created clients because v1 clients are OIDC clients, and new clients start withstatus="active". The list endpoint supports keyset pagination plus indexed search, type, status, grant-type, and scope filters. Admin client responses includestatusandhas_client_secretbut never expose the storedclient_secret_hash; confidential-client creation returns the rawclient_secretonce.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 rawclient_secretonce with the sanitized client shape. Public clients return409 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, supportsstatus=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_requiredclient policies consume that marker on the immediate authorize retry;prompt=nonenever consumes it and still returnsconsent_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_clienterrors return an RFC 6749-compatible Basic challenge while preserving no-store/no-cache response headers, and client grant policy failures returnunauthorized_clientinstead ofunsupported_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_accessauthorization-code grants whose client allowsrefresh_token; they rotate on every use. A refresh-token request with noscopekeeps the original grant, while a request withscopecan 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 staticCAIRN_SIGNING_*env material remains supported as fallback/import material. User ID tokens includeauth_timefrom the browser session authentication time somax_agerequests are auditable by clients, include the sessionacr/amrvalues 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:emailemitsemailandemail_verified,profileemitsname, andgroupsemits current tenant-scoped group slugs sorted by slug. /oauth2/introspectacceptstoken_type_hint=access_tokenandtoken_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, optionalsub, andtoken_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 intoemail_outboxwith AES-256-GCM whenCAIRN_KEY_ENCRYPTION_KEYis 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 claimsqueued/retryrows, invokes the configured provider, and marks rowssent,retry, orfailed.cairn-api operations preflightreports 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 runcairn-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 RFC3339completed_atand optional provider message ID evidence. /oauth2/userinforeturnsemail_verifiedfrom the persisted user record only when the access token includes theemailscope./oauth2/userinfosupports both GET and POST. It accepts bearer tokens through theAuthorizationheader and, for POST only, the RFC 6750application/x-www-form-urlencodedaccess_tokenbody parameter. Requests that use more than one bearer token transport method returninvalid_request; URI queryaccess_tokenbearer parameters are intentionally unsupported and rejected asinvalid_request. Query strings larger than 8 KiB, malformed query encoding, form bodies larger than 16 KiB, or malformed form encoding are rejected asinvalid_requestbefore token lookup. Missing credentials or unsupported authorization schemes return a bareWWW-Authenticate: Bearer realm="cairn"challenge without an error code. Duplicate or malformed Bearer headers/body parameter values returninvalid_request; unknown/expired/revoked bearer tokens and tokens for non-active user subjects returninvalid_token; client-credentials tokens without a user subject returninsufficient_scope. Bearer challenge auth-parameter values are sanitized to the RFC 6750 visible-ASCII character set before insertion intoWWW-Authenticate.- TOTP MFA secrets are AES-256-GCM encrypted in
mfa_credentials.secret_metadatawith AAD bound to organization and user. Once active TOTP exists for a user, password login acceptsmfa_code,totp_code, orrecovery_code; successful TOTP sessions includeamr=["pwd","otp"], and successful recovery-code sessions consume the code and includeamr=["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 inmfa_credentials.secret_metadataas 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 returnsstatus="mfa_required"plus WebAuthn request options, then issues sessions withacr="urn:cairn:acr:password+webauthn"andamr=["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_notificationonly 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-storeandPragma: 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:
- The web client calls
GET /api/v1/session/csrf. - The API returns
{ "csrf_token": "..." }and sets an HttpOnlycairn_csrfcookie. - The web client sends the token in
X-CAIRN-CSRFfor unsafe methods. - 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.