Authentication & Authorization
Start here: README · Related: Conventions & errors · Text translation · Documents · Tenants & billing
In a hurry? The credential every integration needs is a tenant API key. Create one with
POST /api/auth/signup/emailand readtenant_api_keyout of the response — it is returned exactly once. Everything else on this page is detail you can come back for.
TrueIdiom accepts three kinds of credential — a platform operator API key, a per-tenant tenant API key, and OAuth2 bearer tokens issued to individual users — and the auth requirement differs per endpoint. This page is the authoritative description of which credential each auth endpoint needs, how to obtain a bearer token (including the MFA challenge round-trip and the Google sign-in pair), and how roles and tenant scoping constrain what a token can read.
Read it before integrating anything: auth is enforced by FastAPI dependencies rather than declared as OpenAPI security schemes, so the auto-generated /docs page shows every endpoint as if it were public. It is not.
Base URL: https://trueidiom.com. All API routes are prefixed /api. There is no /v1 prefix — the contract is selected by an optional api-version=2026-09-01 query parameter instead (Versioning).
Auth methods at a glance
| Method | Credential | Sent as | Scope | Obtained from |
|---|---|---|---|---|
| Operator API key | The platform break-glass key | X-API-Key: <key> or Authorization: Bearer <key> | Cross-tenant (platform operator) | Server configuration; not issued over the API |
| Tenant API key | Per-tenant key | X-API-Key: <key> or Authorization: Bearer <key>, optionally with X-Tenant-ID | One tenant | tenant_api_key in the POST /api/auth/signup/email or OAuth callback response |
| User access token | OAuth2 bearer token | Authorization: Bearer <access_token> | One user, inside one tenant | POST /api/oauth2/token |
| Page session cookie | Same opaque access token, in a cookie | Cookie: ll_session=<access_token> | One user, inside one tenant | Set automatically by the endpoints that mint tokens |
Choosing an auth method
- Server-to-server translation integration — use the tenant API key. It is the credential the translation, document, terminology, TM, and storage endpoints resolve a tenant from, it does not expire, and it needs no interactive flow. Capture
tenant_api_keyfrom the signup or OAuth-callback response; it is returned exactly once, at tenant creation. - An application acting on behalf of a signed-in person — use the OAuth2 password grant at
POST /api/oauth2/tokenand send the resultingaccess_tokenas a bearer token. This is the only method that carries a user identity, and therefore the only one that populatesGET /api/auth/me, server-derived reviewer identity on document reviews, and per-user role checks. It takes the user's email and password;tenant_idis an optional pin, not a prerequisite. Handle the two responses that are not tokens: the MFA branch — any tenant may require a second factor of its own members, so a password grant can return a challenge on a deployment where the platform-wide requirement is off ((d) MFA) — and, when you send notenant_idand the credentials are valid in several workspaces, a request to name one (Workspace selection). - Browser front-ends — the same token endpoints also set the
ll_sessioncookie, which is what gates the HTML application pages. You get it for free; no extra call. - Platform operations and support tooling — use the operator API key. It is the only credential that reads across tenants.
Do not use the operator API key for ordinary integration traffic. It is a break-glass credential with no tenant scoping, and endpoints that need to know which tenant a request belongs to (e.g. GET /api/auth/users) require you to name the tenant explicitly when you authenticate with it.
(a) The operator API key
The operator key is a server-side setting, unset by default. It is not issued, rotated, or introspectable through the API.
Headers accepted. The key resolver checks, in order:
X-API-Key: <key>Authorization: Bearer <key>
The first non-empty match wins. Because the second form is shared with user access tokens, a request carrying Authorization: Bearer is tested against the operator key first on operator-aware endpoints; if it is not the operator key it is then resolved as a user token. Prefer X-API-Key to keep the two unambiguous.
Comparison uses hmac.compare_digest. A wrong key is rejected the same way a missing one is.
Cross-tenant reach. The operator key widens tenant-scoped reads. It is not quite the only thing that does: the platform-operator check also passes a signed-in session belonging to an email designated as a platform operator while that user has an authenticator enrolled — see (i) Platform operators. The global_administrator role does not grant cross-tenant access, however many admins hold it — see Roles.
Which endpoints accept it.
| Guard | Endpoints |
|---|---|
The admin gate (operator key or an authenticated global_administrator) | POST /api/oauth2/revoke, POST /api/oauth2/introspect, GET /api/auth/users, DELETE /api/auth/users/{user_id}, POST /api/auth/users/{user_id}/restore, PATCH /api/auth/users/{user_id}/role, POST /api/auth/users/{user_id}/mfa/reset, POST /api/auth/users/{user_id}/password-reset-link, plus the /api/tenants/* administration, usage, invite, and security-policy routes |
| The platform-operator check (operator key or a designated operator session — see (i)) | Cross-tenant aggregations, e.g. the tenant and stage-timing breakdowns on the document summary endpoint, which return 403 cross-tenant breakdowns require operator access for an authenticated non-operator |
| Tenant-identity resolution | Translation, document, terminology, TM, storage, and billing routes. A tenant API key authenticates here; the operator key does not resolve to a tenant on its own, but it does unlock cross-tenant visibility on reads that have already resolved one |
GET /api/auth/me, POST /api/auth/signout, POST /api/oauth2/token, the user-facing MFA endpoints (/api/auth/mfa/enroll, /confirm, /verify, /recovery/regenerate), POST /api/auth/invites/preview, POST /api/auth/password-reset, and the OAuth authorize/callback/client-config endpoints do not accept the operator key. They are user-credential endpoints — the last three are public, and their own input (a challenge id, an invite token, a reset token) is the proof. The two administrative MFA routes — POST /api/auth/users/{user_id}/mfa/reset and PATCH /api/tenants/security — do accept it, and are the only MFA-related endpoints that do.
Failure mode. Endpoints behind the admin gate return 401 {"detail":"admin credentials required"} when no usable credential is present, and 403 {"detail":"admin role required"} when the caller authenticated successfully as a non-admin user.
One anonymous path exists, and it is a fresh-install hole. When no operator key is configured and the tenant store holds no accounts, the admin gate passes outright with no credential at all — open dev mode, so a fresh checkout is usable before anything exists. It closes as soon as either half changes: set an operator key, or create the first tenant, and admin credentials are required from that point on. A deployment must therefore configure its operator key before first use. On a configured deployment — including https://trueidiom.com — the admin endpoints require the operator key or a global_administrator session.
curl -sS "https://trueidiom.com/api/auth/users?tenant_id=7c1f5b0e4a9d4d2f&api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY"
Tenant API keys
A tenant API key is minted once, at tenant creation, and returned as tenant_api_key by POST /api/auth/signup/email and by POST /api/oauth2/{provider}/callback when that callback created the tenant (it is null when the user joined an existing tenant). Only a SHA-256 hash and the first 8 characters (api_key_prefix on the TenantAccount object) are retained server-side — the full key cannot be re-read.
Send it exactly like the operator key (X-API-Key or Authorization: Bearer). Add X-Tenant-ID: <tenant_id> to pin the lookup to a specific tenant; without it the key is matched against all active tenants. Endpoints that require tenant identity and receive neither a valid tenant key nor a valid user bearer token return 401 {"detail":"missing or invalid tenant credentials"}.
(b) The OAuth2 token endpoint
POST /api/oauth2/token
Issues user access/refresh token pairs. Also the endpoint that completes an MFA challenge and the endpoint that rotates refresh tokens.
Auth required: none — this is where credentials are exchanged.
Request encoding: application/x-www-form-urlencoded. JSON bodies are rejected with 422.
Rate limited: yes, on the password and MFA grants.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | password, urn:ietf:params:oauth:grant-type:mfa-otp, or refresh_token. Whitespace-trimmed and lowercased before matching |
tenant_id | string | No | password grant only, and optional there: it pins the sign-in to one workspace. Omit it and the server resolves the workspace from the email and password; when those are valid in more than one, the response asks you to choose — see Workspace selection |
username | string | password only | User's email address |
password | string | password only | User's password |
refresh_token | string | refresh_token only | Refresh token from a previous response |
challenge_id | string | MFA grant only | challenge_id from the MFA challenge |
mfa_code | string | MFA grant only | Current TOTP code or a single-use recovery code (xxxxx-xxxxx) |
scope | string | No | Default translate:jobs. Recorded on the issued tokens |
Grant: password
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=password \
-d username=maria@acme-legal.com \
-d 'password=correct-horse-battery-staple' \
-d scope=translate:jobs
The email and password are the whole credential — there is no tenant id to look up first. Add -d tenant_id=7c1f5b0e4a9d4d2f to pin the grant to one workspace; an integration that already sends it keeps working exactly as before, and a pinned grant is never asked to choose a workspace.
When MFA is not required of this user — neither the platform-wide requirement (off by default) nor their tenant's mfa_required — the response is an OAuth2TokenResponse:
{
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
}
| Field | Type | Description |
|---|---|---|
access_token | string | Opaque bearer token. Not a JWT — it carries no claims and cannot be parsed client-side |
token_type | string | Always bearer |
expires_in | integer | Access-token lifetime in seconds (default 3600) |
scope | string | Scope recorded on the token |
user | object | AuthenticatedUser: user_id, tenant_id, email, provider (email or oauth2), role |
refresh_token | string | Opaque refresh token |
refresh_expires_in | integer | Refresh-token lifetime in seconds (default 2592000 — 30 days) |
user.roleis not the authority on the caller's role. On thepassword, MFA, andrefresh_tokengrants the returneduserobject is built from the authentication step and carries the model defaultagent_id_developer, while the role stamped onto the token record itself is read from the user record at issuance. CallGET /api/auth/me(orPOST /api/oauth2/introspect) to read the effective role. Tokens issued throughPOST /api/oauth2/{provider}/callbackdo carry the resolved role inuser.role.
A successful password grant also sets the ll_session cookie on the response.
Workspace selection
An email address is unique within a workspace, not across the platform, so the same address can hold accounts in several. When you send no tenant_id and the same email and password verify in more than one of them, the grant answers 200 with no tokens, no challenge, and no cookie:
{
"tenant_selection_required": true,
"tenants": [
{"tenant_id": "7c1f5b0e4a9d4d2f", "tenant_name": "Acme Legal"},
{"tenant_id": "3b8e5d1a7c04f299", "tenant_name": "Acme Labs"}
],
"next": {"oauth2_token": "/api/oauth2/token"}
}
Repeat the grant with tenant_id set to the chosen workspace. That second attempt is an ordinary pinned sign-in and returns tokens — or an MFA challenge, if the chosen workspace requires one. Nothing has been issued in the meantime, so treat this response as a prompt rather than a session, and branch on tenant_selection_required before parsing an OAuth2TokenResponse.
Returning workspace names is safe because the password has already proved the caller owns every account in the list — this response can only reach someone who could sign into all of them. A grant that carries tenant_id never produces this shape, and neither does one whose credentials match a single workspace.
Grant: urn:ietf:params:oauth:grant-type:mfa-otp
Used to complete a challenge returned by the password grant. See MFA.
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=urn:ietf:params:oauth:grant-type:mfa-otp \
-d challenge_id=Qb7xK2mR9tL4pS0nB8yD1wH6 \
-d mfa_code=418205
Returns the same OAuth2TokenResponse shape and sets the session cookie.
Grant: refresh_token
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK
Refresh is rotating and single-use: the presented refresh token is consumed, and a brand-new access/refresh pair is returned. Store the new refresh_token from every response — replaying the old one returns 401. Omit scope to inherit the scope recorded on the consumed refresh token; supply a non-empty scope to change it. The session cookie is refreshed too.
Errors
| Status | Detail | Cause |
|---|---|---|
400 | username and password are required | password grant missing a field |
400 | invalid email address | username is not a parseable email address |
400 | challenge_id and mfa_code are required | MFA grant missing a field |
400 | refresh_token is required | refresh_token grant missing the token |
400 | unsupported grant_type | grant_type is not one of the three above |
401 | invalid tenant credentials | password grant with tenant_id: unknown tenant, unknown/inactive user, or wrong password |
401 | invalid credentials | password grant without tenant_id: no workspace matched this email and password. One answer for every cause, so the response never reveals where the address is registered |
401 | invalid or expired MFA challenge | Challenge consumed, expired, or wrong code |
401 | invalid or expired refresh token | Refresh token unknown, already used, revoked, or expired |
403 | password sign-in is disabled — use single sign-on | Email/password sign-in is switched off on this deployment (password grant only) |
429 | too many requests — slow down and retry later | Rate limit; includes a Retry-After header |
422 | validation error array | grant_type absent or the body was not form-encoded |
(c) Using bearer tokens and the session cookie
Bearer tokens
Send the access token on every subsequent call:
curl -sS "https://trueidiom.com/api/auth/me?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
The token is opaque and server-validated on each request: it is hashed, looked up, and checked for revocation and expiry. An expired, revoked, or unknown token is indistinguishable in the response from a malformed one.
Optionally add X-Tenant-ID: <tenant_id> on tenant-scoped endpoints. When present, the token's tenant must match it; a mismatch fails the resolution rather than silently using the token's own tenant.
Session cookie
Browser panes cannot set request headers on plain navigations, so the same opaque access token is also written to a cookie.
| Property | Value |
|---|---|
| Name | ll_session |
| Value | The issued access token, verbatim |
Max-Age | The access-token lifetime (default 3600) |
HttpOnly | true |
SameSite | Lax |
Secure | true in production |
Path | / |
Set by: POST /api/oauth2/token (all three grants, on success), POST /api/auth/mfa/verify, POST /api/auth/mfa/confirm (only when a challenge_id was supplied), POST /api/auth/signup/email (only when mfa_required is false), POST /api/oauth2/{provider}/callback (only when mfa_required is false), and POST /api/auth/password-reset (only on the non-MFA branch — a reset that answers with a challenge sets no cookie).
Cleared by: POST /api/auth/signout and POST /api/oauth2/revoke.
Where the cookie is accepted as a credential. The shared session resolver reads the Authorization: Bearer header or the cookie, so both work on admin-guarded endpoints (GET /api/auth/users, PATCH /api/auth/users/{user_id}/role, POST /api/oauth2/revoke, POST /api/oauth2/introspect) and for server-derived reviewer identity. Two endpoints deviate:
GET /api/auth/mereads the bearer header only. A cookie-only client gets401.POST /api/auth/mfa/enrollandPOST /api/auth/mfa/confirmread the cookie only (or a pendingchallenge_id). A bearer-only API client cannot enrol TOTP for an already-signed-in user without one of those two.POST /api/auth/mfa/recovery/regeneratereads the cookie only — a pendingchallenge_idis refused there on purpose, since that caller has cleared only the password factor.
(d) MFA
MFA is off by default. When it is on, sign-in becomes a two-step exchange: the credential step returns a challenge instead of tokens, and the challenge is completed by presenting a code (POST /api/oauth2/token MFA grant, or POST /api/auth/mfa/verify) or, for a user who has no authenticator yet, by enrolling one (POST /api/auth/mfa/enroll then /confirm).
TOTP is the only method a deployment must support. Required MFA needs no mailer: a user with no authenticator is handed an enrolment challenge and binds one from inside the sign-in, so there is never a code waiting to be delivered.
Who is required to do it
Two switches, combined — plus one rule that overrides both:
| Switch | Scope | Set by |
|---|---|---|
| Platform-wide MFA requirement | Every tenant | Deployment configuration |
mfa_required | One tenant | PATCH /api/tenants/security |
mfa_required_for_sso | One tenant | PATCH /api/tenants/security |
| Platform-operator designation | Named individuals, any tenant | Deployment configuration |
A password sign-in is challenged when the platform-wide requirement or the tenant's mfa_required is on.
A designated platform operator is always challenged, whatever the other switches say and whichever path they sign in through — the mandatory second factor is a condition of holding cross-tenant reach, not a tenant's preference. See (i) Platform operators.
Completed IdP sign-ins are exempt. A sign-in finished through POST /api/oauth2/{provider}/callback (provider: "oauth2" — Google) is not challenged, even under a platform-wide requirement, unless the tenant sets mfa_required_for_sso. The reasoning: the IdP enforced its own factor policy — conditional access, passkeys, hardware keys — before this service ever saw the user, so a second local TOTP prompt costs sign-in friction and buys no additional assurance. In procurement language: MFA is enforced at the IdP. A tenant that wants the local prompt stacked on top anyway opts in per tenant.
mfa_required_for_sso extends a requirement rather than creating one. With no base requirement in force (neither the platform-wide requirement nor mfa_required) there is nothing to extend, and setting it alone changes nothing.
Neither flag can be self-assigned: mfa_ is a protected tenant-metadata prefix, so metadata supplied to POST /api/auth/signup/email or an OAuth callback is stripped of both keys (case-insensitively) before the tenant is created. PATCH /api/tenants/security is the only writer.
The challenge object
Returned as mfa_challenge by POST /api/auth/signup/email, POST /api/oauth2/{provider}/callback, and POST /api/auth/password-reset, and inline by the password grant.
| Field | Type | Description |
|---|---|---|
challenge_id | string | Opaque id; supply it to complete the challenge |
method | string | totp (user has a confirmed secret) or totp_enroll (user has none and must bind one). These are the only two values a production deployment ever returns — a third, email_otp, exists solely behind an off-by-default MFA dev-mode switch that also hands the code back in the response body, and is for local development only |
purpose | string | login, signup, oauth_login, or password_reset |
expires_at | string (ISO 8601) | Expiry, 300 seconds after creation by default |
A challenge is single-use: completing it consumes it. Expired or consumed challenges yield 401 invalid or expired MFA challenge.
Branch on method, not on the presence of a challenge. A totp_enroll challenge has no code to verify yet, so POST /api/auth/mfa/verify and the MFA grant can only fail against it — which is why the password grant deliberately omits its grant_type hint for that method and the next object names the enrol/confirm pair instead:
method | next |
|---|---|
totp | {"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"} |
totp_enroll | {"mfa_enroll": "/api/auth/mfa/enroll", "mfa_confirm": "/api/auth/mfa/confirm", "oauth2_token": "/api/oauth2/token"} |
Challenge flow during sign-in
POST /api/oauth2/token grant_type=password
│
├── several workspaces ────> { "tenant_selection_required": true, "tenants": [...] }
│ (no tenant_id sent) repeat the grant with tenant_id — that attempt
│ takes one of the branches below
│
├── MFA not required ──────> OAuth2TokenResponse (done)
│ (neither the platform-wide requirement nor the tenant's mfa_required)
│
└── MFA required ──────────> { "mfa_required": true, "mfa_challenge": {...}, "next": {...} }
│
├── method "totp" ────────> read the current code from the authenticator app
│ (+ "grant_type": "urn:ietf:params:oauth:grant-type:mfa-otp")
│ │
│ └──> POST /api/oauth2/token
│ grant_type=urn:ietf:params:oauth:grant-type:mfa-otp
│ challenge_id=…&mfa_code=… (a recovery code also works)
│ → OAuth2TokenResponse
│
└── method "totp_enroll" ─> no authenticator yet — bind one in-flight:
POST /api/auth/mfa/enroll { challenge_id }
POST /api/auth/mfa/confirm { secret, code, challenge_id }
→ { "enrolled": true, "recovery_codes": [...], "token": {...} }
— sign-in complete, ll_session set
The challenged response from the password grant is not an OAuth2TokenResponse — it has no access_token. Branch on the presence of mfa_required before parsing:
{
"mfa_required": true,
"mfa_challenge": {
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6",
"method": "totp",
"purpose": "login",
"expires_at": "2026-07-26T14:32:05.118422+00:00"
},
"next": {"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"},
"grant_type": "urn:ietf:params:oauth:grant-type:mfa-otp"
}
TOTP
Enrolment is a two-call handshake. POST /api/auth/mfa/enroll generates a base32 secret and returns it with an otpauth:// provisioning URI and a QR PNG data URL; the secret is not persisted at this point. POST /api/auth/mfa/confirm echoes the secret back with a current code — only a valid code persists the secret to the user record. Codes verify with valid_window=1, so the immediately preceding and following 30-second steps are also accepted.
Once a secret is confirmed, every subsequent challenge for that user has method: "totp". Enrolment state (never the secret) is readable as totp_enrolled on GET /api/auth/me for the caller themselves, and on GET /api/auth/users for a tenant admin.
Recovery codes
Confirming an enrolment returns eight single-use recovery codes, formatted xxxxx-xxxxx. Only their SHA-256 hashes are stored, so the response body of POST /api/auth/mfa/confirm (or /mfa/recovery/regenerate) is the one and only time the plaintext exists — a client that drops them leaves the user with no way back in but an admin reset.
- A recovery code is accepted wherever a TOTP code is:
POST /api/auth/mfa/verifyand theurn:ietf:params:oauth:grant-type:mfa-otpgrant. The hyphen makes it unambiguous against a six-digit TOTP code, so one input field can accept either. - Using one consumes it. The same code presented again is rejected exactly like a wrong code (
401). POST /api/auth/mfa/recovery/regeneratereplaces the whole set with eight fresh codes (the old set dies wholesale — that is the point of regenerating). It is session-authorized only: a pendingchallenge_idis deliberately not accepted there, since that caller has cleared just one factor.
Losing the authenticator: admin reset
With the recovery codes gone too, a tenant admin calls POST /api/auth/users/{user_id}/mfa/reset. It clears the member's TOTP secret and recovery codes, so their next sign-in produces a totp_enroll challenge and they bind a new authenticator. Scoping is per-tenant: a user id from another tenant returns 404, and only the operator key crosses tenants.
Audit trail
Every MFA state change is written to the auth audit log, redacted — identifiers and outcomes only, never secrets or codes: mfa_enrollment_challenged, mfa_enrolled, mfa_recovery_codes_regenerated, mfa_recovery_used, mfa_reset_by_admin, mfa_policy_changed, plus the pre-existing mfa_verified and mfa_failed. A read-only PATCH /api/tenants/security (empty body) writes nothing, so the log stays a record of changes.
When no authenticator is enrolled
A user with no confirmed TOTP secret can only be challenged by a flow that offers enrolment. A caller that does not allow enrolment gets 503 service_unavailable ("no authenticator app is enrolled and this deployment cannot deliver email codes") instead of a challenge nobody could complete. Every path that mints a challenge allows enrolment, so an unenrolled user is handed totp_enroll and never reaches that error.
Enrol TOTP for any programmatic integration. It is the only MFA method whose code your client can compute itself, from the secret returned by POST /api/auth/mfa/enroll — bind one in-flight from a totp_enroll challenge (see the flow diagram above).
(e) Roles and what they gate
Two roles exist.
| Value | Display name | Meaning |
|---|---|---|
global_administrator | Global Administrator | Administrator of one tenant |
agent_id_developer | Agent ID Developer | Regular user (the default) |
Any other stored value normalizes to agent_id_developer on read, so pre-RBAC user records need no migration.
global_administrator is per-tenant, not cross-tenant
This is the single most common misreading of the model. Despite the name, global_administrator confers no visibility outside the holder's own tenant:
- Every tenant creator is stamped
global_administratorof the tenant they just created —POST /api/auth/signup/emailalways assigns it, andPOST /api/oauth2/{provider}/callbackassigns it when the callback creates the tenant (joiners getagent_id_developer). - The platform-operator check — the only thing that widens a tenant-scoped read to cross-tenant — is satisfied by the operator API key, by a designated operator session with an enrolled authenticator (see (i) Platform operators), and by open dev mode. It never consults the role.
- Consequently, a signed-in
global_administratorlisting jobs, documents, usage, or users sees only their own tenant's rows. OnGET /api/auth/usersthe resolved tenant is taken from the principal and a supplied?tenant_idis ignored. OnPATCH /api/auth/users/{user_id}/rolethe lookup is scoped to the principal's tenant, so a user id belonging to another tenant returns404 user not foundrather than leaking that the id exists.
What the role does gate, within the caller's own tenant: the admin-gated endpoints listed under operator API key — token revoke and introspect, user listing, role changes, tenant administration and usage reads, and the document approval feed.
How a role is assigned
| Path | Result |
|---|---|
Email signup (POST /api/auth/signup/email) | Registrant becomes global_administrator of the tenant it just created |
| OAuth callback that creates a tenant | First user becomes global_administrator |
| OAuth callback joining an existing tenant | New user becomes agent_id_developer |
PATCH /api/auth/users/{user_id}/role | Explicit override |
The role is stamped onto access and refresh tokens at issuance, so a role change takes effect on the holder's next sign-in or refresh, not immediately on their current token.
Demoting a tenant's last active global_administrator is refused with 409 to prevent lockout.
(f) Google sign-in
Google sign-in uses authorization code + PKCE and is off by default. The Google client is a web-application registration whose token endpoint requires the client secret, so the browser POSTs the code (plus state, code_verifier, redirect_uri) to the callback and the server performs the exchange. It is switched on per deployment.
google is the only supported provider path value; it is trimmed and lowercased before matching. When the Google sign-in switch is off, both authorize and callback return 403. Any other provider value returns 404 {"detail": "unknown oauth provider"} on both routes — an unrecognized name reaches no generic flow, and is rejected before any state is minted or consumed.
Flow
GET /api/oauth2/{provider}/client-config → client_id, endpoints, scope, code_challenge_method
POST /api/oauth2/{provider}/authorize → { state, nonce, expires_at, authorize_url }
↓ browser redirects to the provider with state + nonce + PKCE challenge
↓ provider redirects back to /signin/callback
POST /api/oauth2/{provider}/callback → { account, tenant_api_key, mfa_required, token | mfa_challenge }
The state is single-use and expires after 600 seconds by default. The nonce is minted server-side, must be echoed to the provider, and is verified against the id_token claims at callback time; it is stripped from the state before any metadata is persisted, so it is never reflected back in tenant metadata.
Tenant resolution at callback
The callback binds the tenant to the verified state. A tenant_id in the callback body is honoured only when it matches the state's tenant_id; a mismatch is 400. Resolution order:
- A live invite wins outright. An
invite_tokensupplied atauthorizenames its tenant, which beats org mapping, the email-claim fallback, and tenant creation — a tenant admin deliberately saying where this person belongs is a stronger signal than any claim the IdP happens to carry. The invite is redeemed only when it actually created a membership: an existing member signing in through a link consumes nothing. - A pinned
tenant_idon the state, if there is one. - Org mapping. Google matches on the
hdclaim, present only for Google Workspace accounts — one Workspace domain maps to one TrueIdiom tenant, so the first user from a domain creates it and colleagues join it. Personal Gmail has nohdand is never mapped by email domain. This is the one route the tenant'ssso_auto_jointoggle governs: with it off, a new user matching the claim is refused with403instead of joining. - Email-claim fallback. A provider-verified email matching an existing user links this identity to that user's tenant (preferring
provider="email"records, then the oldest), and stamps the org metadata onto the tenant so future org lookups hit directly. - Otherwise a new tenant is created and
tenant_api_keyis returned.
A joiner's role is the invite's when there was one, otherwise global_administrator for the person who created the tenant and agent_id_developer for everyone else.
Google identities are pinned to their sub: if the email already exists with a different Google sub, the callback fails closed with 400 rather than re-linking, because mail domains recycle email addresses: when someone leaves and their address is later reissued to a new hire, that new person arrives with the same address but a different Google account, and must not inherit the departed user's workspace membership by signing in with it. Google id_token verification enforces signature, audience, nonce, and email_verified.
Domain auto-join
Org mapping means the second person from a Workspace domain joins the workspace the first one created, with no invitation involved. That is the right default for a company buying one workspace and the wrong one for a company that wants to choose its members, so it is a per-tenant switch: sso_auto_join, set through PATCH /api/tenants/security.
- Absent metadata means on. Every tenant created before the toggle existed keeps the original behaviour; only an explicit
falsecloses the domain. - It gates joining, never signing in. With auto-join off, a new user whose
hdmatches the tenant gets403 this organization requires an invitation — ask your workspace admin for an invite link, and no user record is created. Existing members are unaffected: their sign-ins continue exactly as before. An admin cannot lock their own company out with this checkbox. - An invite overrides it. Supply
invite_tokenonPOST /api/oauth2/{provider}/authorizeand the sign-in joins the invite's tenant even while the gate is shut — see (g) Invites. - The refusal is recorded as
google_auto_join_refusedin the audit log.
Like the mfa_ flags, sso_auto_join cannot be self-assigned: sso_ is a protected tenant-metadata prefix, so a self-serve signup or OAuth-created tenant can never arrive with its domain pre-opened (or pre-closed) by the caller.
(g) Invites
There is no mailer, so an invite is a link a tenant admin mints and delivers themselves — over Slack, in person, however they like. The admin is the delivery channel, and the link carries the whole invitation.
The token
The plaintext token is "TENANT_ID.SECRET". It is self-locating, so redeeming it costs one account read and no cross-tenant scan, and only its SHA-256 hash is stored.
Treat the token exactly like an access token. Anyone holding it can join the workspace at the role it names. It exists in the response of
POST /api/tenants/invitesand nowhere else — not in the ledger, not in an audit record, not in a log line.GET /api/tenants/invitesdeliberately returns every field except the token, so a leaked admin session cannot recover a link it did not just mint. Do not put it in a URL you log, an analytics event, or a support ticket.
| Property | Value |
|---|---|
| Lifetime | expires_days on the mint call, default 7, clamped to 1–30 (an out-of-range value is clamped, not rejected) |
| Uses | One. Redeeming removes the entry; a replay is 400 invalid or expired invite |
| Pending invites per tenant | 20. Expired entries do not count and are pruned at the next mint; over the cap, minting is 409 |
| Role | Any valid role, default agent_id_developer. An unknown value is 422 — it is never normalized down silently |
| Email binding | Optional. A bound invite refuses any other address with 400, without consuming itself, so a mis-forwarded link does not burn the invitation |
Redeeming one
Two routes accept it, and both join rather than found:
- Password:
POST /api/auth/signup/emailwithinvite_token.tenant_nameandmonthly_char_limitare ignored — the workspace already exists and is not the joiner's to shape. - Federated:
POST /api/oauth2/{provider}/authorizewithinvite_token, then the ordinary callback. The token rides through the IdP round-trip inside the server-held state (the browser never hands it back, which would make it a free choice of tenant) and is popped out before any tenant metadata is written.
What a join does not do, on either route:
| Founding signup | Invite join | |
|---|---|---|
| Tenant created | Yes | No |
tenant_api_key | The workspace's key, returned once | null (present-but-null, so clients need not branch on its absence) |
| Free-trial grant | Stamped, subject to the per-IP gate | Neither stamped nor gated — a colleague accepting an invitation is not a new customer, and must not consume the per-IP trial budget |
| Role | global_administrator of the new tenant | The invite's role |
| MFA | Only the platform-wide requirement can apply (the tenant is seconds old) | The joined tenant's policy applies, so the response may be a challenge |
Redemption happens only once the membership actually exists. A failed registration — duplicate email, rejected password — leaves the link usable, and an existing member who signs in through a link consumes nothing.
Preview
POST /api/auth/invites/preview is public and rate-limited (bucket invite-preview), because the person holding the token has no account yet. It exists so a sign-in page can render "you are joining Acme as an administrator".
It is not an oracle. Every failure — malformed, unknown tenant, wrong secret, expired, revoked — is the same 200 {"valid": false}. A 404 for an unknown tenant would let anyone guess ids and learn which workspaces exist; a distinct message would leak which half of a token was wrong. On success it returns the workspace name and the role, and nothing about who the invite was minted for.
Audit trail
invite_created, invite_redeemed, and invite_revoked, each naming the tenant, the acting or joining user, the role, and the invite id — never the token.
(h) Password reset
With no mailer there is nobody to send a "forgot password" link, so a tenant admin mints one and delivers it out-of-band, exactly as with invites. When a mailer lands, self-service "forgot password" will reuse these same tokens with the app as the sender.
POST /api/auth/users/{user_id}/password-reset-link (admin) returns the link; POST /api/auth/password-reset (public, bucket password-reset) completes it.
| Property | Value |
|---|---|
| Token shape | "USER_ID.SECRET" — self-locating, so the public completion route accepts no email and no tenant from the caller |
| Lifetime | 3600 seconds (expires_in in the mint response) |
| Uses | One per mint. Minting again replaces the outstanding link, which is how a mis-delivered one is killed |
| Applies to | Password members only. An IdP member is 400 user signs in with an identity provider — no password to reset — the user exists, but a reset would grant them nothing |
| Scoping | POST /api/auth/users/{user_id}/mfa/reset's, verbatim: session/bearer admins are confined to their own tenant and a cross-tenant id is 404; only the operator key crosses tenants |
Completing a reset revokes every other session and refresh token for that account. The reset exists because the old credential can no longer be trusted, and a 30-day refresh token must not outlive it. Expect existing clients of that user to start getting 401 immediately.
The two failure modes are deliberately different. A bad link is 401 invalid or expired reset link — one generic answer for malformed, unknown, expired, and already-consumed, so there is no oracle and no confirmation that a user id exists. A rejected password is 400 naming the rule it broke (the full server policy applies: the minimum length, the common-password list, and the "must not match your email address" rule) and leaves the link usable, since the token was fine.
MFA still applies. An admin-minted link plus a password the holder just chose is still one factor, so completing a reset for a member whose sign-in policy requires MFA returns the same challenge shape as any sign-in rather than tokens:
{
"mfa_required": true,
"mfa_challenge": {"challenge_id": "…", "method": "totp", "purpose": "password_reset", "expires_at": "…"},
"next": {"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"}
}
Branch on mfa_required before looking for token. The password change itself has already happened at that point — the challenge gates the session, not the credential write. Without a requirement in force the response is {"token": {...}, "mfa_required": false} and the ll_session cookie is set.
Recorded as password_reset_link_created and password_reset_completed (the latter carrying the number of tokens revoked). Neither record contains link material.
(i) Platform operators
Cross-tenant reach used to mean putting the break-glass operator key into whatever was making the request — and a key in a browser is a key in a history file. A designated operator can hold that reach from an ordinary signed-in session instead.
Designation is deployment configuration only. Operators are named by email address in a deployment setting (matched case-insensitively, whitespace trimmed). No API route grants it, and none ever will: an endpoint that mints operators is a privilege-escalation surface for any compromised admin session.
Designation alone is not authority. A designated session carries operator reach only while that user has an authenticator enrolled, and both halves are re-checked on every request — nothing is cached. A phished password must never become a cross-tenant credential.
That requirement is enforced from both ends:
- Operator sign-ins are always MFA-challenged, regardless of the platform-wide requirement, of the tenant's own
mfa_required, and of the SSO exemption. A designated user signing up gets atotp_enrollchallenge on a brand-new tenant with no policy at all; afterwards they get an ordinarytotpchallenge on every sign-in, including through an IdP. - Clearing the enrolment removes the powers immediately, on the very session that had them.
POST /api/auth/users/{user_id}/mfa/resetagainst an operator drops them back to an ordinary tenant admin until they re-enrol; the session token itself stays valid, because an MFA reset revokes nothing.
The global_administrator role remains per-tenant and is not a path here: an admin who enrols an authenticator, holds the oldest tenant, and promotes themselves through PATCH /api/auth/users/{user_id}/role still sees exactly one tenant.
The break-glass operator key is unchanged — same header, same power, same standing advice to rotate after use. It remains the credential for non-interactive platform tooling and the way in when no operator can sign in. One asymmetry worth knowing: the key has no "own tenant" to fall back on, so routes like PATCH /api/tenants/security and GET /api/auth/users make a key-only caller name the tenant with ?tenant_id, and answer 400 without it. A designated operator session is not in that position: on both routes it defaults to the workspace it is signed into, and names ?tenant_id only to reach a different one.
Endpoint reference
Every auth response body on error is {"detail": "<message>"}. Request-validation failures (422) return the FastAPI array form: {"detail":[{"loc":[...],"msg":"...","type":"..."}]}. Every response echoes an X-Request-ID header — include it when reporting a problem.
POST /api/auth/signup/email
Create a tenant and register its founding administrator in one call.
Auth required: none. Rate limited: yes (signup bucket).
Gated by: the email-signup switch (on by default). When it is off, returns 403 email signup is disabled — use single sign-on.
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
tenant_name | string | Yes | Display name of the tenant to create |
email | string | Yes | Founding admin's email address. Normalized to lowercase |
password | string | Yes | Minimum 8 characters at the schema level; the server password policy additionally enforces a minimum of 12 characters, rejects common passwords, and rejects a password equal to the email local-part |
monthly_char_limit | integer ≥ 1 | null | No | Defaults to the server default (500000), which is also the ceiling: a larger value is clamped down to the default rather than rejected. Self-serve signup can lower its own cap as a spend control, never raise it. Ignored when invite_token is present |
metadata | object | No | Arbitrary key/value metadata stored on the tenant. Ignored when invite_token is present |
invite_token | string | null | No | An invite link's token. Turns this call into a join: no tenant is created, tenant_api_key is null, no free-trial grant is stamped, tenant_name is ignored, and the registrant gets the invite's role. See (g) Invites |
curl -sS -X POST "https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"tenant_name": "Acme Legal",
"email": "maria@acme-legal.com",
"password": "correct-horse-battery-staple",
"monthly_char_limit": 250000,
"metadata": {"industry": "legal"}
}'
Response 200
{
"account": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 250000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {"industry": "legal"}
},
"tenant_api_key": "kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7x",
"mfa_required": false,
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"next": {"oauth2_token": "/api/oauth2/token"}
}
tenant_api_key is returned once. Store it before discarding the response.
metadata is stored verbatim except for the platform-owned prefixes, which are stripped: stripe_, trial_, topup_, and mfa_. A signup cannot grant itself billing state or pre-set its own MFA policy — see (d) MFA.
When MFA is required (only the platform-wide requirement can apply here — the tenant is created by this very call, with no policy of its own yet), token is absent and the response instead carries "mfa_required": true, an mfa_challenge object, and a next object matching the challenge method. The founding admin has no authenticator yet, so in practice the method is totp_enroll and next is {"mfa_enroll": "/api/auth/mfa/enroll", "mfa_confirm": "/api/auth/mfa/confirm", "oauth2_token": "/api/oauth2/token"}: enrol, confirm, and the confirm response completes the sign-in.
The registrant is created with role global_administrator — of their own tenant only.
Joining with an invite
With invite_token set, the response has the same keys but a different meaning: account is the existing workspace, tenant_api_key is null, and the registrant holds whatever role the invite named. mfa_required reflects the joined tenant's policy, so unlike a founding signup this call really can return a challenge without a platform-wide requirement being set.
curl -sS -X POST "https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"tenant_name": "ignored",
"email": "tomas@acme-legal.com",
"password": "correct-horse-battery-staple",
"invite_token": "7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU"
}'
An invalid, expired, revoked, or already-redeemed token is 400 invalid or expired invite. A token bound to a different address is 400 this invite was issued for a different email address, and the link stays usable.
This path is gated by the same email-signup switch as the rest of the route: a deployment that turned password signup off does not want invites minting password users either.
Errors
| Status | Cause |
|---|---|
400 | email already registered for tenant, invalid email address, password must be at least N characters, password is too common; choose a less predictable one, password must not match your email address |
400 | invalid or expired invite, this invite was issued for a different email address (invite join only) |
403 | Email signup disabled |
429 | Rate limited (Retry-After header) |
422 | Missing required field or password shorter than 8 characters |
POST /api/oauth2/token
See (b) The OAuth2 token endpoint for full parameter, response, and error documentation.
POST /api/oauth2/revoke
Revoke an access or refresh token.
Auth required: admin — the operator API key, or a global_administrator bearer token or session cookie.
Encoding: application/x-www-form-urlencoded.
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The access or refresh token to revoke |
token_type_hint | string | null | No | Accepted and ignored; both token types are looked up regardless |
curl -sS -X POST "https://trueidiom.com/api/oauth2/revoke?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX
{"revoked": true}
The response is {"revoked": true} whether or not a matching token was found — revocation is idempotent and does not disclose token existence. The call also clears the ll_session cookie on the response, so a browser calling it signs itself out.
Errors: 401 (admin credentials required), 403 (admin role required), 422 (missing token).
POST /api/oauth2/introspect
RFC 7662-style token introspection.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. A user cannot introspect their own token.
Encoding: application/x-www-form-urlencoded.
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Access or refresh token to inspect |
curl -sS -X POST "https://trueidiom.com/api/oauth2/introspect?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX
{
"active": true,
"token_type": "access_token",
"scope": "translate:jobs",
"exp": 1785076181,
"sub": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"role": "global_administrator"
}
| Field | Type | Description |
|---|---|---|
active | boolean | Always present. false for unknown, revoked, or expired tokens |
token_type | access_token | refresh_token | null | Which store matched |
scope | string | null | Scope recorded at issuance |
exp | integer | null | Expiry as a Unix timestamp |
sub | string | null | User id |
tenant_id | string | null | Owning tenant |
email | string | null | User's email |
role | string | null | Role stamped on the token at issuance |
An inactive token returns {"active": false} with every other field omitted (null). This response — not the caller's role — is the reliable source for a token's role.
Errors: 401, 403, 422 (missing token).
POST /api/auth/signout
Revoke the caller's session token and clear the session cookie.
Auth required: none, by design — it can only invalidate the cookie the caller already holds. Safe to call with no active session.
Request body: none.
curl -sS -X POST "https://trueidiom.com/api/auth/signout?api-version=2026-09-01" \
-b "ll_session=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-c /dev/null
{"signed_out": true}
Note that this endpoint reads the cookie only. A bearer-token client that wants to invalidate its token server-side uses POST /api/oauth2/revoke (admin) instead; otherwise the token expires on its own after expires_in seconds.
Errors: none specific to this endpoint.
GET /api/auth/me
Return the signed-in user and their tenant.
Auth required: Authorization: Bearer <access_token>. The session cookie is not accepted here.
curl -sS "https://trueidiom.com/api/auth/me?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
{
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "global_administrator",
"totp_enrolled": true
},
"tenant": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 250000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {"industry": "legal"}
}
}
user.provider is email for password accounts and oauth2 for every federated identity (Google reports oauth2, not the provider name). user.role here is read from the token record and is authoritative.
user.totp_enrolled is the caller's own MFA state — true once they have confirmed an authenticator. It is enrolment state only; the secret is never exposed. This is the only place a non-admin can read it, since GET /api/auth/users is admin-only.
Errors
| Status | Detail |
|---|---|
401 | missing bearer token — no Authorization: Bearer header |
401 | invalid or expired bearer token |
401 | tenant not found for bearer token — the token is valid but its tenant no longer exists |
GET /api/auth/users
List a tenant's users.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant_id | query | string | null | Conditional | Required with the operator API key, which has no workspace of its own to fall back on. A designated operator session defaults to the workspace it is signed into and may name another. For a plain session/bearer admin it is ignored — the roster returned is always their own tenant's |
include_revoked | query | boolean | No | Default false. Revoked members are omitted from the listing unless you ask for them; pass true to see them (each row carries active) so you can offer reinstatement. A seat count taken from the default response therefore covers active members only |
# Session/bearer admin — own tenant, tenant_id not needed
curl -sS "https://trueidiom.com/api/auth/users?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
# Operator key — must name the tenant
curl -sS "https://trueidiom.com/api/auth/users?tenant_id=7c1f5b0e4a9d4d2f&api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY"
[
{
"user_id": "9f3a71c4d0b25e88a147c60d",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "global_administrator",
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"totp_enrolled": true
},
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
]
The projection never exposes password hashes, TOTP secrets, or provider subject identifiers. totp_enrolled is enrolment state — true when the user has a confirmed authenticator — and is what an admin reads before deciding whether a member needs POST /api/auth/users/{user_id}/mfa/reset. Note that provider in this listing is the raw stored label (email, google) — unlike AuthenticatedUser.provider, which collapses federated identities to oauth2.
Errors
| Status | Detail |
|---|---|
400 | tenant_id query parameter is required with API-key access |
401 | admin credentials required |
403 | admin role required |
DELETE /api/auth/users/{user_id}
Revoke a member's access. They are signed out everywhere immediately — live sessions and refresh tokens alike — and can no longer sign in, by password or through an identity provider.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id | path | string | Yes | Id of the member to revoke |
No request body.
curl -sS -X DELETE "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the same user projection as GET /api/auth/users, with active now false:
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": false,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
The record is deactivated, not deleted — the tenant's jobs, documents, and audit trail all reference it — so the member disappears from GET /api/auth/users unless you pass ?include_revoked=true, and POST /api/auth/users/{user_id}/restore is the way back. Any outstanding password-reset link for that member is dropped with the same call. Re-revoking an already-revoked member is a no-op that still returns the projection, so a double-clicked button is not an error.
Errors
| Status | Detail |
|---|---|
401 | admin credentials required |
403 | admin role required |
404 | user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
409 | you cannot revoke your own access, or cannot revoke the last admin of a tenant — the two refusals that stop a workspace losing its last way in |
POST /api/auth/users/{user_id}/restore
Reinstate a revoked member with the role they already held. The only way back in: a user is keyed on tenant and email, so a revoked address that could not be restored could never rejoin its own workspace.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Scoped exactly like the revoke route.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id | path | string | Yes | Id of the member to reinstate |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/restore?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the user projection with active back to true. Nothing is issued here — the member signs in again from scratch, and an authenticator they had enrolled still applies. Restoring an already-active member is a no-op that returns the projection unchanged.
Errors
| Status | Detail |
|---|---|
401 | admin credentials required |
403 | admin role required |
404 | user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
PATCH /api/auth/users/{user_id}/role
Change a user's role.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id | path | string | Yes | Id of the user to modify |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
role | string | Yes | global_administrator or agent_id_developer |
curl -sS -X PATCH "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/role?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-H "Content-Type: application/json" \
-d '{"role": "global_administrator"}'
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "global_administrator",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
Errors
| Status | Detail |
|---|---|
400 | invalid role — value is not one of the two valid roles |
401 | admin credentials required |
403 | admin role required |
404 | user not found — unknown id, or an id in another tenant when called by a session/bearer admin |
409 | cannot demote the last admin of a tenant |
POST /api/auth/mfa/enroll
Generate a TOTP secret and provisioning material. Does not persist the secret — call /api/auth/mfa/confirm to activate it.
Auth required: either a pending challenge_id (mid-sign-in, before any session exists) or the ll_session cookie. The Authorization: Bearer header is not consulted by this endpoint.
Rate limited: yes (mfa-enroll bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
name | string | null | No | Overrides the issuer label in the otpauth:// URI. Defaults to TrueIdiom |
challenge_id | string | null | No | Pending MFA challenge id, proving the password step already passed |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/enroll?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6"}'
{
"secret": "K5XW6ZDPN5YHI2LPNZSXG43F",
"otpauth_uri": "otpauth://totp/TrueIdiom:maria%40acme-legal.com?secret=K5XW6ZDPN5YHI2LPNZSXG43F&issuer=TrueIdiom",
"qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
}
| Field | Type | Description |
|---|---|---|
secret | string | Base32 TOTP secret. Hold it client-side and post it back to /confirm |
otpauth_uri | string | Provisioning URI for authenticator apps |
qr_code_data_url | string | PNG data URL rendering of otpauth_uri |
Errors
| Status | Detail |
|---|---|
401 | invalid or expired MFA challenge — a challenge_id was supplied but is unusable |
401 | authentication required — no challenge_id and no valid session cookie |
429 | Rate limited |
POST /api/auth/mfa/confirm
Verify a TOTP code against a freshly generated secret and persist it. When a challenge_id is supplied, a valid code is itself MFA proof and the call completes sign-in.
Auth required: same as /api/auth/mfa/enroll — a pending challenge_id or the ll_session cookie.
Rate limited: yes (mfa-confirm bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
secret | string | Yes | The secret returned by /api/auth/mfa/enroll |
code | string | Yes | Current TOTP code. Verified with a ±1 step window |
challenge_id | string | null | No | Pending challenge id. When present, the challenge is consumed and tokens are issued |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/confirm?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"secret": "K5XW6ZDPN5YHI2LPNZSXG43F",
"code": "418205",
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6"
}'
With challenge_id (sign-in completed, ll_session set on the response):
{
"enrolled": true,
"recovery_codes": [
"7f3a1-c04d9", "b2e58-1a7c0", "4f299-6ad31", "57e0b-3c8a2",
"9d1f4-6e0af", "2d75b-18b41", "d7e62-93c04", "a158f-2b7c5"
],
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
}
}
Without challenge_id (already-signed-in user adding TOTP) the response carries enrolled and recovery_codes only — no token key, no cookie change. The issued token always uses scope translate:jobs.
| Field | Type | Description |
|---|---|---|
enrolled | boolean | Always true on 200; a bad code is a 400, not {"enrolled": false} |
recovery_codes | string[] | Eight single-use codes, xxxxx-xxxxx. Only hashes are stored — this response is the only time the plaintext exists. Show them to the user and let them save them before continuing |
token | object | OAuth2TokenResponse. Present only when a challenge_id was supplied, meaning this call completed a sign-in |
Re-confirming replaces the authenticator and the recovery set: the codes returned by an earlier confirm stop working.
Errors
| Status | Detail |
|---|---|
400 | invalid verification code |
401 | invalid or expired MFA challenge / authentication required |
429 | Rate limited |
422 | Missing secret or code |
POST /api/auth/mfa/recovery/regenerate
Issue a fresh set of eight recovery codes, invalidating every previously issued one.
Auth required: the ll_session cookie — a signed-in, enrolled user. A pending challenge_id is not accepted: that caller has cleared only one factor, and minting recovery codes from there would turn a stolen password into a standing second factor.
Rate limited: yes (mfa-recovery bucket).
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/recovery/regenerate?api-version=2026-09-01" \
-b "ll_session=$SESSION"
{
"recovery_codes": [
"0e4a9-d4d2f", "5b0e4-a9d4d", "1f5b0-e4a9d", "c1f5b-0e4a9",
"7c1f5-b0e4a", "b8xQ2-mL7pT", "kR3vB-8xQ2m", "3vB8x-Q2mL7"
]
}
Errors
| Status | Detail |
|---|---|
401 | authentication required — no valid session cookie |
409 | no authenticator app is enrolled — nothing to recover into. The request is well-formed; the account state is wrong for it |
429 | Rate limited |
POST /api/auth/users/{user_id}/mfa/reset
Clear a member's authenticator and recovery codes so they re-enrol at their next sign-in. The answer to a lost phone once the recovery codes are gone too.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id | path | string | Yes | Id of the member to reset |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/mfa/reset?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the same user projection as GET /api/auth/users, with totp_enrolled now false:
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
The member's next sign-in produces a totp_enroll challenge (assuming MFA is still required of them), and confirming it issues a new authenticator and a new recovery set. Recorded as mfa_reset_by_admin in the audit log.
Errors
| Status | Detail |
|---|---|
401 | admin credentials required |
403 | admin role required |
404 | user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
POST /api/auth/users/{user_id}/password-reset-link
Mint a password-reset link for a member. The answer to a forgotten password in a deployment with no mailer: the admin is the delivery channel.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id | path | string | Yes | Id of the member to mint a link for |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/password-reset-link?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
{
"reset_url": "https://trueidiom.com/signin?reset=3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"token": "3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"expires_in": 3600
}
| Field | Type | Description |
|---|---|---|
reset_url | string | Absolute link to hand to the member |
token | string | The same credential, unwrapped, for a "copy code" affordance |
expires_in | integer | Seconds until the link expires (3600) |
This is a bearer credential for one account. Deliver it over a channel you trust and never log it. Minting again replaces any outstanding link for that user, so a mis-delivered one is killed by minting another.
Recorded as password_reset_link_created.
Errors
| Status | Detail |
|---|---|
400 | user signs in with an identity provider — no password to reset |
400 | this member's access has been revoked — restore it first — the member exists and the admin can see them, but no password will get a revoked account in; POST /api/auth/users/{user_id}/restore comes first |
401 | admin credentials required |
403 | admin role required |
404 | user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
POST /api/auth/password-reset
Set a new password from a reset link, and sign the holder in.
Auth required: none — the token is the credential. Rate limited: yes (password-reset bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The token from the mint call. Self-locating, so no email or tenant is accepted (or needed) here |
new_password | string | Yes | Subject to the full server password policy |
curl -sS -X POST "https://trueidiom.com/api/auth/password-reset?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"token": "3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"new_password": "correct-horse-battery-staple"
}'
{
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {"user_id": "3b8e5d1a7c04f2996ad3157e", "tenant_id": "7c1f5b0e4a9d4d2f", "email": "tomas@acme-legal.com", "provider": "email", "role": "agent_id_developer"},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"mfa_required": false
}
The ll_session cookie is set alongside. When the member's sign-in policy requires MFA the response carries "mfa_required": true with an mfa_challenge and next instead — and no token key. Branch on mfa_required. See (h) Password reset.
Completing a reset revokes every other access and refresh token for that user, and consumes the link. Recorded as password_reset_completed with the number of tokens revoked.
Errors
| Status | Detail |
|---|---|
400 | Password policy rejection (password must be at least N characters, password is too common; choose a less predictable one, password must not match your email address). The link is not consumed — retry with a better password |
401 | invalid or expired reset link — malformed, unknown, expired, or already consumed. One generic answer, deliberately |
429 | Rate limited |
422 | Missing token or new_password |
POST /api/auth/invites/preview
Report what an invite link opens, for a sign-in page's "you are joining X" banner.
Auth required: none — the token is the credential. Rate limited: yes (invite-preview bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The invite token (TENANT_ID.SECRET) |
curl -sS -X POST "https://trueidiom.com/api/auth/invites/preview?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"token": "7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU"}'
{"valid": true, "tenant_name": "Acme Legal", "role": "agent_id_developer"}
Every failure is 200 {"valid": false} — malformed, unknown tenant, wrong secret, expired, revoked. There is no 404 and no distinct message, because this route is public and either would turn it into a tenant-enumeration oracle. The success shape carries the workspace name and role only; the invite's bound email, label, and creator belong to the admin who minted it.
Errors: 429 (rate limited), 422 (missing token).
PATCH /api/tenants/security
Set a tenant's sign-in policy: the two MFA flags and SSO domain auto-join. The only writer of the mfa_ and sso_ tenant-metadata namespaces; see (d) MFA and Domain auto-join for what the flags do.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant_id | query | string | null | Conditional | Required with the operator API key, which has no workspace of its own to fall back on and answers 400 without it. A designated operator session defaults to the workspace it is signed into and names ?tenant_id only to reach a different one. A session/bearer tenant admin may omit it (their own tenant is used) or name their own tenant; naming another tenant returns 404 |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
mfa_required | boolean | null | No | Every member must present MFA to sign in. null/omitted leaves the current value alone |
mfa_required_for_sso | boolean | null | No | Extend that requirement to completed IdP sign-ins. null/omitted leaves the current value alone |
sso_auto_join | boolean | null | No | Allow a new user matching the tenant's Google Workspace domain (hd) to join without an invitation. Absent metadata means true, so only an explicit false closes the domain. null/omitted leaves the current value alone |
Every field is tri-state, so one flag can be changed without restating — and clobbering — the others. An empty body ({}) is a read: it returns the effective policy and writes nothing, not even an audit record.
curl -sS -X PATCH "https://trueidiom.com/api/tenants/security?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-H "Content-Type: application/json" \
-d '{"mfa_required": true}'
{"mfa_required": true, "mfa_required_for_sso": false, "sso_auto_join": true}
All three keys are returned on every call, including one that patched only an MFA flag. That is deliberate: clients read an absent sso_auto_join as on, so omitting it would render a closed domain's checkbox as open until the page reloaded.
The response is always the resulting policy, read back through the same helpers the next sign-in will evaluate — not an echo of the request. A change is recorded as mfa_policy_changed in the audit log, naming the acting admin (the operator key has no principal to name) and the flags set.
Turning mfa_required on does not sign anyone out: existing tokens remain valid until they expire, and the requirement applies from each member's next sign-in. Members with no authenticator get a totp_enroll challenge then — no mailer, no admin provisioning step.
Errors
| Status | Detail |
|---|---|
400 | tenant_id query parameter is required with API-key access |
401 | admin credentials required |
403 | admin role required |
404 | tenant not found — unknown id, or another tenant's id from a session/bearer admin |
POST /api/auth/mfa/verify
Complete an MFA challenge and receive tokens. The JSON equivalent of the urn:ietf:params:oauth:grant-type:mfa-otp grant; use whichever encoding suits your client.
Auth required: the challenge_id itself is the credential.
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
challenge_id | string | Yes | From the mfa_challenge object. A totp_enroll challenge cannot be completed here — enrol instead |
code | string | Yes | TOTP code or a single-use recovery code (xxxxx-xxxxx) |
scope | string | No | Default translate:jobs |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/verify?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6",
"code": "418205",
"scope": "translate:jobs"
}'
Returns an OAuth2TokenResponse (identical shape to the token endpoint) and sets the ll_session cookie.
Errors
| Status | Detail |
|---|---|
401 | invalid or expired MFA challenge — unknown, consumed, expired, or wrong code |
422 | Missing challenge_id or code |
POST /api/oauth2/{provider}/authorize
Mint the single-use state (and, for OIDC providers, the nonce) that binds a sign-in attempt.
Auth required: none. Rate limited: yes (oauth-authorize bucket).
Gated by: the Google sign-in switch.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Yes | google — the only supported value. Trimmed and lowercased; anything else is 404 unknown oauth provider |
Request body — application/json
All fields optional; send {} for the common case.
| Name | Type | Required | Description |
|---|---|---|---|
tenant_name | string | null | No | Name to use if the callback ends up creating a tenant |
tenant_id | string | null | No | Pin the sign-in to an existing tenant. The callback refuses a mismatching tenant_id |
monthly_char_limit | integer ≥ 1 | null | No | Applied when the callback creates the tenant |
redirect_uri | string | null | No | Recorded on the state |
email_hint | string | null | No | Normalized and pinned on the state |
metadata | object | No | Merged into tenant metadata if the callback creates a tenant |
invite_token | string | null | No | An invite link's token. Carried through the IdP round-trip inside the server-held state — the browser never hands it back at the callback, which would make it a free choice of tenant — and popped out before any tenant metadata is written. A live invite decides which tenant the completed sign-in lands in; an unusable one is ignored. See (g) Invites |
curl -sS -X POST "https://trueidiom.com/api/oauth2/google/authorize?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"tenant_name": "Acme Legal"}'
{
"provider": "google",
"state": "R7xK2mQ9vL4pT0nB8yD1wH6zJ3cF5gA0",
"expires_at": "2026-07-26T14:39:41.221904+00:00",
"authorize_url": "/api/oauth2/google/callback?state=R7xK2mQ9vL4pT0nB8yD1wH6zJ3cF5gA0&email={email}",
"nonce": "8yD1wH6zJ3cF5gA0eU2iO7"
}
| Field | Type | Description |
|---|---|---|
provider | string | Normalized provider label |
state | string | Single-use state. Send it to the provider and back to the callback |
expires_at | string (ISO 8601) | 600 seconds after issue, by default |
authorize_url | string | Callback path template for this state |
nonce | string | Present for google. Send as the OIDC nonce; it is verified against the id_token at callback |
Errors: 400 (invalid email address if email_hint is malformed), 403 (provider disabled), 404 (unknown oauth provider — the path value is not google), 429 (rate limited), 422 (malformed body).
POST /api/oauth2/{provider}/callback
Verify the provider's response, resolve or create the tenant and user, and issue tokens.
Auth required: the verified state plus the provider's id_token/code.
Rate limited: yes (oauth-callback bucket).
Gated by: the same flag as authorize.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Yes | google — the only supported value; anything else is 404 unknown oauth provider. Must match the provider recorded on the state |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
state | string | Yes | The state from authorize |
id_token | string | null | Unless code | OIDC ID token. Supply this or code |
code | string | null | Unless id_token | Authorization code; the server exchanges it |
code_verifier | string | null | With code | PKCE verifier for the exchange |
redirect_uri | string | null | With code | Redirect URI used in the authorize request |
email | string | null | No | Ignored — the email comes from the verified token |
tenant_id | string | null | No | Must match the state's tenant_id when both are present |
tenant_name | string | null | No | Overrides the state's tenant_name when creating a tenant |
monthly_char_limit | integer ≥ 1 | null | No | Overrides the state's value when creating a tenant |
metadata | object | No | Merged over the state's metadata when creating a tenant |
# The server performs the code exchange
curl -sS -X POST "https://trueidiom.com/api/oauth2/google/callback?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"state": "M4pT0nB8yD1wH6zJ3cF5gA0eU2iO7rY4",
"code": "4/0AVMBsJhq2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7xN0k",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirect_uri": "https://trueidiom.com/signin/callback"
}'
Response 200
{
"account": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 500000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {
"google": {"hd": "acme-legal.com"}
}
},
"tenant_api_key": "kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7x",
"mfa_required": false,
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "oauth2",
"role": "global_administrator"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"next": {"oauth2_token": "/api/oauth2/token"}
}
| Field | Type | Description |
|---|---|---|
account | object | The resolved or created TenantAccount |
tenant_api_key | string | null | Non-null only when this call created the tenant. Returned once |
mfa_required | boolean | true when the resolved tenant sets mfa_required_for_sso and a requirement is in force (the platform-wide requirement or the tenant's mfa_required) — or the signed-in user is a designated platform operator, who is always challenged whatever the tenant's policy says. A completed IdP sign-in is otherwise exempt — see (d) MFA |
token | object | OAuth2TokenResponse. Present only when mfa_required is false; the ll_session cookie is set alongside it |
mfa_challenge | object | Present only when mfa_required is true, with purpose: "oauth_login" |
next | object | Follow-up paths: oauth2_token, plus either verify_mfa or the mfa_enroll/mfa_confirm pair, matching the challenge method |
Unlike the token endpoint, token.user.role here reflects the user's resolved role.
Errors
| Status | Detail |
|---|---|
400 | invalid or expired oauth state |
400 | oauth provider mismatch — path provider differs from the state's provider |
400 | oauth state is missing its nonce; restart sign-in |
400 | code or id_token is required for Google callback |
400 | google id_token nonce mismatch, google account email is not verified, and other token-verification failures |
400 | tenant_id does not match authorized oauth state |
400 | this email is already linked to a different Google account — contact your tenant admin |
403 | Provider disabled |
403 | this organization requires an invitation — ask your workspace admin for an invite link — the org claim matched a tenant with sso_auto_join off, and this user is neither an existing member nor carrying an invite |
404 | unknown oauth provider — the provider path value is not google, checked before the state is consumed |
404 | tenant not found — the pinned tenant_id does not exist |
429 | Rate limited |
422 | Missing state |
GET /api/oauth2/google/client-config
Public client parameters for the Google OIDC flow.
Auth required: none. Gated by: the Google sign-in switch.
curl -sS "https://trueidiom.com/api/oauth2/google/client-config?api-version=2026-09-01"
{
"enabled": true,
"missing_reason": null,
"client_id": "418205773190-k5xw6zdpn5yhi2lpnzsxg43f.apps.googleusercontent.com",
"authorize_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"scope": "openid email profile",
"response_type": "code",
"code_challenge_method": "S256",
"redirect_path": "/signin/callback"
}
| Field | Type | Description |
|---|---|---|
enabled | boolean | true only when both a client id and a client secret are configured |
missing_reason | string | null | Operator-facing hint when enabled is false |
client_id | string | Public OAuth client id |
authorize_endpoint | string | https://accounts.google.com/o/oauth2/v2/auth |
scope | string | openid email profile |
response_type | string | code |
code_challenge_method | string | S256 |
redirect_path | string | Path Google redirects back to |
There is no token_endpoint here by design: the browser never calls it. Post the code to /api/oauth2/google/callback and the server exchanges it with the client secret.
enabled: false with a populated missing_reason is a normal 200 response, not an error — render your sign-in button accordingly.
Errors: 403 Google sign-in is disabled.
Operational notes
Rate limiting
Auth endpoints are rate-limited per client IP and per endpoint bucket. The budget defaults to 30 attempts per 60 seconds, and rate limiting is on by default but can be switched off per deployment. The client IP is taken from the first entry of X-Forwarded-For when present.
| Bucket | Endpoint |
|---|---|
signup | POST /api/auth/signup/email |
token-password | POST /api/oauth2/token, grant_type=password |
token-mfa | POST /api/oauth2/token, MFA grant (additionally keyed by challenge_id) |
mfa-enroll | POST /api/auth/mfa/enroll |
mfa-confirm | POST /api/auth/mfa/confirm |
mfa-recovery | POST /api/auth/mfa/recovery/regenerate |
invite-preview | POST /api/auth/invites/preview |
password-reset | POST /api/auth/password-reset |
oauth-authorize | POST /api/oauth2/{provider}/authorize |
oauth-callback | POST /api/oauth2/{provider}/callback |
Exceeding a bucket returns 429 {"detail":"too many requests — slow down and retry later"} with a Retry-After header carrying the window length in seconds. Where the deployment keeps its rate-limit counters in a shared store the budget is shared across app instances; otherwise it is per-process.
Request size limits
Two limits apply to every request, including auth requests:
| Limit | Value | Behaviour |
|---|---|---|
| Request body | 30000000 bytes (30 MB) | Enforced globally and streaming, so it also covers chunked bodies. Exceeding it returns 413 {"detail":"request body exceeds MAX_UPLOAD_BYTES"} |
Declared Content-Length | 10000000 bytes | Checked before the body is read, on endpoints that call the payload-limit helper. Exceeding it returns 413 {"detail":"request body exceeds MAX_REQUEST_BYTES"}; a non-numeric Content-Length returns 400 {"detail":"invalid Content-Length header"} |
Translation payloads are additionally bounded to 50000 source characters per text and 50 translations per batch request, both returning 413.
Request correlation
Send X-Request-ID: <id> to correlate your logs with the server's; the value is bound into every server log line for that request. The server echoes it back in the X-Request-ID response header, generating one when you do not supply it.
Deployment switches that change auth behaviour
These are set per deployment, never through the API. They are listed because each one changes what an integration sees.
| Switch | Default | Effect |
|---|---|---|
| Operator break-glass key | unset | The cross-tenant operator credential. Configured on every production deployment; see (a) |
| Platform-operator designation | none | Emails designated as platform operators. A matching signed-in user gets cross-tenant reach only while an authenticator is enrolled, and their sign-ins are always MFA-challenged. Deployment config only — no route grants it; see (i) |
| Email signup | on | Off → POST /api/auth/signup/email returns 403 |
| Email/password sign-in | on | Off → the password grant returns 403 |
| Platform-wide MFA requirement | off | On → signup and the password grant return an MFA challenge instead of tokens, for every tenant. Completed IdP sign-ins stay exempt unless the tenant sets mfa_required_for_sso. A single tenant can require MFA without this via PATCH /api/tenants/security |
| MFA dev mode | off | On → a user with no authenticator may be handed an email_otp challenge whose one-time code is returned in the response body. Local development only: it is off by default, the deployment logs a warning when it is on, and no production deployment ever emits an email_otp challenge |
| Google sign-in | off | Off → the Google authorize/callback/client-config endpoints return 403 |
| Access-token lifetime | 3600 s | Access-token lifetime and session-cookie Max-Age |
| Refresh-token lifetime | 2592000 s | Refresh-token lifetime |
| MFA challenge lifetime | 300 s | How long a challenge stays completable |
| OAuth state lifetime | 600 s | How long an authorize state stays redeemable |
| Minimum password length | 12 | Server-side minimum at registration |
| Session cookie name | ll_session | Name of the cookie the token endpoints set |
Session cookie Secure flag | on in production | Makes the cookie HTTPS-only |
| Auth rate limiting | on | Auth endpoint rate limiting |
Two further switches gate features you will meet immediately after authenticating, and are documented here because their failure modes look like auth failures:
- Translation (on by default) — when switched off, endpoints that start new translation work return
503 {"detail":"translation is temporarily disabled"}. Reads, reviews, and downloads of existing jobs continue to work. - Billing (off by default) — when off, the
/api/billing/*routes return503 {"detail":"billing is not enabled"}and no subscription gate applies anywhere. When on, endpoints that start billable work return402for tenants without a current subscription (or with an exhausted free-trial character grant), with detail text naming/billing.