auth
Every way a caller proves who it is: email/password (argon2id), OAuth 2.1, MFA, passkeys, machine-to-machine service accounts, plus the RS256 JWT, session, and RBAC middleware the rest of the platform leans on. This page is the module's contract; for the narrative of how a request becomes an identity, read Authentication flow.
What it owns
- Email + password — argon2id in PHC format (OWASP 2025 params), transparent rehash on login when the stored hash is weaker than the current default.
- OAuth 2.1 — Google, Apple, GitHub, and Discord, each independently enable-able per surface, with a web flow and a mobile flow.
- Tokens and sessions — RS256 access + refresh JWTs from one key pair, refresh rotation with family replay detection, and a Redis-backed revocation set so logout kills an access token instantly rather than waiting out its TTL.
- MFA — TOTP with backup codes, WebAuthn passkeys, and the step-up gate (
RequireStepUp) that catastrophic actions sit behind. - Device trust — per-surface trusted-device grants that let a known device skip the second factor.
- Service accounts — OAuth2 client-credentials machine identities (ADR-0014). See below.
- RBAC middleware —
RequirePermission/RequireAudience/RequireStepUp, satisfying the SDK'sRoleMiddlewareseam. The permission decisions belong to authz; auth only enforces them at the route.
Two surfaces, one module
Almost every route is mirrored under /v1/auth/operator/* (Tier-1) and /v1/auth/client/* (Tier-2) — same handlers, different collections, cookie domains, and rate-limit policy. A token minted for one surface is rejected at the edge of the other with 401 audience_mismatch, above and beyond per-route RBAC.
Some surfaces exist on one tier only: session listing and OAuth link/unlink are operator-side self-service, while registration exists on both but is independently gated by config.
Service accounts
A machine caller — an integration, a CI job, a fork's own backend — authenticates with the OAuth2 client-credentials grant. Adopted in ADR-0014; operator console at /admin/service-accounts.
The model
A service account is an operator user row, not a new entity. iface.User carries an additive Kind field — empty for a human, service for a machine principal. The account is created with a synthetic, undeliverable email under the RFC 2606 reserved .invalid TLD, the minimum system role (guest), and no password hash. Because it is a user, tenant membership, custom roles, authz bindings, Cedar evaluation, audit provenance, and JWT membership embedding all work unchanged — real capability comes from tenant-scoped custom-role bindings granted on top. Kind is immutable after creation: it is not part of the user-update input shape, so no update path can turn a human account into a machine one or back.
Credentials
Credentials live in their own service_account_credentials collection, hashed with the same argon2id service as passwords. The client ID is an opaque sa_-prefixed identifier distinct from the user UUID; the client secret is sas_-prefixed, generated server-side from 32 random bytes, and returned exactly once — at creation or rotation — never persisted or logged in plaintext.
At most two credentials may be active per account. That is what makes rotation zero-downtime: issue the new one, migrate the caller, revoke the old. Note it is a count-then-insert check, not an atomic conditional insert, so two concurrent issues can both briefly succeed — operational hygiene, not a security boundary, since each credential stays individually revocable.
The grant
curl -X POST https://console.example.com/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"grantType":"client_credentials","clientId":"sa_...","clientSecret":"sas_..."}'
{ "accessToken": "eyJhbGciOiJSUzI1NiIs...", "tokenType": "Bearer", "expiresIn": 900 }
The endpoint is public and rate-limited. Two things to expect that differ from an off-the-shelf OAuth2 client:
- JSON, camelCase. Not RFC 6749's form-encoded
grant_type/access_tokenshape — the platform is JSON-first throughout. A stock OAuth2 library needs a thin adapter or a hand-rolled request. - No refresh token. The client credential is already a long-lived secret; a service account simply repeats the grant when its access token expires.
Every rejection reason — unknown client ID, revoked credential, wrong secret, disabled account, a user that is not a service account — collapses into one indistinguishable error, and an unknown client ID still burns a dummy hash-verify so that path costs the same wall-clock time as a wrong secret. Failed attempts are throttled per source IP and per targeted client ID.
Audience
The grant mints aud: "service". The operator host mux accepts the audience set operator, service; the client mux is unchanged and accepts client only. Service accounts therefore reach the Tier-1 operator surface and nothing else — a Tier-2 machine credential is out of scope and would be a separate design.
Admin surface
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/admin/service-accounts | List service accounts |
| POST | /v1/admin/service-accounts | Create one — returns the client secret once |
| GET | /v1/admin/service-accounts/{id} | Get one with credential metadata |
| PATCH | /v1/admin/service-accounts/{id} | Rename, enable, or disable |
| POST | /v1/admin/service-accounts/{id}/credentials | Issue a rotation credential — secret returned once |
| DELETE | /v1/admin/service-accounts/{id}/credentials/{credentialId} | Revoke a credential |
Reads require auth.service_accounts.read. Every mutation requires auth.service_accounts.manage plus a step-up MFA proof fresher than 5 minutes, the same bar as every other secret-revealing admin mutation in this module. Revocation is deliberately not idempotent: revoking an already-revoked credential returns the same not-found outcome as revoking one that never existed, so there is no oracle distinguishing the two.
Operational caveats
- Disabling has bounded latency. It stops new grants instantly, but a token minted just before remains cryptographically valid for up to the access-token TTL (default 15 minutes). This is accepted because permissions are never embedded in the token — the authz evaluator resolves them fresh per request, so unbinding roles or disabling the account changes its authorization immediately.
- Fails closed everywhere else. Password login, every OAuth flow, and all refresh-token read paths reject a
serviceprincipal. The client-credentials grant is the only path that can mint a token for one. - No privileged roles.
super_adminandadministratorcan never be assigned to a service account, and that guard fails closed even when the pre-read needed to classify the target is unavailable — the assignment is refused rather than risked.
Session lifetime
Three separate lifetimes govern how long a user stays signed in. Before ADR-0017 only the first two existed, and the second was never documented as a timeout at all.
| Lifetime | Controlled by | Default | What it means |
|---|---|---|---|
| Access token | admin accessTokenTTL → JWT_ACCESS_TOKEN_EXPIRY → 15m | 15m | How long a minted access token is accepted. Range 1m–24h; longer values are clamped, because the Redis revocation denylist stores entries for 24h + 1min and a token must never outlive its own revocation entry. |
| Idle window | JWT_REFRESH_TOKEN_EXPIRY | 7d | This is the idle timeout. Rotation writes a fresh now + this on every refresh, so a session ends only after this long without activity — it is not a separate control. |
| Absolute cap | admin sessionAbsoluteTTL | 30d (720h) | The maximum total age of a session, measured from login, independent of activity. Range 1h–89d; clearing the field disables the cap. |
Reaching the absolute cap is a logout, not a denial: the session's refresh
tokens are revoked, the session document is marked inactive, and the session
id is pushed onto the revocation denylist — the same three steps as an
administrative termination. The client receives a 401 with
code: "session_max_age_reached".
Both refresh paths enforce the cap. GET /v1/auth/session mints an access
token without rotating the refresh cookie (a deliberate anti-replay split), so
a client calling only that endpoint would otherwise hold a session open
forever.
Rotation only ever happens on an explicit refresh call (/refresh-cookie, /refresh). RequireAuth itself is bearer-only — it never reads or rotates the refresh cookie (ADR-0020); an expired access token is a plain 401 that the client answers with /refresh-cookie and a retry.
The same session_max_age_reached code comes back from any protected route
once the terminated session's access token meets the revocation denylist — in
practice that is the response a user actually sees, since the refresh
endpoints are read by the SPA's own plumbing rather than shown to anyone. It
is deliberately distinct from session_revoked: a session that simply reached
its maximum age was not revoked, and the difference matters to whoever reads
the resulting support ticket.
If session state cannot be evaluated at all, the refresh endpoints answer
503 session_enforcement_unavailable rather than 401. That is not a
sign-out and a client must not treat it as one — both shipped SPAs keep the
access token and retry on the next request. Reporting a storage outage as an
authentication failure would train clients to discard sessions that are still
perfectly valid.
Upgrading
Deploying the absolute cap for the first time signs out any session that began
more than sessionAbsoluteTTL ago, on its next refresh. Clear the field at
/admin/modules to keep the previous unbounded behaviour.
Retention
The three lifetimes above govern authentication; retention is a separate
question again. Session documents are audit and device history that the
risk scorer reads — nothing authenticates off them — and are deleted at
expiresAt, which every login sets to 90 days out
(models.AuthSessionRetention). The index deletes the row when that timestamp
passes; it does not add a further 90 days on top of it, so 90 days from login
is the whole of the window.
That window is also the risk scorer's history depth: its device-fingerprint and IP factors count session rows, so a device or IP last seen more than 90 days ago reads as new and scores accordingly.
Refresh-token rows have their own retention story, covered next.
Refresh-token retention
Expired refresh-token rows are deleted by a background sweep the module starts with itself, not by a Mongo TTL index — the row may only go once its own expiresAt is past, and the first cleanup of an upgraded install needs bounded progress and a visible backlog, which a TTL monitor gives you neither of. See ADR-0017.
Three things an operator should know about it:
- One replica sweeps, whichever wins a Redis lease. The lease is held across the idle wait as well as the drain, so the per-cycle bound of 5,000 rows per tier is a cluster-wide figure rather than something you multiply by your replica count. If Redis is unreachable, or the lease is lost to another replica, the sweeper steps down and re-contends five minutes later rather than stopping: a Redis restart or a failover costs one cycle, not the rest of the process's life. Authentication is never affected either way.
- The cadence adapts; the batch does not. While a batch reports more work waiting, the next pass is five minutes away; once it comes back dry, six hours. A million-row backlog drains in under a day at roughly 17 deletes per second, and the loop returns to idle on its own.
- There is nothing to configure and nothing to trigger. The two intervals are maintenance constants, and because the sweep drains itself there is no "run extra cycles during a maintenance window" procedure. Watch
orkestra_auth_token_sweep_backlog_estimate{tier}fall to zero.
Permissions
| Key | System set | Grants |
|---|---|---|
auth.self | — | Edit your own password and sessions |
auth.mfa.self | — | Enroll, verify, and remove your own MFA factors |
system.users.mfa_reset | ✅ | Reset another user's MFA factors |
system.users.password_reset | ✅ | Trigger a password-reset email for another user |
system.users.email_verify_resend | ✅ | Resend another user's email-verification mail |
system.users.oauth_unlink | ✅ | Unlink an OAuth identity from another user |
auth.service_accounts.read | ✅ | List and inspect service accounts |
auth.service_accounts.manage | ✅ | Create service accounts, issue, rotate, and revoke credentials |
Keys in the system set are granted by the super_admin / administrator / developer role shortcuts without an explicit global binding, and the seeder excludes them from org roles. Without that flag a freshly created administrator — one with no binding to the seeded administrator role — would 403 on the admin credential endpoints.
Config
auth is by far the largest configuration surface in the base — 63 fields at /admin/modules/auth, arranged into a group rail: Registration, Login & Sessions, Password Policy, MFA, OAuth Providers (with Google, Apple, GitHub, and Discord as child nodes), Anti-abuse & Notifications, and Sessions & Account. Fields are seeded from their EnvVar on first boot and owned by the ConfigService thereafter — see Module configuration and OAuth providers for the operator walkthrough.
The Login & Sessions group's sessionAbsoluteTTL is the admin-facing field behind the absolute cap — see Session lifetime above for what it does and enforces.
:::note Where the routes are This page names the surfaces, not all ~90 of the module's operations. The generated per-endpoint reference — request/response schemas included — is under API Reference, grouped by tag: Authentication, MFA, WebAuthn, Auth - Device Trust, Service Accounts, Mobile, and Self-Service. :::