Admin API authentication
Admin API access is controlled at the network level. The admin interface is bound to your provisioned endpoint and protected by your Myra Security account credentials. Inference endpoints use a bearer-token model described below.
Admin API
Security posture
All endpoints under /admin/v1/ are accessible to authenticated operators via your Myra Security account. The admin API is intended to be called by the admin UI or from automation scripts on your management network.
💡 Note: For on-premise deployments, bind the admin listener to an internal network interface and restrict access to trusted hosts. Do not expose the admin API directly on a public interface without network-level controls in place.
Transient faults never invalidate a session. Every authenticated admin endpoint (and the sign-in endpoints below) distinguishes a definitively missing, deleted or disabled account (401/403/404, and for the session routes a cleared cookie) from a transient backing-store fault, which returns 503 with a retryable error body. A 503 sets or clears no session cookie, increments no failed-attempt counter and records no denial in the audit log — clients should retry with backoff (a 503 on POST /admin/auth/otp/request has already spent one of the ten request-cap slots, because the cap is checked before the lookup). One deliberate exception: on POST /admin/auth/otp/verify a fault that hits after the code was accepted (while the account is being loaded) has already consumed that code — the client should request a fresh one rather than replay it. A backing-store fault can never authenticate a request either; the gate fails closed.
Browser sign-in endpoints
The browser sign-in flow uses a passwordless one-time-code (OTP) exchange. These endpoints are reachable without a prior session (they establish or read it):
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/admin/auth/otp/request |
Public | Request a one-time sign-in code for an e-mail address. Always returns a generic success message regardless of whether the address is registered. Address shape: the email is canonicalised (surrounding whitespace trimmed, ASCII letters lower-cased) and must be printable ASCII with no inner whitespace and exactly one @; a value that is absent or empty is 400 { "error": "email required" }, one that is malformed (a non-ASCII or control byte, inner whitespace, no single @, or a raw length above 254 bytes) is 400 { "error": "…", "code": "invalid_email" } — nothing is stored or sent in either case. Prior codes stay valid (keep-live, AGF-3080): a request does not invalidate earlier unused sign-in codes for the address — a resend no longer strands a user who then enters the first email's code. Only DEAD codes (already used, expired, or guess-budget-spent) are purged on a new request, so several codes can be live at once (bounded by the per-address request cap). Codes are scoped by purpose (the cancellation page's confirmation codes are a separate set that a sign-in code never verifies and a sign-in request never purges, see Billing). All live sign-in codes for the address share one guess budget — every wrong guess counts against every live code — and a successful sign-in, via either the typed code or the magic link, retires every live code and link for the address so no stale sibling can mint a second session. The request and hourly caps bound the episode. (This replaces the former newest-only behaviour, where a resend deleted the prior code; the residual — a previously-issued code stays live until expiry (≤ the code window) or a successful sign-in rather than being killable by a resend — matches what the cancellation codes already accept.) Cap: at most 10 requests per address per code window — a 429 { "code": "otp_request_cap" }, identical for a registered and an unregistered address. There is deliberately no per-client-IP cap on this route: the client address is taken from X-Forwarded-For, which a caller can set, so a per-IP cap on the primary sign-in would be a lever against a chosen office's egress address while bounding nothing. The code window is a deployment setting (default 900 s; 60–86400, out-of-range values fall back to the default). A transient database fault returns 503 (no code was stored — request again; the request-cap slot is still spent, the cap is checked before the lookup so a registered and an unregistered address answer alike). |
POST |
/admin/auth/otp/verify-link |
Public | AGF-3046: sign in via the single-use magic link instead of typing the code. Body { token } — a 43-char base64url token that rides the login email's URL fragment (/login#token=…) and is POSTed by the SPA (never a GET, so the token never reaches nginx access logs or the Referer header). On success sets the same aig_admin session cookie and returns the same { "user": … } payload as /otp/verify, through the identical success tail (full-row re-resolve → login_blocked gate → real-iat session). remember_me is server-fixed false on the link path (a fresh-browser click carries no checkbox) → the standard 8-hour session. ABSENT, malformed, unknown, expired and already-used tokens all return the one generic 400 { "error": "invalid_or_expired" } — no enumeration oracle (invariant 11); a transient DB fault is 503, never a definitive 400 on a valid token. Single-use: a successful verify sets used_at and NULLs the token, so a replay is inert and the 6-digit code on the same row is also consumed (exactly one of {link, code} wins). AGF-3080: because sign-in codes are now keep-live (a resend can leave several live), a successful link verify retires every live sign-in code and link for the address, not only the token's own row — so a sibling code/link cannot survive the real sign-in. A coarse global ceiling (otp_link_global) bounds random-token load; no per-IP cap (the X-Forwarded-For rationale), and the 256-bit token makes guessing infeasible. Static-OTP accounts mint no link. |
POST |
/admin/auth/otp/verify |
Public | Verify the e-mailed code. On success sets the aig_admin session cookie and returns that full account payload nested under a top-level user key — { "user": { … } } — whose object matches GET /admin/auth/me field-for-field (including the tenant flags such as tenant_pii_masking_enforced and tenant_workflows_enabled). Note the shape difference: /me returns the account object bare, this route wraps it, so seed your cached user from response.user, not the response root. Optional remember_me extends the session. The email follows the same canonical shape as /otp/request (400 invalid_email when malformed); the code must be a string or a number — an absent, null, boolean, object or array code is 400 { "error": "email and code required" } and is never counted as an attempt. Per-code budget: five wrong guesses exhaust the code — the sixth and later attempts return 429 { "code": "otp_attempt_cap" } until a new code is requested (the same code is returned for an address with no live code, registered or not). Hourly ceiling: twenty rejected verification attempts per address within an hour — every wrong guess and every attempt answered 429 otp_attempt_cap, across codes — return 429 { "code": "otp_failure_cap" } until the hour has passed; a fresh code does not lift it, a successful sign-in clears it, and a 503 counts nothing. A valid code for a removed account returns 403; a transient database fault returns 503 (before the code was checked: nothing consumed or counted; while loading the account after the check: the code is consumed — retry with a fresh one) — no cookie is set in either case. |
GET |
/admin/auth/me |
Session cookie | Return the signed-in user's profile. 401 if the session is missing or invalid (cookie untouched) or the account was deleted or disabled (cookie cleared). A transient database fault returns 503 and leaves the cookie untouched — retry. The payload carries session_expires_at (unix seconds — the current session's expiry), which the app uses to drive sliding session renewal; it is absent while impersonating (the impersonation payload carries no session of its own). |
POST |
/admin/auth/renew |
Session cookie | Sliding session renewal — silently re-anchor the current aig_admin session's expiry to now + the original window so a long, active session never crosses exp and hard-redirects to /login mid-stream. On success sets a fresh aig_admin cookie and returns { "ok": true, "renewed": true, "expires_at": <unix seconds> }. See Sliding session renewal for the accepted input, the fail-closed rejects, the throttle, and the auth_time claim. |
PATCH |
/admin/auth/me |
Session cookie | Update the caller's own profile (locale, preferred name, work category, model instructions, one-shot UX flags). The response is the same full account payload as GET /admin/auth/me (including the tenant flags such as tenant_pii_masking_enforced), so a client can replace its cached user with the response without dropping any field. If the write commits but the fresh payload cannot be read back (transient database fault), the response is 503 with "saved": true — the change is stored; do not blind-resubmit. The account is re-read from the live row before anything is written: a deleted or disabled account gets 401, a cleared cookie and nothing is written; an account whose live role is demouser, or that has no system role at all, gets 403 (the session's own role claim is never consulted). Two 503 shapes exist: without saved a transient fault happened before anything was written — nothing was stored, retry; with "saved": true the write committed — reload, do not resubmit. See Updating your profile. |
POST |
/admin/auth/logout |
Public | Clear the session cookie. No session is verified — the call always clears the cookie and returns { "ok": true }. |
GET |
/admin/auth/methods |
Public | Capability probe for the register/sign-in quick sign-in row. Returns { "signin": [ … ], "signup": [ … ] }, each a JSON array of provider ids (never an object, even when empty). The SPA renders a provider button only when it appears here for that verb, so the buttons stay conditional and fail-closed — nothing configured returns two empty arrays and the row is absent, no error. A provider is named only when the platform flag oauth_login_enabled is on (default off ⇒ both arrays empty) and its OAuth credentials are fully provisioned — google requires its client id, secret and redirect URI to be provisioned, and microsoft likewise; a partial set is treated as absent. signin lists every such ready provider. signup lists a ready provider only when sign-up is additionally reachable — self-serve registration on (a deployment setting) — so the register screen never shows a button that would dead-end; signup is always a subset of signin. The AVV state does not hide the register button: the Sign up with … button captures the affirmative Terms + AVV consent itself (see Google & Microsoft quick sign-in). Read-only, no side effects, reveals only which providers are configured (never a secret). |
Sign-in return target (/login?next=). The browser sign-in page accepts an optional next query parameter naming the page to return to after the sign-in; it is written by the application itself (a protected page opened without a session, a shared conversation's Continue) and is validated in the browser only — no backend endpoint reads it. Accepted shape: a same-origin absolute path — a value that starts with exactly one /, at most 2048 characters, with an optional query and fragment — whose first path segment is not a pre-sign-in page (login, signup, demo, debug). Rejected: anything else — a scheme (https://…, javascript:…), a protocol-relative //host, a backslash anywhere in the path (browsers read \ as /, so /\host is protocol-relative and /x\..\page walks up), a percent-encoded slash or backslash (%2F, %5C, any case) in the path part (the query may carry them — a pre-filled /easy?text=… encodes the prompt's own slashes), a control character in any encoding (raw or percent-encoded), an embedded //, a . or .. path segment in any percent-encoded spelling, a malformed percent sequence, a non-string, an empty value. A rejected, empty, or absent next all land on the start view: the shared outcome is the most restrictive landing the application has, so a forged value can only lose the return and never gain a page. The value is passed to the in-application router only, never to a browser-level navigation. Single-sign-on and Google/Microsoft round trips carry no return target on the wire — the start endpoints read no next/RelayState return target and every sign-in callback lands on / (a Google/Microsoft registration lands on /easy); instead the browser keeps the validated value in the tab's session storage for at most ten minutes and the application applies it once, re-validated by the same rule, when that tab returns to /. A hostile value is never stored; an identity-provider error returns to /login?error=… and the stored value stays the return target for the retry; a round trip that returns in a different tab lands on the start view.
Sign-in code e-mail language. The code mail is sent in the first of: the account's own locale, the tenant's default_locale (tenant settings), English — the same chain every lifecycle mail uses (Lifecycle e-mails → Language).
Sliding session renewal
The aig_admin session JWT has a hard exp. Without renewal a long conversation that crosses exp gets a 401 on its next /admin call and hard-redirects to /login mid-stream, losing the in-progress draft. POST /admin/auth/renew removes that: while the session is still valid it silently re-mints a fresh session cookie with its expiry re-anchored to now + the original window, through the same issuing path a login uses (no second session mechanism). The application calls it in the background at roughly half the remaining lifetime (read from session_expires_at on /me), so an active session slides indefinitely and never lapses; renewal is a pure background request that never touches the composer, so the draft and any in-flight answer are preserved. There is deliberately no absolute-lifetime cap — an auth_time (original-login epoch) claim is carried unchanged across every renewal so one can be introduced later without a token or schema migration.
Accepted input / what is rejected (trust boundary). The only input is the aig_admin session cookie; the request has no body and the client is never the authorization boundary. It is validated fail-closed through the identical verify + live-account chain the whole admin API uses, and nothing is minted on any rejection — expiry cannot be defeated by presenting a garbage or expired cookie:
- absent cookie →
401(no session to renew); - malformed (non-JWT) or wrong-signature cookie →
401— absent and malformed are distinct answers, both refused; - expired token →
401(a pastexpis rejected before renewal is even considered); - wrong-purpose token (a
signup/impersonationcredential presented as a session) →401; - revoked / disabled / deleted account (the session's
iatis at or before the account's revocation floor, or the row is gone/blocked) →401and the cookie is cleared; - a transient database fault →
503(nothing minted, retry).
On success the response is { "ok": true, "renewed": true, "expires_at": <unix seconds> }. A ≈10-second per-session throttle bounds cookie/audit churn from multi-tab or visibility double-fires: a renewal that arrives within the window returns { "ok": true, "renewed": false, "expires_at": <current exp> } without re-minting (the throttle is best-effort — under shared-dictionary pressure it fails open and re-mints, never dropping a needed renewal). Every real renewal writes an auth.session_renewed audit row (never the token or email). Renewal is paused during impersonation (the impersonation payload carries no session_expires_at), which is bounded by the impersonation credential's own short lifetime.
💡 Note: Sessions are carried in the
aig_admincookie (a signed JWT). The Google/Microsoft quick sign-in (below) is separate from the standard email-OTP sign-in flow. The public no-login demo-login entry point is disabled:POST /admin/auth/demo-loginreturns404for every request and mints no session, so a/demo?for=<name>link can no longer start a session (it lands on the Demo unavailable card). The former per-address rate-limit on that entry no longer applies.
Google & Microsoft quick sign-in / registration
Personal-identity OAuth lets a customer sign in or register with a Google or a personal Microsoft (consumer MSA) account, beside email-OTP. This is distinct from the tenant-configured OIDC / SAML SSO below (that authenticates existing users against a tenant's own IdP; this creates and signs in self-serve customers). One code path serves both verbs — the button label is the only difference — so an unknown identity registers (a new free-trial tenant, exactly like the email trial funnel) and a known one signs in.
Ships dark, fail-closed on two independent gates. Every surface (the /admin/auth/methods probe, the initiate redirect, and the callback) is inert unless both the platform flag oauth_login_enabled is on (a DB-backed Feature Flag, platform-admin only, default off) and the provider's OAuth credentials are provisioned. Either gate failing ⇒ the button is not advertised and the endpoints return 404 (indistinguishable from an unmounted route — the client is never the authz boundary). Enabling a provider is therefore a deliberate act: flip the flag and provision creds.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/admin/auth/google · /admin/auth/microsoft |
Public (gated) | Initiate sign-in. Generates an opaque state + a nonce, stores them in the database (keyed by state, single-use, 10-min TTL) with the remember-me intent and the sign-up email locale, sets the aig_oauth_state cookie (HttpOnly; Secure; SameSite=Lax), and 302-redirects to the provider's authorize endpoint. The correlation is DB-backed (not an in-process per-node cache), so an active-active deployment where the initiate and callback land on different sites still completes — mirroring the OIDC SSO flow. The browser initiates this GET on the admin-auth host (the same host the callback is served from), so the host-only aig_oauth_state cookie set here is present at the callback — on a split-host deployment (SPA host ≠ admin-API host) a sign-in initiated on the SPA host would set the cookie on the wrong host and every callback would fail the CSRF check. 404 unless flag-on and creds present; rate-limited per client IP (429 past the cap), because the durable state row makes the unauthenticated initiate a write. Accepted query: remember (1/true for a 30-day session; anything else = the 8-hour default). Rejected: any consent parameter — consent_terms_version, avv_accepted or avv_version, with a value, valueless or repeated — answers 400 and stores nothing: consent is accepted only through the POST …/start below (a clickable link must never be able to carry a consent record), so a consent that arrives on this channel is treated as malformed, not as absent. |
POST |
/admin/auth/google/start · /admin/auth/microsoft/start |
Public (gated) | Initiate registration with consent — the register page's Sign up with … button, enabled only once the page's single Terms + AVV consent checkbox is ticked. A credentialed JSON POST from the application origin (CORS allowlist + Content-Type gate) with body { "consent_terms_version": "<version>", "avv_accepted": true }, validated by the same rule as POST /admin/auth/signup/request: consent_terms_version must be a string of 1–32 characters from [A-Za-z0-9 . - _ :] (stored verbatim as the legal record), and while an AVV version is in force avv_accepted must be the JSON boolean true. Rejected with 400, nothing stored, no cookie: a missing body, a body that is not a JSON object (null, a number, an array, a string), a body that fails to parse (malformed_body), a missing / empty / over-long / badly-charactered / non-string (null, number, array) consent_terms_version, and — while the AVV is active — an absent or non-true avv_accepted (false, "true", 1, null, an array). Same gates as the GET plus the registration gate: 404 unless flag-on and creds present and self-serve registration is on (a sign-up-only surface is as unmounted as the /signup/* routes when registration is closed), the same per-IP rate limit (429, applied before validation), 503 when the state row cannot be written. On success it mints the same state + nonce + cookie as the GET and stores the consent with the correlation — the terms version, the server's AVV version in force at that moment, and consent_at (the click time) — then answers 200 { "authorize_url": "<provider authorize URL>" }; the browser navigates there itself (the application follows the URL only if it is an https:// URL of at most 2048 characters). Content-Type gate: the request must carry Content-Type: application/json (parameters such as ; charset=utf-8 allowed, case-insensitive); a form or text/plain post, an absent header or a repeated header answers 400 before the body is read, with nothing stored and no cookie — so a cross-site page cannot run this endpoint with a CORS simple request (no preflight) either. Independently of that, a cross-site page can never read the response: the state and nonce stay secret, so nothing can be provisioned from a forged start. No remember-me on registration (the 8-hour session). |
GET |
/admin/auth/google/callback · /admin/auth/microsoft/callback |
Public (gated) | Trust boundary. When the provider redirects back with an ?error= instead of a code — a user cancel at the consent screen (access_denied) or a provider fault — the callback 302-redirects the browser to /login?oauth_error=<reason> (never a raw JSON body, which would render as text): access_denied → cancelled, any other provider error → try_again. No state is consumed and the cookie is not read on this branch (it issues nothing), mirroring the OIDC ?error= behaviour; the provider's error/error_description is never reflected. Otherwise (a normal code+state return): verifies the aig_oauth_state CSRF cookie, then atomically consumes the single-use state (a replay / expired / unknown state → 400; a transient DB fault on the consume → 503), exchanges code at the provider token endpoint over verified TLS, then validates the id_token and signs the user in (existing) or registers a new trial tenant (unknown — only when the correlation carries the consent recorded by POST …/start). |
Callback validation (every check fails closed; ABSENT and MALFORMED both reject — no session, no account). In order: code/state must be non-empty strings (400); the aig_oauth_state cookie must equal the returned state (400, CSRF — this double-submit check runs first) and then the state must be consumed server-side (400, expired/unknown); the stored entry must carry a non-empty nonce (400); if the stored entry carries any consent field, all of them must be well-formed — consent_version a non-empty string, avv_version a string, consent_at an integer no later than now + 60 s and no older than the 10-minute state lifetime plus 60 s (the 60 s absorbs clock skew between active-active sites in either direction) — else 400 (the entry is the server's own write, but a mangled one must neither pass as a consent nor fall through to the consent-less path); the token exchange must succeed (a 5xx, typically 502, on a non-2xx / network / non-JSON / missing-id_token response); the id_token claims must decode to an object (502); then iss must be an expected issuer (502), aud must equal the client id (502), exp must be a number ≥ now − 120 s (401, an absent exp is treated as expired), nonce must equal the stored value (401), the provider's verified-email claim must be the boolean true (Google email_verified, Microsoft xms_edov; a string "true", 1, or absent all 403), sub must be a non-empty string (502), and email must be a non-empty, RFC-shaped address — the email claim only, never preferred_username (which no claim attests) (502). Only then is the identity resolved.
Resolution. An existing active account → the OAuth identity is linked (oauth_link) and a session is issued (landing on /, honouring a remember-me from the GET initiate). A disabled account → denied (never routed to registration). An unknown identity → a new free-trial tenant is provisioned exactly like the email no-card trial (the same one-per-email dedup, per-domain/per-IP throttle, consent snapshot, and trial entitlement), only if self-serve registration is on and the correlation carries the consent recorded by POST …/start — the stored terms version, consent_at (the click time) and the AVV version are written to the signup intent exactly as /signup/request writes them, and the stored AVV version must still equal the server's current AVV version at the callback (a change between click and callback means the customer accepted a different document → nothing is provisioned and the register page asks again; this is stricter than the email funnel, which trusts its request-time snapshot). A flow that started on the sign-in GET has no consent, so an unknown identity there is bounced to the register page rather than provisioned. The new trial lands on /easy with the 8-hour session. A concurrent double-registration of the same email yields exactly one tenant (the loser is signed into the winner's account, not errored).
Bounce targets (?oauth_error=<reason>). User-facing outcomes are browser redirects. Registration outcomes return to the register page's trial step — /signup?trial=1&oauth_error=<reason> with consent_required (no consent in the correlation, or the AVV version changed), registration_unavailable (self-serve off), already_registered, rate_limited, or try_again (a transient fault during a registration flow); the page shows the matching message and, for consent_required, moves focus to the consent checkbox. Sign-in outcomes return to /login?oauth_error=<reason> with account_suspended, account_unavailable (the account's organisation was removed), cancelled (the user cancelled at the provider's consent screen — also where a cancelled registration lands, since the ?error= branch consumes no state and so cannot tell sign-in from sign-up; the Register tab is one click away), or try_again (a transient fault during a sign-in flow, or a registration that succeeded but whose session could not be issued — sign in to continue). The application maps the reason through one closed allowlist on both pages; an unknown or tampered value shows a generic message and is never reflected. Every outcome that reaches identity resolution (an account lookup, link, provision, or denial after the token exchange) is written to the audit log with the OAuth identity provider:sub in idp_entity_id (the wide column shared with SSO) and the tenant id in entity_id (empty pre-provision) — never the e-mail, so the audit log carries no PII here. The pre-exchange provider-error bounce (cancelled/try_again from an ?error= with no code) resolves no identity and writes no audit row — it is logged at ngx.WARN only, mirroring the OIDC ?error= path. The identity lives in idp_entity_id rather than entity_id because a long subject (e.g. a Microsoft oid GUID) overflows the UUID-sized entity_id and would otherwise drop the row. Microsoft's xms_edov requires the Azure app registration to emit the email + xms_edov optional claims; until validated against real consumer tokens the Microsoft provider is provisioned but left dark.
Public login branding (white-label)
So the login page can render a tenant's white-label branding before anyone signs in, two public endpoints resolve a tenant by slug (from a ?tenant=<slug> link). Both take a hostile slug and are hardened fail-closed:
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/admin/auth/brand/lookup?tenant=<slug> |
Public | Returns { "branded": true, "product_name", "greeting", "logo_version", "logo_light_version" } when the slug resolves to a tenant that has branding, else { "branded": false }. Only these non-sensitive fields are ever returned. |
GET |
/admin/auth/brand/logo?tenant=<slug>&variant=dark\|light&v=<version> |
Public | Returns the login logo as JSON { "mime", "version", "data" } where data is the base64-encoded image (image/png/image/jpeg). The login page fetches this over connect-src and renders it as a data: URL, so a split-host deployment (SPA host ≠ admin/auth host) is not blocked by the SPA img-src CSP that forbids a cross-host <img src>. Same shape as the in-app /admin/v1/tenants/{id}/logo asset route. v is the content-hash version and is required by the login page; when present the response is immutable-cacheable (a re-upload = new version = new URL). |
- Slug validation. The slug must be 1–64 characters of
[A-Za-z0-9._-](it is lower-cased). Anything else is treated as a miss. - Identical fail-closed misses (no oracle). An invalid-charset slug, an unknown slug, a soft-deleted tenant's slug, and a known but unbranded tenant all return the byte-identical
{ "branded": false }— the endpoint is not an account/tenant-existence oracle, and a database error also degrades to{ "branded": false }(never a 5xx that would confirm a match). The logo route returns a generic 404 for an invalid slug/variant, an unknown tenant, or a tenant with no such logo. - No data leak. The lookup reads a narrow column allowlist (product name, greeting, and the logo pair's version + MIME) — never plan, budget, SIEM/SSO posture, cloud regions, the chat disclaimer, or the sidebar links. The logo route re-clamps the stored MIME to the PNG/JPEG allowlist and never serves (or base64-encodes) any other type.
- Rate-limited per IP (its own bucket, separate from SSO discovery) to blunt slug enumeration.
How the public rate limits behave
There are two deliberately different shapes, and which one applies depends on what is being counted.
Per-IP request limiters — a fixed window. SSO discovery, login branding and its logo, the four
SAML routes, and the self-serve signup routes count requests per client IP. The window opens on
the first request from that client and closes a fixed number of seconds later, whatever happens in
between: a request that is refused does not extend it. So a 429 from these always clears on
its own once the window elapses — there is no state an operator has to reset, and no way for a
client to lock itself out permanently. Requests with no usable client IP share one bucket
(anything that is not an address — a blank or non-address X-Forwarded-For value, a hostname,
RFC 7239's unknown; a forged but well-formed address keys on that address), and each endpoint keeps its own counter, so exhausting one never affects another.
An IPv6 address carrying a zone id (fe80::1%eth0) counts as its address: the zone names the
receiving interface, not the client, and is stripped before keying, so such a client is never
thrown into the shared bucket where unrelated junk could exhaust its allowance.
Failure counters. The one-time-code guess budget is per code: five wrong guesses exhaust
the code itself (otp_attempt_cap), and the only way past that block is to request a new code
— which invalidates the exhausted one and starts a fresh budget of five. This is what keeps a
"request a new code" remedy honest across every gateway node. Around it sit two tumbling
windows keyed per address: at most ten code requests per code window (otp_request_cap) and
at most twenty rejected verification attempts per hour across codes (otp_failure_cap, cleared
only by a successful sign-in — and every rejected attempt counts, a wrong guess and an attempt
on an already-spent code answered 429 otp_attempt_cap alike, so hammering a dead code spends
the hour as surely as guessing does). Together they bound an unauthenticated caller to 5 × 10
guesses per window and 20 rejected attempts per hour on any one address — and, because anyone can send requests for any
address, to locking that address out of new codes for the rest of a window or out of
verification for the rest of an hour (a bounded nuisance, never an authentication). The
CI-session endpoint keeps its sliding failure window keyed per client-IP bucket (every
failure refreshes it, so the block anchors to the last failed attempt). A successful attempt is
never counted.
Where each counter lives — and the stated residual. The per-code budget is a column on the
code row, so it is the same on every gateway node. The tumbling windows and the five-guess
budget for an address that has no live code (an unknown address, a registered address whose
code expired, a static-code account) live in each node's memory, so those counts are per node
in a multi-node deployment. On one node an unknown address and a registered one are
indistinguishable in every response and over time. A caller whose requests reach two different
nodes can tell a registered address with a live code from an unknown one (five wrong guesses
on one node, a sixth on the other: the live code is already spent everywhere, the memory counter
on the second node is not) — only that state, only across nodes, and only within the code
window. For the same reason auth.otp_blocked is recorded once per episode per node.
Request body shape. POST /admin/auth/otp/request, POST /admin/auth/otp/verify, and PATCH /admin/auth/me expect the request body to be a JSON object. A body that is valid JSON but not an object — null or a bare scalar (42, true, "x") — as well as an absent body, is treated as an empty object. A JSON array is left unchanged (no admin route reads an array body). A malformed or unparseable body is not treated as an empty object: it is rejected at the trust boundary with 400 { "code": "malformed_body" } (an unreadable spooled body returns 503). The OTP routes have required fields, so an empty object fails their validation with 400 (for example, POST /admin/auth/otp/request with a null body returns 400 { "error": "email required" }). PATCH /admin/auth/me has no required field, so an empty object is a valid no-op that returns 200 with the unchanged profile. Only the whole-body shape is normalised here; per-field validation (required, non-empty, type) is enforced by each route.
Reactivation opt-out (public)
POST /admin/auth/reactivation/opt-out — the login-free opt-out behind every
member-reactivation e-mail. The mail links
to the SPA page /reactivation/opt-out#t=<token>; the token rides the URL fragment (never sent
to a server, so it is absent from every access log), and the page posts it only when the member
clicks confirm — a GET of the link records nothing, so a mail scanner pre-fetching links cannot
opt anyone out. There is no GET route.
Accepted: a JSON object body { "token": "<string>" } where the token is a signed HS256
credential the platform minted for that member (purpose = reactivation_optout, 60-day expiry),
at most 2048 bytes, naming a live (non-deleted) member. Extra fields are ignored.
Rejected → 400 { "ok": false, "error": "invalid_token" }, nothing written — deliberately one
answer for every failure (no oracle between them): an absent, empty, unparseable or non-object body;
an absent, non-string, empty or oversize token; a malformed, tampered, expired or wrong-purpose
token (a session token or a signup token is refused here exactly as a session verifier refuses a
reactivation token — the purpose claim gates both directions); a token naming an unknown or
soft-deleted member.
Success → 200 { "ok": true }. Idempotent: the first confirm stamps
user.reactivation_opted_out_at and writes one audit event (user.reactivation_opted_out,
actor type user, with the client IP); a repeat is 200 with no second event. An administrative
opt-in does not invalidate the member's token — a renewed objection wins (Art. 21(3)).
Signup-abandonment reminder (public)
AGF-3044. A platform-global maintenance sweep (signup_reminder_enabled) — enabled
platform-wide by migration 0354 after the DPO/legal sign-off (integration already; production
when the release lands), though the flag ships dark in code
(AGB/AVV acceptance is not express e-mail consent, §7 UWG), and a platform admin can turn it off
again at any time — sends each unverified, consent-bearing self-serve signup one reminder that re-mints a fresh
30-minute 6-digit code and a 24-hour magic link, so the person can finish. Eligibility is
one-per-email (the newest unverified intent): it excludes intents that recorded no consent (the
pay-first funnel), that are already verified/consumed/in-checkout, that already own an account, that
were already reminded, that are suppressed, and test/fixture addresses; the newest intent must be
1 h–72 h old (past the instant-or-never verify window, well inside the 14-day intent reaper). Bounded
by a per-tick cap (20) and a platform daily cap (signup_reminder_daily_cap, default 100).
POST /admin/auth/signup-reminder/opt-out — the login-free unsubscribe behind every reminder. The
mail links to the SPA page /signup-reminder/opt-out#t=<token>; the token rides the URL fragment
(never sent to a server) and is posted only on confirm — no GET route, so a link-prefetching mail
scanner cannot opt anyone out. Accepted: a JSON object { "token": "<string>" } where the token
is a signed HS256 credential (purpose = signup_reminder_optout, 30-day expiry) naming the
signup e-mail, at most 2048 bytes. Rejected → 400 { "ok": false, "error": "invalid_token" },
nothing written — one answer for every failure (absent/empty/unparseable/non-object body;
absent/non-string/empty/oversize token; malformed/tampered/expired/wrong-purpose token — a
reactivation_optout token is refused here and vice-versa, the purpose claim gating both; a
non-e-mail subject). No enumeration oracle: the reply never reveals whether that address ever signed
up. Success → 200 { "ok": true }. Idempotent: the first confirm inserts a
signup_reminder_suppression row (a retained Art. 21 objection record — it outlives the signup
intent and is never reaped) and writes one signup_reminder.opted_out audit event; a repeat is 200
with no second row. The transactional /signup/request code path never consults the suppression
list, so unsubscribing from reminders never blocks a requested signup code.
Other answers. 429 { "error": "rate_limited" } — a per-client-IP fixed window of 30 requests
per minute on its own bucket (see how the public rate limits behave);
the page shows "try again in a minute", never "broken link". 503 { "error": "temporarily_unavailable" }
— a database fault; this is never disguised as invalid_token, because it does not depend on the
token.
Demo contact capture
💡 Note: The public no-login demo entry is disabled (
POST /admin/auth/demo-login→404), so no newdemousersessions can start. Thedemouserrole and the endpoint below remain for any session already issued before the cut-off (valid until its natural expiry); the documented behaviour applies to such a live session only.
The no-login demo (role demouser) collects a prospect's contact details once per visit, before it may send any prompts. GET /admin/auth/me returns demo.contact_needed: true until the form is submitted this visit; the SPA shows a mandatory modal while it is true.
Mobile environment switch flag
GET/PATCH /admin/auth/me (and the /otp/verify login payload) carry can_switch_mobile_env — a 0|1 integer, server-derived from the per-user mobile_env_switch column (default 0). It is 1 only for a test user an operator has explicitly entitled; it drives the mobile app's Production/Integration environment switcher, which the SPA renders only when the flag is 1 and the native app bridge is present.
Accepted shape / what is rejected (trust boundary). The field is server-authoritative and read-only on this payload — the client cannot set it (PATCH /admin/auth/me ignores any mobile_env_switch/can_switch_mobile_env in the body; it is not a writable profile field). The serializer normalises the stored value with the house nonzero → 1 rule and fails closed to 0 for an absent, NULL, or non-numeric value, so a corrupt row hides the control rather than exposing it.
It is a UX visibility gate, not a security control. The Integration backend enforces its own login; a user who is not a test user there cannot authenticate even if they reach it. The flag only decides whether the switcher is shown — the client is never the authorization boundary. To entitle a tester, set `user`.mobile_env_switch = 1 in the production database.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/admin/v1/demo/contact |
Session cookie (role demouser) |
Store one contact lead, then mark this visit's form as submitted (demo.contact_needed flips to false). |
This is the single write the demo identity is permitted besides ghost conversation creation and inference-token minting; every other write is rejected with 403.
Accepted body — a JSON object with exactly these string fields, each required and non-empty after trimming:
| Field | Max length (bytes) | Notes |
|---|---|---|
full_name |
200 | |
email |
320 | Must match local@domain.tld (a liberal shape check, not a deliverability check). |
company |
200 | |
phone |
60 | Stored verbatim; no format is imposed. |
Rejected (400): a missing, non-string, empty/whitespace-only, or over-length field; a malformed email. Rejected (403): any caller whose role is not demouser. On success the endpoint returns 201 { "ok": true }; leads are append-only (each submission is a new row) and carry no conversation content.
CI session grant (non-production only)
POST /admin/auth/ci-session mints the same aig_admin session an interactive login mints, for an allowlisted synthetic test identity — so automated test rigs authenticate reliably without driving the login UI. It exists only to make test auth deterministic; it is not a production feature and is designed so a leaked credential cannot become a standing backdoor.
It does not exist on production. The route is dual-gated, fail-closed, and returns a 404 — byte-identical to an unmounted route — unless both hold:
- The runtime environment is
devorint(utils.env.name(); an allowlist, so a future staging tier also fails safe). This gate is structural and cannot be flipped by a configuration slip. - The
auth.ci_sessionconfig block is present — configured only by Myra on int/dev deployments and never in the production environment.
As a third backstop, the minted session JWT carries a ci claim that verify_session_jwt rejects on any tier other than dev/int — so even under a shared-JWT-secret mistake a CI session cannot authenticate production. A static lint (scripts/lint_no_ci_session_in_prod.sh) fails the build if a prod config or .env.production* ever wires the block, or if the int and prod JWT secrets are equal.
Auth (trust boundary). Authorization: Bearer <secret>. Only the sha256 of the secret is stored server-side (in config); the plaintext lives solely in CI vaults. An absent, non-string, or wrong Authorization header is rejected 401 (constant-time compare; never throws). A duplicated Authorization header is collapsed to its first value and validated normally — a duplicate whose first value is the correct bearer succeeds. Wrong/absent-secret attempts are not audited (unattributable) but are rate-limited per source IP (10 failures / 300 s → 429); a successful grant is never counted.
Accepted body — a JSON object:
| Field | Type | Notes |
|---|---|---|
email |
string, required | Canonicalised like the sign-in routes (trimmed, ASCII lower-cased). Must be an exact member of the configured allowlist AND end in @local.test (the synthetic-test domain). The identity must already exist; the endpoint never creates users. |
ttl_minutes |
integer, optional | Session lifetime, clamped server-side to [1, 60] (default 15); any out-of-range or non-numeric value falls back to the default. |
Rejected: an absent, non-string or empty (after trimming) email → 400 (no identity asserted — nothing counted or audited); a malformed email (non-ASCII or control byte, inner whitespace, no single @, raw length above 254 bytes) is an asserted identity that can never be allowlisted and takes the same path as an unlisted one — 403, counted against the per-IP failure window and audited; an email not in the allowlist, not @local.test, or resolving to no/disabled account → 403; a transient DB fault → 503 (nothing minted). On success: 200 with a Set-Cookie: aig_admin=… (scoped to this admin-API host) plus a JSON body { token, max_age, expires_at, email } for non-browser consumers (e.g. a native WebView) — never the secret. Every successful grant and every post-authentication denial is written to audit_log (auth.ci_session_issued / auth.ci_session_denied) with the actor and IP but never the secret, token, or email.
Consumers are non-browser only (test-runner HTTP clients / curl): the admin-API CORS preflight allows only Content-Type, so a browser fetch sending Authorization fails preflight by design.
Self-serve sign-up (pay-first)
The public self-serve funnel lets a new customer verify an email, pick a plan, pay through Stripe Checkout, and land in the product — no operator provisioning. It is a distinct credential chain from the OTP login above: a short-lived signup JWT (in the aig_signup cookie, purpose=signup, scoped to /admin/auth/signup/) that the session verifier explicitly refuses, and that is swapped for the real aig_admin session only once payment has provisioned the tenant.
All of the self-serve endpoints below are gated by a deployment-level self-serve switch. When it is off every one returns 404 (indistinguishable from an unmounted route); the kill-switch stops new sign-ups only — the Stripe webhook keeps provisioning already-paid checkouts.
A second, narrower control governs only the paid entrance: the global platform setting paid_signup_enabled (a DB-backed Feature Flag, platform-admin only, default off). When it is off — the default — the plan picker is not offered and a new customer starts on the free trial; and POST /admin/auth/signup/checkout is refused server-side (404, fail-closed), so hiding the UI is not the only guard. A platform admin turns it on from the Feature Flags section at the top of Account › User Management › Organisations; the setting is read per request (and the write invalidates the cross-worker cache), so it flips immediately with no restart or rebuild. GET /admin/auth/signup/legal reports it to the sign-up page as paid_signup_enabled. (It replaces a former deployment flag, which is retired.)
Turning paid sign-up off removes the direct paid entrance only. It does not affect the trial-to-paid conversion (POST /admin/v1/billing/checkout), which is how a trial workspace subscribes and remains available throughout.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/admin/auth/signup/request |
Public | Step 1 (AGF-2751 R3): send a 6-digit verification code (30-min, single-use) to an email that can be registered. The trial sends the email + the affirmative AGB/AVV consent here — recorded on the intent at step 1 (before the OTP verify). The paid funnel sends email-only here (its consent is recorded later at signup/checkout). The name/voucher fields are gone (email-only). A present-but-malformed consent → 400; consent is validated before the existing-account lookup, so it is never an enumeration oracle. Always returns the same generic message. |
POST |
/admin/auth/signup/verify |
Public | Verify the code. On success sets the aig_signup cookie (HttpOnly; Secure; SameSite=Strict; Path=/admin/auth/signup/, 24 h). AGF-3045: the code is matched against any live (unconsumed, unexpired) intent for the email, not only the newest — so a resent code no longer strands the user (previously only the most recent code worked). The match is a constant-time compare per live row, with a dummy-hash compare when there is none, so a wrong code and a missing intent stay indistinguishable by timing. |
POST |
/admin/auth/signup/verify-link |
Public | AGF-3045: verify via the single-use magic link instead of typing the code. Body { token } — a 43-char base64url token that rides the signup email's URL fragment (/signup#token=…) and is POSTed by the SPA (never a GET, so the token never reaches nginx access logs or the Referer header). On success sets the same aig_signup cookie as /signup/verify. ABSENT, malformed, unknown, expired and already-used tokens all return the one generic 400 { "error": "invalid_or_expired" } — no enumeration oracle (invariant 11). Single-use: a successful verify NULLs the stored token hash, so a second click (or a replay) matches nothing and is inert. A coarse global ceiling bounds random-token load; there is no per-IP cap (the IP bucket is the client-controlled X-Forwarded-For), and the 256-bit token makes guessing infeasible. AGF-3044: the link has its OWN expiry (signup_intent.link_expires_at, read as COALESCE(link_expires_at, code_expires_at) so a row minted before the column existed still verifies within its 30-min code window) — 30 minutes at signup, extended to 24 hours only when the signup-abandonment reminder re-mints it; the re-minted 6-digit code stays 30 minutes (a 24-hour 6-digit code would be guessable). |
POST |
/admin/auth/signup/checkout |
aig_signup cookie |
The paid account-creation step: validate + record the AGB/AVV consent (moved here from request, AGF-2751), then create (or resume) a Stripe Checkout Session for the chosen catalog plan, interval and seats; returns its URL. Refused with 404 when the paid_signup_enabled platform setting is off (the direct paid entrance is closed — the trial-to-paid conversion is unaffected). |
POST |
/admin/auth/signup/trial |
aig_signup cookie |
The trial account-creation step (AGF-2751 R3): takes no body — the affirmative AGB/AVV consent was recorded at step 1 (signup/request). Provisions a real tenant directly (no Stripe) from the intent and swaps the signup cookie for the real aig_admin session. The provisioning choke point refuses an intent whose consent snapshot is NULL, so a consent-less (email-only) intent can never become a trial. |
GET |
/admin/auth/signup/status |
aig_signup cookie (optional) |
Poll provisioning. Once provisioned, swaps the signup cookie for the real aig_admin session. |
POST |
/admin/auth/signup/voucher |
Public | Advisory, display-only check of an optional voucher code — see Vouchers. |
GET |
/admin/auth/signup/legal |
Public | AVV (Art. 28 DPA) acceptance readiness — { avv_required, avv_version, paid_signup_enabled }. |
GET |
/admin/auth/signup/catalog |
Public | The product catalog the sign-up page renders (plans, prices, seat limits, add-ons, top-ups) — see below. |
signup/request
AGF-2751 R3 — email + consent step 1. Registration is a two-step funnel: step 1 asks for the business email and the single merged AGB/AVV consent checkbox; step 2 is the OTP; verifying auto-provisions the trial. The trial sends its affirmative consent HERE, so it is recorded on the intent at step 1 — before the OTP verify, and never after an account exists. The paid funnel sends email-only here and records its consent later at signup/checkout (consent at the pay button); its intent is created with a NULL consent snapshot that the provisioning choke point refuses until checkout records it. The name and voucher fields were removed entirely (email-only). See AVV acceptance and the provisioning guard below.
Accepted body — a JSON object:
| Field | Rules |
|---|---|
email |
Required. Canonicalised exactly like the sign-in routes (surrounding whitespace trimmed, ASCII letters lower-cased; printable ASCII only — a non-ASCII or control byte, inner whitespace, or a raw length above 254 bytes is rejected) and must additionally match a pragmatic local@domain.tld shape (single @, dotted domain, no leading/trailing/double dots). One address policy at both boundaries: an address that could not sign in by one-time code cannot be signed up either. |
consent_terms_version |
Sent by the trial (the paid funnel omits it → email-only). When present, it is validated fail-closed: 1–32 chars of [A-Za-z0-9 . - _ :]; a malformed value (non-string, empty, too long, bad charset, or JSON null) → 400. When absent (Lua nil), the intent is created email-only (NULL consent — the paid path). Absent ≠ malformed (invariant 11): the malformed value is rejected, never degraded to the permissive email-only path, and an email-only intent can never provision (the choke point refuses NULL). Validated before the existing-account lookup, so the 400/200 split does not depend on whether the email is registered — no enumeration oracle. |
avv_accepted |
Required to be boolean true when consent is present and AVV is active (the default); absent/non-true → 400. Ignored when no consent is sent (paid path) or when AVV is dormant. |
locale |
Optional; en or de (default de). Stored on the intent — every later lifecycle email keys off it. |
ref |
Optional affiliate referral code (see below). Resolved at request time and locked as the lifetime attribution — it attaches to the first touch, independent of consent. |
Rejected (400): missing/non-string/malformed email; an email whose domain is on the static in-code disposable-domain blocklist (e.g. mailinator.com, guerrillamail.com, 10minutemail.com, yopmail.com, temp-mail.org, and ~25 more); a present-but-malformed consent_terms_version; or (consent present + AVV active) an avv_accepted that is not boolean true. Rejected (429): more than 5 requests/hour per email or 20 requests/hour per IP.
AVV (Art. 28 data-processing agreement) acceptance
The /avv page serves the DPO-approved binding AVV verbatim (Version 1.1.1 / 21.08.2026, German — the German version is the sole legally binding one per its §13, so it is shown in every UI language; an English translation is pending and, until it lands, an on-page notice states the German version is authoritative).
Where consent is captured (AGF-2751 R3). The single merged AGB + AVV checkbox is affirmatively accepted (unchecked by default) and recorded on the signup_intent (consent_terms_version, consent_at, avv_version) before any tenant is provisioned — but at a funnel-appropriate step:
- Trial → at step 1 (
POST /signup/request): consent rides the email step and is recorded immediately, so the account (created only after the step-2 OTP verify) is never created without it. - Paid → at the pre-payment obligation step (
POST /signup/checkout): consent sits with the pay button, recorded before the Stripe session is created (the webhook that provisions later trusts this snapshot).
Both places validate fail-closed via ONE shared validator: a well-formed consent_terms_version (1–32 chars, [A-Za-z0-9 . - _ :]) and, while AVV is active, avv_accepted: true; a malformed consent rejects with 400. The two funnels write the same intent columns (one source of truth), differing only in when the earliest affirmative step occurs. A defence-in-depth guard at the single provisioning choke point (_provision_tenant_stack, shared by the trial path, the OAuth sign-up path and the paid Stripe webhook — which runs no consent check of its own) refuses to create a tenant whose consent snapshot is NULL, so no account can ever exist without a recorded consent (the columns are nullable per migration 0343; this guard is the structural replacement for the retired NOT NULL).
The server-pinned current AVV version is the DB-backed platform setting avv_current_version (read per request via the shm-cached settings store, so a platform admin flips it live via PUT /admin/v1/settings with immediate effect — it is no longer a deployment setting). It defaults to 2026-08-21 ⇒ active by default: an absent/empty row falls back to that code default, so the account-creation step requires avv_accepted: true and snapshots the server version onto signup_intent.avv_version; at provisioning that snapshot is projected into the consent_record table (document='avv', server version, accepted_at, accepted_ip, linked tenant_id/user_id) inside the provisioning transaction. consent_record is the authoritative, per-document AVV acceptance evidence. The accepted version is server-pinned (never client-supplied). A malformed stored value (over 32 chars, out of the [A-Za-z0-9 . - _ :] charset, or empty after trim) fails safe to the code default 2026-08-21 (never dormant) and is logged as an error; the settings PUT validator rejects such values at the boundary, so a bad version cannot be stored through the API. Dormant-by-default is retired — an admin cannot disable acceptance by blanking the key (clearing restores the active code default). Retention: on tenant hard-delete the row is retained (accountability, §195 BGB limitation period) — hard_delete_tenant stamps tenant_deleted_at and the purge reaper deletes it once older than the configured consent-retention window (default 3 years); subject_email + accepted_ip are kept unscrubbed because they are the evidentiary payload.
GET /admin/auth/signup/legal returns { "avv_required": boolean, "avv_version": string, "paid_signup_enabled": boolean } (the AVV pair derived from the avv_current_version setting per request) so the sign-up page can render the acceptance line only when active. With the active-by-default setting, avv_required is true and avv_version is the pinned version (default 2026-08-21). It 404s when self-serve is off, like every signup route.
The VAT wording of the pricing page and the obligation screen comes from each price's tax_behavior in signup/catalog (the former price_tax field is gone).
The optional voucher_code (a redeemable trial-credit code ^[A-Z0-9-]{4,64}$, case-insensitive) is accepted only on the paid funnel's signup/checkout step. AGF-2751 R3 removed the manual voucher field from the trial (registration is email-only), so the no-card trial has no promo-code entry; the affiliate ?ref= attribution (below) is a separate, unaffected mechanism. A malformed value is dropped and the checkout proceeds unaffected (it never blocks the funnel); a well-formed code is stored on the intent and redeemed authoritatively once the tenant is provisioned. See Vouchers.
The body also accepts an optional ref: an affiliate referral code (^[A-Z0-9-]{4,64}$, case-insensitive on input) carried from a partner's referral URL. It is resolved at request time to an active partner and the resolved partner id — never the code string — is stored on the signup intent, then copied onto the provisioned tenant as referred_by_partner_id (with referred_at), establishing the lifetime attribution used by the partner dashboard. Fail-closed to no attribution: an absent, malformed, unknown, or suspended-partner ref all resolve to a NULL attribution and the signup proceeds — the shared outcome is the restrictive one (no partner is credited), so a wrong/garbage code can never credit a partner it does not name. A transient DB fault during the optional lookup is best-effort (the signup still proceeds, unattributed). Non-string values (e.g. a JSON object) are rejected by the shape check. The referral code is a marketing/lookup handle only; the durable attribution is the partner id, so a partner renaming its code never affects past attributions.
v1 scope: affiliate attribution covers the email/password self-serve funnel only. A prospect who clicks a ?ref= link but then signs up via OAuth (Google/Microsoft) is not attributed in v1 — the OAuth callback carries no referral code. Carrying ref through the OAuth start URL is a bounded fast-follow. The partner-facing surface is documented in Partner dashboard.
Anti-enumeration: the response is an identical generic 200 { "message": … } whether or not the email already has an account. An email that already has a live account gets that response with no intent created and no code sent (its return path is login/reactivation, not sign-up). As with the OTP-login precedent, this leaves a timing asymmetry (the existing-account path skips the cleanup + insert + email work) — this is documented rather than pretended away; the per-IP limit blunts timing-based enumeration.
signup/verify
Accepted body: { email, code } (both required; email normalized as above). The code is compared in constant time against the newest still-verifiable intent for that email; a missing intent is compared against a fixed dummy hash so existence is not revealed by timing. Rejected (400): missing email/code or malformed email. Rejected (401): wrong or expired code. Rejected (429): 5/hour/email or 20/hour/IP. On success: 200 { "verified": true } and the aig_signup cookie.
signup/catalog
GET /admin/auth/signup/catalog — public, no authentication, no input. The product catalog the sign-up page renders: every public catalog row with the price checkout will charge (the catalog mirrors Stripe; shown == charged). It 404s when self-serve is off, like every signup route.
{
"trial_days": 7,
"currency": "EUR",
"plans": [
{ "slug": "custom", "plan_value": "self_serve_custom", "display_order": 20, "badge": "popular",
"self_serve": 1, "max_seats": 500, "grants": { "web_search": true },
"prices": { "month": { "unit_amount": 1300, "currency": "EUR", "interval": "month", "tax_behavior": "exclusive" },
"year": { "unit_amount": 14040, "currency": "EUR", "interval": "year", "tax_behavior": "exclusive" } },
"checkout": { "month": true, "year": true } }
],
"addons": [ { "slug": "eu_gov", "display_order": 30, "badge": null, "grants": {}, "prices": { "month": { … }, "year": { … } } } ],
"topups": [ { "slug": "wallet_topup", "currency": "EUR", "min_minor": 100, "max_minor": 100000,
"presets": [2500, 5000, 10000, 25000] } ]
}
- Amounts are minor units (cents);
intervalisnullfor a one-time price.max_seatsis the most seats one customer can buy on that plan (the plan's configured seat maximum;nullwhen unlimited/unknown). checkout.<interval>istrueonly when checkout can actually charge that interval right now (a resolvable price and thepaid_signup_enabledsetting on); the price shown for it is exactly the price checkout charges.topups(step 1b): a wallet top-up is any integer amount withinmin_minor–max_minor(minor units ofcurrency);presetsare suggestions. The entry appears only while the wallet is switched on (wallet_enabled) and a top-up can actually be sold (valid config, Stripe product bound) — otherwisetopupsis[]. (An earlier release removed the one-releaseoffers: []compatibility field.) See Catalog — wallet top-up configuration.- Only public rows appear. No Stripe price/product ids, lookup keys, nicknames or diagnostics ever leave the server. Row names and descriptions are not in the body — the page renders them from the
catalog.<kind>.<slug>.name/.descriptiontranslation keys.
| Status | When | Caching |
|---|---|---|
200 |
the feed | Cache-Control: public, max-age=60 (the rendered body is also cached server-side for 60 s per node and dropped on every catalog change and wallet_enabled change) |
404 |
self-serve is off | no-store |
429 { "error": "rate limited" } |
more than 120 requests per 60 s from one client IP | no-store |
503 { "error": "catalog_unavailable" } |
the catalog cannot be read (never an empty 200 that would read as "nothing for sale") |
no-store |
signup/checkout
Requires a valid aig_signup cookie (a normal session token is refused — the purpose=signup check runs in both directions) and a verified email. Refused with 404 when self-serve or the paid_signup_enabled setting is off.
Accepted body — either a choice:
| Field | Rules |
|---|---|
plan |
Required. A catalog plan slug (^[a-z0-9_-]{1,64}$) that is public and purchasable, e.g. custom. The former starter / pro / …-annual values no longer exist (400 unknown_plan). |
interval |
Required. "month" or "year". |
seats |
Required. Integer ≥ 1 and ≤ the plan's max_seats (from signup/catalog). The Stripe Checkout charges exactly this quantity of the seat price, and after payment the new workspace's seat limit is this number. |
expect |
Required. { "unit_amount", "currency", "tax_behavior" } — the price the page showed. If the current price differs, nothing is created and the answer is 409 price_changed with the current offer, so the customer confirms the new price. |
consent_terms_version |
Required (AGF-2751). The AGB consent version, same shape/rules as at AVV acceptance (1–32 chars, [A-Za-z0-9 . - _ :]). Absent or malformed → 400, no session created. Recorded on the intent before the Stripe session is created (the webhook that provisions later trusts this snapshot). |
avv_accepted |
Required (AGF-2751) while AVV is active (the default). Must be boolean true; any other value (absent/string/number/null/object) → 400, no session. The accepted version is server-pinned. |
voucher_code |
Optional; the redeemable code (moved here from request). Malformed → dropped; well-formed → recorded on the intent and redeemed post-provision. |
— or { "resume": true } alone (the "continue checkout" button after a Stripe cancel; the page reload has lost the choice). A resume answers from the intent's own session's real status and never reads the catalog: still open → 200 with its URL; already paid → 409 checkout_pending; expired, replaced or none → 409 restart_checkout. The resume path carries no consent and is not consent-gated — its original checkout already recorded consent.
One open checkout per email. All sign-up attempts of one email (any intent, any device) share one open Stripe Checkout Session: a new choice retires the previous open session before a new one is created, so two payable sessions can never exist at once.
On success: 200 { "checkout_url", "session_id" }. The Checkout Session is card-only, pins customer_email, shows the seat quantity × price before the card is entered, and stamps metadata[product]=ai-gateway.
Status / error |
Meaning | Client action |
|---|---|---|
400 missing_field |
plan, interval, seats or expect absent |
fix the request |
400 invalid_body / invalid_plan / invalid_interval / invalid_seats / invalid_expect |
a field is present but malformed (invalid_seats also for seats above the plan maximum; the body then carries max_seats) |
fix the request |
400 unknown_plan |
not a public, purchasable plan | re-read the catalog |
401 |
signup cookie missing/expired | start again |
403 |
email not verified | verify first |
409 already subscribed |
intent consumed, or the email already has an active subscription | sign in |
409 price_changed |
the price changed since it was shown (body: offer) |
show the new price, ask again |
409 checkout_in_progress |
another request for this email is creating a checkout (≤ 60 s) | retry shortly |
409 checkout_pending |
a checkout for this email is already paid and being provisioned | wait / poll signup/status |
409 restart_checkout |
(resume) the session is gone | choose the plan again |
502 checkout_unavailable |
Stripe could not be reached | retry later |
503 checkout_unavailable |
the catalog cannot offer a price for this plan/interval | retry later |
signup/trial
Starts the no-card 7-day free trial and is the trial's account-creation step — the second step of the two-step funnel (verifying the OTP calls it). Requires a valid aig_signup cookie — the email is already OTP-verified by request→verify, so this is not a new unauthenticated surface.
AGF-2751 R3 — takes no body. The affirmative AGB/AVV consent was captured and recorded on the intent at step 1 (signup/request), so this endpoint reads no consent (nor any name/voucher — registration is email-only). A stale client that still POSTs a consent body is harmless: the body is ignored and the recorded step-1 snapshot is authoritative. The consent gate is enforced structurally: the provisioning choke point (_provision_tenant_stack) refuses to provision an intent whose consent snapshot is NULL, so an email-only (consent-less) intent — e.g. one minted by a client that skipped the checkbox — verifies fine but cannot become a trial (the grant returns an error, no tenant).
Every authoritative billing value (the plan, the credit amount, the trial length) comes from the server, never the request — a body that tries to set plan or budget_usd is ignored. The trial credit is the trial_credit_eur of the catalog's trial row (catalog; default €3) and the model set is the DB-tunable self_serve_trial plan-config row; the length is 7 days. If that catalog row is missing or cannot be read, the trial is not provisioned (a retryable error) rather than granted without its credit; a row whose credit is null (none advertised) grants none.
On success it provisions a real tenant directly (no Stripe, no subscription row), stamps trial_ends_at = now + 7 days and the credit as a lifetime (total-period) budget, sets the real aig_admin session cookie, clears the aig_signup cookie, and returns 200 { "trial": true, "session_issued": true }. The trial owner is created without a name (registration collects none now — the user sets it later in their profile). Enforcement is the existing quota path: a 402 trial_expired once the 7 days pass, and a 402 trial_budget_exhausted once the credit is spent (see Billing).
Rejected (401): missing/expired signup cookie or intent. Rejected (403): email not yet verified. Rejected (409): the intent is already consumed, the email already owns an active subscription, or the email already used its one free trial ({ "error": "trial_already_used" }). Rejected (429): more than the per-domain (1000 by default, configurable — see trial_per_domain_daily) or per-IP (5, fixed) trials in a rolling 24 h — the anti-abuse throttle. 500 when the intent carries no recorded consent (the choke point refuses — an email-only intent cannot provision). Trial eligibility is one per verified email: the email is canonicalized (lower-cased, +tag subaddressing stripped, dots removed for dot-insensitive providers such as Gmail) before the dedup/throttle checks, and the atomic backstop is the account email's uniqueness. Any DB fault in the dedup or throttle check fails closed (500 / reject), never silently granting a second trial. Like every signup route it 404s when self-serve is off.
signup/status
Accepted query: ?session_id=<Stripe Checkout Session id> (only a value shaped like a Stripe Checkout Session id, cs_…, is ever correlated; anything else is ignored). The session_id is a correlation check only, never an auth key (cs_… ids leak via URLs/referrers): a real session cookie is issued only when a valid signup JWT bound to that intent is present, the intent is provisioned, and the stored session id equals the session_id param. On that path the endpoint sets the real aig_admin cookie and clears the aig_signup cookie (returned session_issued: true); otherwise it returns a plain { "status": "pending" | "provisioned", "session_issued": false } — so an expired/absent signup JWT still gets a status answer (the success page then offers a login link instead of hanging).
Connect-OX popup and COOP
The Open-Xchange "Connect" button opens the authenticated SPA page /connect/ox?origin=<exact OX
origin>&nonce=<opaque> in a popup. The page is wrapped in the sign-in guard (a logged-out
visitor is bounced to /login?next=/connect/ox… and returned after signing in), lets the user pick
a gateway + model, and calls POST /admin/v1/me/connect/ox
to mint an inference-scoped token. It hands the result to the OX plugin by
window.opener.postMessage({ source: "myra-connect-ox", nonce, tenant, gateway, model, token }, origin)
— the second argument is the exact origin from the query, never "*", so no other window
can receive the token.
Security properties of the page (defense-in-depth around the server gate, which is authoritative):
- Frame-bust / opener guard: if the page is framed (
window.top !== window.self) or was opened without an opener (!window.opener), it refuses — no token is requested. This blocks clickjacking and a token being stolen by opening the URL directly. - Input validation: a missing/over-long/malformed
nonce(^[A-Za-z0-9_-]{1,128}$) or a non-canonicaloriginshows an error and mints nothing — a bad value is rejected, never sanitised-and-echoed. Theoriginis also shown to the user before they confirm. - COOP invariant: the handshake relies on
window.openersurviving the cross-origin open, so/connect/oxmust NOT be served withCross-Origin-Opener-Policy: same-origin(it would silently sever the return channel). The app sets no COOP header today;X-Frame-Options: SAMEORIGIN+ CSPframe-ancestors 'self'back the frame-bust above. If a global COOP is ever introduced, this route must be exempted.
The token minted here is scoped, rate-limited, and non-expiring — see the endpoint reference for the
forced controls and the connect_ox_allowed_origins allow-list (Settings).
Single sign-on (OIDC)
A tenant can enable generic OpenID Connect sign-in (e.g. Microsoft Entra ID / ADFS) beside email-OTP. SSO authenticates existing accounts — it does not create users (provisioning is a separate concern). Per-tenant configuration lives under the tenant admin API (see Tenants and gateways); the runtime endpoints are:
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/admin/auth/sso/lookup?email=… |
Public | SSO discovery for the login page. Returns { "sso": true, "tenant": "…", "protocol": "oidc" \| "saml" } if the email's domain has an enabled SSO config, else { "sso": false }. The login button initiates the returned protocol for that tenant. When a domain is configured for both OIDC and SAML, OIDC takes precedence (the primary path). SAML is surfaced only when the gateway-wide SAML validator token is provisioned; an enabled-but-unprovisioned SAML config returns { "sso": false } (falls back to the e-mail code flow rather than a dead-end button). Reveals only the domain→tenant SSO mapping + protocol — never a secret, and nothing about whether the address exists. Rate-limited per IP. |
GET |
/admin/auth/oidc/{tenant}/start |
Public | Begin the OIDC flow for a tenant: stores an opaque state + nonce + the canonical redirect_uri server-side in the database (single-use, 10-min TTL), sets a Lax CSRF cookie, and redirects to the IdP's authorization endpoint. The correlation is DB-backed (not an in-process per-node cache) so an active-active deployment where /start and /callback land on different sites still completes. |
GET |
/admin/auth/oidc/callback |
Public | The IdP redirect target. Verifies the CSRF cookie, atomically consumes the single-use state (a replay/expired/unknown state → 400), verifies the id_token, resolves the user, and on success sets the aig_admin session cookie. |
The callback redirect_uri is canonical and fixed per deployment, sourced from trusted deploy config (not the request Host): in production it is https://ai-api-admin.myra.eu/admin/auth/oidc/callback (on the integration environment, https://ai-api-admin-int.myra.eu/admin/auth/oidc/callback) — a single, non-tenant-keyed URI (the tenant is recovered from the signed state, never the URL). A spoofed Host cannot change it (deriving it from a client-controlled header would be an auth-code-exfiltration vector). It is overridable by a deployment setting. Register this exact URI as the redirect/reply URL in the IdP application. The exact value emitted at /start is persisted with the state and re-sent byte-for-byte at token exchange, so the IdP's exact-match check always agrees. This mirrors the MCP OAuth-callback posture.
When the IdP redirects back to the callback with an ?error= (for example a Conditional Access denial, a cancelled sign-in, or consent_required when admin consent is missing), the callback 302-redirects the browser to /login?error=<slug> (see SSO failure redirect below) — access_denied maps to sso_denied, the consent_required/interaction_required/login_required family to sso_retry, any other provider error to sso_failed. The IdP's error_description is never reflected. Grant admin consent and review Conditional Access policy at the IdP to resolve these. Step-by-step provider setup (Microsoft Entra ID, and generic OIDC/SAML providers) lives under Administration → Security → Single sign-on.
SSO failure redirect
Every OIDC/SAML browser-navigation endpoint — /oidc/{tenant}/start, /oidc/callback, /saml/{tenant}/start, and /saml/{tenant}/acs — is reached by a top-level browser navigation (the IdP redirects or auto-POSTs the browser to it, and the login page sets window.location for /start). On any failure these endpoints therefore 302-redirect to /login?error=<slug> rather than emitting a JSON body (a JSON body would render to the browser as raw text). <slug> is drawn from a closed, server-controlled set:
| slug | meaning | mapped from |
|---|---|---|
sso_failed |
generic sign-in failure (also the fallback for any failure status not listed below) | 401 auth failures, 400 malformed callback (bad/missing/expired code/state, CSRF mismatch), 404 SSO-not-enabled, 413 oversize, 429 rate-limited, 500 config, unrecognised IdP error |
sso_denied |
authorization denied | 403 (tenant gate, no email in assertion/token, IdP access_denied) |
sso_retry |
retry with interaction | IdP consent_required/interaction_required/login_required |
sso_unavailable |
transient / provider unavailable | 502/503 (discovery, token endpoint, DB fault, provider not configured, SAML validator unreachable) |
The real HTTP status and any provider error_description are logged and audited server-side — never placed in the URL. The login page renders a translated, human message from the slug (both en and de); an absent or empty ?error= shows the ordinary sign-in screen (no banner), and an unknown or malformed value falls back to a single generic message. The ?error= value is treated as untrusted (principle 11): the login page looks it up in a fixed slug→message map (own-property lookup, so __proto__/toString/constructor resolve to the generic message) and never reflects the raw query value into the page — there is no reflected-XSS surface. The /metadata, /logout, and /sls endpoints are not part of this redirect behaviour.
id_token verification (BSI Schutzbedarf HOCH)
The id_token is verified in-process against the tenant's configured issuer, fail-closed at every step:
- RS256 only.
alg:none, anyHS*(RS/HS confusion), and an embeddedjwk/jku/x5uor acritheader are rejected. Keys come only from the configuredjwks_uri. - Key by
kid, fetched from JWKS and cached per(jwks_uri, kid); the RSA key must be ≥ 2048-bit with a sane exponent. - Signature is checked before any claim is read.
- Claims:
issexact-match;audmust contain the client id (azpchecked when present, and required for a multi-audience token per OIDC §3.1.3.7);exp/iat/nbfwithin ±120 s skew; the login-timenoncemust match. - Microsoft Entra multi-tenant. When a tenant's SSO config carries its Entra directory tenant GUID (
expected_tid), the verifier switches to Entra mode (a config without it stays exact-issgeneric-OIDC, byte-for-byte as before — Keycloak/ADFS are unaffected). In Entra mode, additionally: theisshost must be on the trusted Microsoft authority allowlist (login.microsoftonline.com,sts.windows.net) — any other host, a userinfo/@splice, or a non-httpsscheme is rejected (iss_untrusted_authority); the token'stidmust be a non-empty string equal toexpected_tid— enforced always, even single-tenant, so a validly-signed token from another Entra directory that merely lists us inaudis rejected (tid_missingwhen absent/blank/non-string,tid_mismatchwhen present-but-wrong); a templated configured issuer containing the literal{tenantid}(returned by Entra common/organizations discovery) has that placeholder filled with the pinnedtidand is then exact-matched (iss_mismatchotherwise). Both v1 (https://sts.windows.net/<tid>/) and v2 (https://login.microsoftonline.com/<tid>/v2.0) issuer forms are accepted, and the audience may be either the bare client id orapi://<client_id>(Entra mode only — generic-OIDC still requires the bare client id). A malformedexpected_tid(a non-string, non-empty config value) fails closed (bad_config) rather than silently degrading to generic mode.
Identity resolution and the tenant boundary
A tenant's IdP is the identity authority for that tenant only. After verification the user is resolved by the configured subject_claim (sub by default; set oid for the Entra directory object id — a mutable claim such as email is rejected), and:
- a resolved account whose tenant differs from the flow's tenant is rejected (
403); - a first-time login links by verified email/UPN within the tenant's allowed-domain allowlist (the verified-domain control), tenant-scoped;
- an unknown identity returns
403— no account is created.
Every SSO sign-in, denial, and failure is written to the audit log (the tenant in entity_id, the IdP entityID / issuer in idp_entity_id, never any secret).
Single sign-on (SAML 2.0)
A tenant can alternatively enable SAML 2.0 SP-initiated SSO (e.g. Microsoft Entra ID / ADFS). Like OIDC, SAML authenticates existing accounts — it does not create users. The gateway never parses SAML XML itself: an in-container, loopback-only validator performs all XML-DSig verification, and the gateway consumes only the signed scalar claims it returns. Per-tenant configuration lives under the tenant admin API (see Tenants and gateways).
These SP endpoints are served on the admin-API host over HTTPS (for example
https://ai-api-admin.myra.eu/admin/auth/saml/<tenant>/metadataand…/acs) — the direct admin-API host, reachable without relying on the same-origin/adminproxy, not theai.myra.euapplication host. The SP entity ID and ACS URL registered at the identity provider must use that host and thehttpsscheme; the emitted metadata, theAuthnRequestIssuer, and the tenant admin panel all reflect it (the scheme is taken fromX-Forwarded-Proto, so the edge's TLS termination is honoured). Register the values exactly as the SP/metadatadocument / the admin panel display them.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/admin/auth/saml/{tenant}/metadata |
Public | The SP metadata XML (entityID, ACS, NameID format). The AuthnRequestsSigned flag is always present and is true only when the tenant enables signed requests; the signing KeyDescriptor appears when signed requests are enabled; the SingleLogoutService location appears when the tenant sets an IdP Single Logout URL (idp_slo_url) — independent of signing. Upload/point your IdP at this. Every host- and config-derived value interpolated into the document is XML-escaped, so a request Host cannot inject markup into the served metadata. Here {tenant} is the tenant id (UUID), not the slug. |
GET |
/admin/auth/saml/{tenant}/start |
Public | Begin the flow: builds an AuthnRequest, stores a single-use correlation row, redirects to the IdP. Rate-limited per IP+tenant. |
POST |
/admin/auth/saml/{tenant}/acs |
Public | Rate-limited per IP+tenant, before the signature parse, so an unauthenticated POST flood cannot pin the validator. Assertion Consumer Service. The IdP POSTs the SAMLResponse; the validator verifies signature/XSW/conditions and returns the signed scalars. The gateway then does single-use InResponseTo consume, one-time assertion replay defence, the tenant gate, and sets the aig_admin session cookie. Like the OIDC callback, this is a top-level browser navigation, so any failure 302-redirects to /login?error=<slug> (SSO failure redirect) rather than emitting JSON — a validation/replay failure maps to sso_failed, a tenant/no-email denial to sso_denied, and a validator-unreachable or provider-unconfigured condition to sso_unavailable. |
GET |
/admin/auth/saml/{tenant}/logout |
Public | SP-initiated Single Logout. Reads any session cookie opportunistically and never rejects (like POST /admin/auth/logout). Clears the local session cookie immediately, then — if the login was via SAML and the tenant configured an IdP SLO URL — redirects to the IdP with a LogoutRequest. Otherwise a plain local logout. Rate-limited. |
GET |
/admin/auth/saml/{tenant}/sls |
Public | Rate-limited per IP+tenant. SP SingleLogoutService. Handles an IdP-initiated LogoutRequest (verifies its signature, clears the local cookie, returns a signed LogoutResponse to the IdP) or our own SP-initiated LogoutResponse coming back. |
Accepted inputs and what is rejected (trust boundary)
SAMLResponse(ACS) — base64 string, pre-capped at 700 KB. The validator rejects: unsigned assertions, signature-wrapping (XSW), deprecated algorithms (RSA-SHA1/SHA-1), wrong signing cert, expired/wrong-audience/wrong-recipient/wrong-destination conditions, a missingInResponseTo(IdP-initiated is not accepted at the ACS), and a non-identity NameID format —transient(per-session, re-minted each login) and entity/encrypted are rejected with a precise, format-naming error, since the NameID is the identity key.unspecifiedis accepted (it is Microsoft Entra's common default and carries a stable objectId), as is an omittedFormat(which defaults to unspecified per SAML core);persistentandemailAddressare accepted as before. Assertion timestamps (both theConditionswindow and the bearerSubjectConfirmationDataNotOnOrAfter/NotBefore) are validated with ±120 s clock-skew tolerance (matching the OIDC±120 sabove), so a small IdP clock drift or in-transit delay does not reject an otherwise-valid login; an assertion expired beyond that tolerance is still rejected. An assertion that repeats an attributeName(for example a default Keycloak client emitting one<saml:Attribute Name="Role">per role) is accepted, and the repeated values are merged into one multi-valued attribute. This affects attribute parsing only: every signature, XSW, algorithm, condition, and NameID check above still applies unchanged, and the identity is always the signed NameID, never an attribute.SAMLRequest/SAMLResponse(SLS) — redirect-binding params, pre-capped at 100 KB. The SLO message must carry a valid query-string signature verified against the tenant's IdP signing certificate; an unsigned or forgedLogoutRequestis rejected (the force-logout defence). A repeated or value-less query parameter is rejected rather than coerced.idp_slo_url(config) must behttps://or empty;sign_authn_requestsis a boolean.
Single Logout and the stateless-session limitation
Admin sessions are stateless JWTs. There is no per-session revocation list — an individual stolen cookie cannot be revoked on its own — but each account carries a per-user revocation floor (sessions_valid_after): disabling or deleting the account records the current instant, and every session whose issue time (iat) is at or before it is refused. Single Logout therefore:
- ends the local session (clears the
aig_admincookie) immediately and unconditionally — a validator or IdP error never leaves the user signed in; and - propagates the logout to/from the IdP over the SAML protocol so the IdP session also ends.
After an ordinary "sign out" a JWT still held in another browser or device remains valid until it expires — sign-out clears only the calling browser's cookie and writes no revocation floor. This is inherent to stateless JWT sessions. A disable or delete is different: it stamps the revocation floor, so every session on every device is refused on its next request and cannot be revived (see below).
What outlives a change is only the session's existence, not its authorization. For the aig_admin admin session the token identifies the account; role, custom-role grants, disabled and deleted state are read live from the database on every admin request (the token's own role claim is never consulted), so a role downgrade, a custom-role change, a disable or a delete applies to every open admin session on its next request — a downgraded session stays signed in with the reduced permission set; a disabled or deleted account's session gets 401 (and GET/PATCH /admin/auth/me clear the cookie). Disabling or deleting a user permanently ends every session on every device by stamping the per-user revocation floor: an older cookie is refused even after the account is re-enabled or restored (the floor is never cleared — a returning user signs in afresh). The user's /v1 API tokens are revoked in the same step and likewise do not return on a re-enable; the caller mints new ones. Only exp, a sign-out, or another disable applies to a session issued after the last floor — and sliding renewal extends exp only while the session is still valid and un-revoked, so it can never revive a session the floor has already killed (a renewal re-runs the identical live-account check and refuses a revoked one).
Signed requests
When a tenant enables sign_authn_requests, the gateway signs the AuthnRequest/LogoutRequest with a gateway-wide SP key provisioned by your operator; the private key is never per-tenant and never stored in the database. If signing is enabled but no SP key is provisioned, the request path fails closed (no silent unsigned request) and the SP metadata still advertises AuthnRequestsSigned="true" so the mismatch is visible to the IdP. Note that with signing enabled and no key provisioned, inbound Single Logout messages are also rejected (the same settings build fails closed) — provision the SP key before enabling signing if SLO is in use.
Every SAML sign-in, logout, denial, and failure is written to the audit log (the tenant in entity_id, the IdP entityID in idp_entity_id, never any secret).
Inference API authentication
Inference endpoints (/v1/{tenant}/{gateway}/{provider}/...) use an opaque bearer token issued by the admin API.
Token acceptance order
The gateway checks request headers in this order; the first matching header wins:
| Priority | Header | Example |
|---|---|---|
| 1 | x-aig-token |
x-aig-token: myra_xxxx |
| 2 | Authorization: Bearer |
Authorization: Bearer myra_xxxx |
| 3 | x-api-key |
x-api-key: myra_xxxx |
This ordering lets you drop the gateway in as a replacement for the OpenAI API without modifying existing clients that send Authorization: Bearer or x-api-key.
Token security model
- Tokens are hashed with SHA-256 before storage. The plaintext is never persisted.
- The plaintext token is returned once in the creation response — copy it immediately.
- If a token is lost, delete it and create a new one; there is no recovery path.
- Tokens are scoped to a single gateway. Using a token on a different gateway returns
401 unauthorized. The path is/v1/{tenant}/{gateway}/{provider}/...— the token must belong to the{gateway}segment (a common mistake is reading the{provider}segment, e.g.myra, as the gateway). The401message is specific so you can tell the cases apart: - no token sent → "No API token provided..." (add the
Authorization: Bearer/x-aig-token/x-api-keyheader). - a token that belongs to a different gateway of the same tenant → "This token is recognized
but is scoped to a different gateway in this tenant..." (fix the
{gateway}path segment — do not regenerate the token). - an unknown token, or one from another tenant → "Missing or invalid gateway token for gateway '...'" (the generic case; the two are deliberately indistinguishable so the response never confirms a token's existence in a tenant you have not named).
- The wrong-gateway hint is tenant-scoped: it only ever tells a caller who already holds the token that it belongs to another gateway of the tenant they named in the URL. It never names the other gateway and never reveals whether a token exists in a different tenant.
- an explicitly revoked token →
401 token_revoked, "This gateway token was revoked on. Mint a new token and update the caller." Revoking (DELETE …/tokens/{id},DELETE /admin/v1/me/tokens/{id}, a chat-bridge teardown) deletes the token row and writes a revocation tombstone (auth_token_revoked: the token's hash, its gateway, the time) in the same transaction, so the two can never disagree. The tombstone is read only for the gateway named in the URL: a token revoked elsewhere is the generic case above. A token that vanished with its agent, user or gateway (a cascade, not a revocation) is also the generic case. - What "immediate" means for a revocation. On the site that processed the revocation the cached authorization is dropped right after the transaction commits, so the very next request there is refused. Two bounded exceptions, both at most one authorization-cache TTL (5 minutes): a request that read the live row just before the commit and cached it just after the drop, and — production being active-active across sites with a per-site cache — the other site, which keeps serving its cached entry until that TTL lapses. There is no cross-site invalidation today.
- A decommissioned (soft-deleted) tenant fails closed. Once a tenant is soft-deleted
(
deleted_atset — see Deleting a tenant), every authentication and routing resolver rejects it in SQL: its tokens no longer authenticate, its gateways no longer route, and its users can no longer sign in (session, email OTP, OIDC/SAML, SCIM). This is what lets the contract-end purge erase the tenant with no new rows written mid-flight. - A token's bound user is scoped to the gateway's tenant. If a token carries a
user_id(for By-User attribution/budget), that user must belong to the same tenant as the gateway. A request whose bound user is in another tenant is refused403"This token is not valid on this gateway." — checked at the/v1sink before any routing, inference or tool call, so a cross-tenant token can never read another tenant's project knowledge (viaread_file/search_knowledge/code_interpreter) or spend another tenant's budget. The detail is generic and never names the bound user or their tenant. This holds independently of the mint check — even a token minted before the fix, or by any path, is refused here. The one exception is a platform admin (a tenant-less operator account,role: admin,tenant_idNULL), who is cross-tenant by design — this is what lets the operator Playground / chat drive any gateway; a tenant-resident admin is not exempt and stays confined to its own tenant.
Token capability scopes
A gateway token carries an optional scopes array (set at creation — see Creating a
token) that limits which capabilities the token may exercise. Enforcement is
opt-in and least-privilege:
- Recognized capabilities:
inference,tools,admin. - A token that declares one or more recognized capabilities is held to them, fail-closed:
a capability the token did not grant is denied. In particular, a token whose scopes exclude
inference(for example["admin"]) is refused at the inference endpoint with403 forbidden— "This gateway token is not authorized for inference (its scopes exclude 'inference')." - A token that declares no recognized capability — an empty
scopes, or only legacy/unknown strings such as["playground"]or["read","write"]— is treated as unrestricted (the historical default). Unrecognized scope strings therefore do not restrict; to enforce least-privilege, use the recognized capability names above (matched case-insensitively). A token minted with noscopesremains unrestricted. - Mint-time shape:
scopesmust be a JSON array of at most 50 strings, each 1–64 characters fromA-Z a-z 0-9 . _ : / -, stored verbatim (the read side matches case-insensitively and trims, but a string with whitespace —"inference "— is refused at mint rather than silently normalized). A string, number, boolean, or object-with-keys value, an out-of-alphabet element, or more than 50 elements is rejected with400on every mint route (gateway, user, and personal tokens) — see Users & tokens → Token fields. - Enforcement applies to gateways with authentication required. On a gateway with
auth_required: falsethe token is optional, so a mis-scoped token is not rejected there (it is no stricter than sending no token at all). - Today the
inferencecapability is enforced at the request boundary;tools/adminare part of the vocabulary for forward compatibility (the admin API authenticates with a separate session credential, not a gateway token).
Disabling authentication
For development or fully-internal networks, authentication can be disabled per gateway:
curl -X PATCH https://<your-gateway-host>/admin/v1/gateways/{id} \
-H "Content-Type: application/json" \
-d '{"config": {"auth_required": false}}'
⚠️ Caution: Never set
auth_required: falsein production. Any caller with network access to the gateway endpoint can make inference requests and incur provider costs.
Creating a token
Before you begin, ensure the following conditions are met:
- ☑ You have admin access to the gateway.
- ☑ You have the gateway ID available.
Proceed as follows to create a token:
- Send a
POSTrequest to/admin/v1/gateways/{gateway_id}/tokenswith the token configuration in the request body. - The API creates the token and returns the plaintext value once.
curl -X POST https://<your-gateway-host>/admin/v1/gateways/{gateway_id}/tokens \
-H "Content-Type: application/json" \
-d '{
"label": "my-service",
"scopes": ["inference"],
"expires_at": 1798761600,
"rate_limit": {"requests": 100, "window_sec": 60},
"budget_usd": 50.00
}'
Response:
{
"id": "tok_abc123",
"token": "myra_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"gateway_id": "gw_xyz789"
}
⚠️ Caution: The
tokenfield in this response is the only time the plaintext value is available. Store it in your secrets manager immediately.
-> The new token is ready for use in inference requests.
Using the token
Proceed as follows to authenticate an inference request:
- Include one of the supported authentication headers in the request.
- The gateway validates the token against the stored hash.
# x-aig-token header (preferred)
curl -X POST "https://<your-gateway-host>/v1/myapp/production/openai/chat/completions" \
-H "x-aig-token: myra_xxxx" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}'
# Authorization: Bearer (OpenAI SDK compatible)
curl -X POST "https://<your-gateway-host>/v1/myapp/production/openai/chat/completions" \
-H "Authorization: Bearer myra_xxxx" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}'
# x-api-key (Anthropic SDK compatible)
curl -X POST "https://<your-gateway-host>/v1/myapp/production/anthropic/chat/completions" \
-H "x-api-key: myra_xxxx" \
-H "Content-Type: application/json" \
-d '{"model":"claude-opus-4-6","messages":[{"role":"user","content":"Hello"}]}'
-> The API returns the requested inference response.
Role-based access control
Tokens are linked to a user record via user_id. The role of the user determines what the token can do:
| Role | Inference | Admin API |
|---|---|---|
admin |
All gateways | Full access |
member |
Assigned gateways only | Self-service only — own profile (GET /admin/auth/me), own tokens, commands, and connectors. No tenant administration. |
viewer |
403 on all requests |
Read-only own profile (GET /admin/auth/me); cannot create tokens or manage resources. |
💡 Note: The
viewerrole is intended for operators who need read access to the admin UI dashboard only. Any inference request from a viewer-role token is rejected with403 forbidden.💡 Note: This table shows the three baseline roles. The platform also ships the roles
tenant_admin,ki_manager,finance, anddemouser, and supports tenant-defined custom roles. See Roles for the full role model and per-permission grants, and Users & tokens for how roles are assigned.