Flow — signing in and authenticating a request
Six families of credential reach the iterion cloud API, and each one either converges on the single function that mints a session or on the single choke that turns a presented token into a tenant-scoped identity.
Drawn from the code at commit
98787101a(branch fix/org-tree-errors-1668, on v3.276.0), except the best-effort landing stamp (patchBestEffort) and the one classifier its JSON consumers cross (refuseConnector), which landed later, with #2655. How this page is kept true: README.
Minting a session
- The gates above are ordered as the code runs them, and neither covers the SSO entry points. The origin gate is the umbrella middleware's and runs before the public-path bypass, so it covers
/api/auth/logineven thoughisPublicPathlists it — butoriginGateAllowsreturns true for anything that is not a state-changing/api/call, and/startand/callbackare GETs. The buckets are not middleware:limitRoutedecorates the login handler (shared with the password change and both reset routes), register, refresh and the desktop exchange — the two SSO routes are registered bare. - Not drawn, and not through
issueLoginInTeam: the password flows — the forced rotation onPOST /api/auth/password/change,POST /api/me/passwordand the reset confirm — read the session's context (prepareLogin) and revoke every session of the account (revokeAccount) BEFORE they commit, and write the refresh row after it (commitLogin), so a read or a revocation that fails leaves the password and the link as they were; their new session is born under the generation their own revocation raised, read from nothing (RevokedAccountRoot).POST /api/auth/registerbuilds its session from the rows it just wrote (loginContextFromRows), born under generation zero (NewAccountRoot), with no read after its commit — and the landing defaults it stamps on the account are written best-effort and logged (patchBestEffort), since the session carries the team already and refusing would answer 500 over an account the retry cannot reuse; the logged-in invitation accept mints nothing — every read it needs runs once before it claims the invitation, and the team and the org membership it mirrors the grant to are read again after the claim, so the write follows rows an off-boarding may have just removed; a read that fails there answers an error with the invitation already spent and nothing granted — an admin reads it "accepted by" that account, which holds no grant, the same link answers 409invitation already accepted, and only a new invitation gets the user in. An SSO first login plans the grants its matched rows take BEFORE it creates the account, so a read the plan needs that fails commits neither the account nor its SSO link; the plan reads no row of that account — a uuid minted a moment ago holds none — and the landing defaults come from the grant's own team read, stamped best-effort like the register path's, so all three first logins answer the same way when that one cosmetic write fails. Its session's context is read once the account, its link and its grants are written (issueNewAccountLogin), its root born under generation zero, read from nothing: a read failing there answers an error with the account written, and the user's next attempt signs in as a returning user. - A resolver failure is classified before it is answered: only a provider row the store says is ABSENT (or whose kind is not OIDC) is
oidc.ErrUnknownProvider, while a read that failed and a client secret the sealer cannot open areoidc.ErrProviderUnavailable./startanswers the first404 unknown providerand the second with the identity contract (500, generic body, the cause logged) — one classifier,refuseConnector, which every JSON consumer of the resolver goes through, the GitHub-orgs routes included; the callback, where the user sees a banner, splits all three —unknown_provider,disabled,provider_unavailable. Collapsing them made a tenant whose provider store was down read exactly like a tenant whose SSO had been deleted, with nothing logged. - The tenant and the provider id come from
PendingAuth, written at/startand keyed by the IdP-echoed state, so a slug presented at/callbackcannot be coerced into another tenant's policy. GitHubGatingActivecounts enabledkind=githubrows with no tenant filter: one org's allow-list switches gating on for every GitHub login on the deployment, and a gated match then admits a user even underinvite_only.
Authenticating a later request
extractBearerreads three channels: theAuthorization: Bearerheader, the access cookie of the spelling this build writes, and at=query parameter accepted only on/api/wsand/api/ws/*, because a browser cannot attach a header to a WS upgrade. No shipped client sets the third one — the SPA uses the cookie and the desktop build runs withDisableAuth— and the server authenticates it all the same, so/api/ws?t=<jwt>is not an unauthenticated request.
The refresh session
A refresh reads everything its answer needs — the account, its memberships, the active team's org — BEFORE the rotation, so a failed read leaves the presented token live; the successor's write, its check against
session_revocations(confirmSuccessor), its claim (ClaimSession, which only a session not revoked takes) and its check again are the steps after it. A revocation records itself there first — the record is its commit point, the one write it waits for — and its pass over the live sessions runs after its answer, so a sign-out or a revoke-all that lands while the write is in flight, whether its pass ran before the successor existed, after it, or not yet, retires that successor and refuses the presentation; a write, a check or a claim that fails leaves the successor unwritten or unclaimed — handed to nobody — and no refresh deletes a row it wrote, so the token's next presentation completes it and no successor a presentation was handed disappears. A refresh is bounded at 20 seconds as a whole and each of its session-store steps at 5 seconds.The reuse grace window (
ITERION_REFRESH_REUSE_GRACE, 30 seconds by default,0disables it, measured on the database's clock —RotateSessionstamps$$NOWand the presentation is compared withSessionStore.Now, both on the primary) is what two tabs, a desktop retrying a lost answer, or two concurrent refreshes of one token present: a token a rotation retired moments ago. A rotation whose successor was handed to nobody — never written, or written and never claimed — is completed by the token's next presentation within the completion horizon — 5 minutes after the rotation, or the window when that is longer — and before the token's expiry; later, the token is one its holder abandoned, and its presentation is reuse. The successor is re-derived from the presented token and the rotation's nonce under a key derived from the JWT signing key, and checked against the successor hash the rotation recorded —sessionsstores no refresh token — anddesktop_sso_ticketsrests none either: a desktop ticket's payload is the deferred login under the ticket's SHA-256, the session minted at redemption, so no token waits in the collection, the oplog or a backup, and an unredeemed ticket leaves no session to end (cloud-admin.md §2, #2635). Its price: a stolen token replayed within the window of its rotation receives the same successor the legitimate holder received, and both then hold the same lineage until a retired token of it is presented past its window, which revokes the lineage — the account's other logins keep their sessions. A sign-out never re-opens a window: it keepsrevoked_atand revokes the presented token's whole lineage, whose record refuses every successor a retired token of it names.A token the store has no row for — Mongo's TTL index reaped an expired session — and a session whose account no longer exists answer 401
session not found, which clears the cookies; a store failure answers 500 and leaves them alone.An access token names the session it was minted for (
sid, the lineage;sgen, its generation). Every route that mints a credential, grants or exercises platform authority, or changes or removes a credential asksCheckDerivationfirst — a team or org switch,POST /api/me/tokens(again once the token exists),POST /api/ws/ticketand its ticket's redemption, the SSO link's start and callback (again once the link is written), the removal of an SSO identity or a personal access token, the in-app password change, "sign out everywhere", the writes of an org's SSO providers and verified domains, the platform acts outside the admin prefix (org-member placement, the global memory space, another account's token), and every platform-admin route (requireSuperAdmin, which also reads the super-admin flag from the account); the desktop exchange asks the standing of the account its ticket defers — against the lineage and generation the callback named — before the session is minted. The super-admin CLAIM is re-derived where the identity is established — a token carries it only while its credential stands and the account still is a super-admin — so a demotion takes the platform authority away at once, everywhere. It refuses a credential once a revocation covers its session (SessionRevoked) or the account is disabled or pending a password change, a personal access token whose row is revoked, expired or gone (SetPATState), a personal access token asking for a credential of its own account and a ticket everything, and an access token naming no session an SSO link; it answers the presented credential's expiry: a switch re-issues the token with that expiry and the same session (IssueAccessUntil), a WebSocket ticket opens nothing past it. A token naming no session — minted before access tokens named theirs — is otherwise bounded by its own expiry alone.
No access-token denylist is wired at boot, so after a revoke-all an outstanding access JWT keeps the access it grants until its own expiry — the access-token TTL, ITERION_ACCESS_TTL, 15 minutes by default — except at the routes above, which refuse it (CheckDerivation); everything else it does meanwhile is done and persists (cloud-admin.md §2). JWTDenylist and NewJWTSignerMulti exist but nothing outside tests constructs them.
Steps
| actor | action | code |
|---|---|---|
| studio | lists the global connectors plus the per-org rows of every tenant resolved from a slug, an email or a domain — never a 404 | handleListProviders, resolveOrgTenants |
| server | refuses a cross-origin state-changing call before the public-path bypass | authMiddleware, originGateAllows, requireSafeOrigin |
| server | spends one bucket keyed by client IP and one by the lowercased email peeked from the body | limitRoute, peekJSONField |
| user | posts email and password; an unknown address still spends argon2id against dummyHash | handleLogin, Service.Login |
| store | counts the failure, locks at the fifth for 15 minutes, clears both counters on success | Service.Login, LockoutThreshold, LockoutDuration |
| server | starts an SSO flow and persists PendingAuth under the random state | beginOIDCFlow, GenerateStateAndPKCE, MongoStateStore.Put |
| provider | returns through the stable cloud callback; the state is taken once and the binding compared in constant time | handleOIDCCallback, MongoStateStore.Take |
| server | builds a per-org connector by unsealing its client secret behind the public-unicast SSRF guard | resolveConnector, buildOrgOIDCConnector, outboundStrict |
| store | resolves the GitHub gating context with one positive-only $in over the materialised keys | githubGateContext, FindGitHubGrantingOrgs |
| store | grants memberships grant-only, preserving an existing Source, and deletes only github_sso rows that stopped matching | grantMembership, reconcileGitHubGrants |
| server | picks the active team, resolves its org and org role, reads the account's session generation, mints the JWT with a kid header, a UUID jti, the session it names and an exp at the access-token TTL (15 minutes by default) | issueLoginInTeam, pickActiveTeam, resolveOrgForTeam, NewSessionRoot, JWTSigner.IssueAccess |
| store | writes the refresh row as the root of a new lineage; the plaintext token is returned and never persisted in sessions — and a desktop SSO ticket carries none either: its store holds the deferred login under the ticket's hash, and the session is minted at redemption (#2635) | IssueSession, MongoSessionStore |
| server | sets the HttpOnly cookies and echoes access_token only for a non-browser caller; the org tree is not part of the answer — GET /api/auth/me is the one route that builds it | writeSession, isBrowserClient, setAuthCookies, handleMe, buildOrgTree |
| desktop | takes a single-use ticket at a validated loopback instead of cookies, then redeems it over its native client | safeDesktopRedirect, handleDesktopExchange |
| cli | signs in with a PAT, with email and password, or through a browser loopback on 127.0.0.1 | runRemoteLogin, loginViaBrowser |
| server | re-issues the presented access token on a team or org switch, with its expiry and its session — no new refresh row — refusing a revoked session, a disabled account, one pending a password change and a personal access token | handleSwitchTeam, Service.SwitchTeam, Service.CheckDerivation, effectiveMembership, JWTSigner.IssueAccessUntil |
| server | verifies a later request: the WS ticket first, then an iap_ PAT, then the JWT | requireAuth, wsTicketIdentity, identityFromPAT, JWTSigner.Verify |
| store | re-reads the PAT's owner, membership and team on every use; last_used_at is written off the hot path | identityFromPAT, pat.Token.Usable |
| server | re-scopes a run-addressed route to the run's team, then stamps the store tenant | scopeRunByID, stampAuthedContext |
| operator | turns auth off for a local studio or the desktop build, synthesizing a super-admin per request | requireAuth under cfg.DisableAuth |
Credential states
| what | states | moved by |
|---|---|---|
identity.UserStatus | active, disabled, pending_password_change | the admin PATCH disables or sets pending; ChangePasswordPending and a reset confirm activate; disabled refuses login, refresh, every credential mint and PAT use alike; pending_password_change refuses refresh, every credential mint, PAT use and an SSO login, and sends a password login to the forced change |
users lockout | unlocked, locked_until set 15 minutes out | the fifth consecutive failure sets it; any success clears it; while locked, Login still spends argon2id and answers ErrInvalidCredentials |
auth.Session | live, rotated, expired, revoked, TTL-reaped | the state diagram above |
pat.Token | usable, revoked, expired | Usable is false once revoked_at is set or expires_at has passed; the row is kept after revocation for audit |
oidc.PendingAuth | put, taken, TTL-expired | Put at /start, Take at /callback is single-use and re-checks the 10-minute expiry because TTL deletion is lazy |
| WS and desktop tickets | minted, redeemed, TTL-expired | Mint (60 seconds, or 2 minutes for a desktop ticket) and a FindOneAndDelete Redeem |
orgsso.OrgSSOProvider | enabled, disabled | the org admin's routes; a disabled row answers ErrProviderDisabled, deliberately distinct from unknown |
orgsso.VerifiedDomain | claimed, verified | a DNS TXT record under _iterion-challenge. sets verified_at; only a verified domain satisfies canAutoLink |
orgsso.GitHubTeamGrant | inert, verified | FlattenGitHubTeamKeys skips an unverified grant, so it is stored and visible yet never matches at login |
Guards that refuse
requireSafeOrigin— 403 for a state-changing/api/call carrying an unrecognisedOrigin; an absentOriginpasses (no ambient credential to ride), andITERION_REQUIRE_ORIGIN=0disables the gate without a redeploy.- The token buckets — 429 with
Retry-Afteron the login, register or refresh bucket; the password change, both reset routes and the desktop exchange share the login bucket, and no bucket wraps/startor/callback. The backend is a seam: the in-process LRU is per replica and deliberately soft, the Valkey store makes the count exact across them. Service.Login— a disabled owner, a wrong password and a livelocked_untilall answer 401invalid credentials, the message being collapsed inhandleLogin. The response time is not: an unknown address and a livelocked_untileach spend a dummy argon2id cycle, while a disabled owner returns before any hashing, sodisabledstays distinguishable by timing. A 403password change requiredforpending_password_change, with no token minted.resolveConnector— 404 at/startfor an unknown slug and for a disabled row alike;ErrProviderDisabledonly becomes its ownsso_error=disabledat the callback.MongoStateStore.Take— single-use plus an expiry re-check; every miss, and aPendingAuthnaming another provider, becomessso_error=state_expired.- The agent-binding compare — constant-time, cleared whatever the outcome,
sso_error=agent_bindingon a mismatch; a desktop flow stores an empty binding on purpose and is bound instead by its loopback listener, the single-use state and PKCE. canAutoLink— five conditions:AutoLinkOnEmail, a domain store, a DNS-TXT-verified domain for the tenant, a target that is not a super-admin, and a target that administers no other non-personal team. Any failure isErrLinkRequiresConsent→ 409 on the API,sso_error=link_requiredon the callback.- The signup gates — both sit on the NEW-identity branch, past
oidc_linksand past the email match:ErrSSORestricted→ 403 for a gated GitHub identity matching no allow-listed team,ErrSignupClosed→ 400 for an ungated one meeting asignup_modeother thanopen. A returning user keeps logging in — anoidc_linksrow, or an email match on an operator-trusted provider, ends inissueLoginwith no gate check, andreconcileGitHubGrantsmerely deletes thegithub_ssomemberships that stopped matching. On the callback both are a 302 instead:sso_error=restrictedfor the first, andsso_error=login_failedfor the second, which falls into thedefaultarm ofssoErrorForAuthalongside every other failure. safeDesktopRedirect— 400 at/startunlessnext=is an absolutehttploopback URL with an explicit port, no userinfo and no fragment.wsTicketIdentity— a presented but dead ticket — unknown, expired, already redeemed, or naming no user — is a hard 401invalid or expired ws ticket, never a fall-through to the bearer paths; a redemption the ticket store could not complete is no verdict on the ticket: 500identity unavailable, the cause logged and the ticket kept out of the log.identityFromPAT— distinct 401 messages for an invalid, revoked or expired token, an inactive owner, a vanished membership and a failed team read; the mint side refuses a token that resolves to no team unless its owner is a super-admin.JWTSigner.Verify— HS256 only, with issuer, audience and expiry required; 401token expired, otherwise 401token invalid(a denied jti included).scopeRunByID— 404 for a missing, deleted or invisible run, 403 for a role below the route's rung, 500 when the store fails.stampAuthedContext— 403 for a team-less identity on a tenant-scoped path, rather than handing the store an empty tenant;/api/auth/,/api/me/,/api/orgs/,/api/teams/,/api/admin/and public marketplace GETs pass team-less.ghostTeamMutatingRefusal— 404 for a mutating/api/teams/{id}/…whose id resolves to no team and whose callercanManageTeamwould otherwise admit; undercfg.DisableAuththe same refusal runs first and unconditionally, the probe being pointless against a synthesized super-admin.
Collections touched
- Read on a mint:
users,memberships,org_memberships,teams— the active team, its parent org and the caller's roles — andsession_revocationsfor the account's session generation, unless the call created the account or revoked its sessions itself — except where the login is already in hand: a registration builds it from the rows it wrote, the desktop exchange mints the login its ticket deferred. An org switch also lists the org's teams to pick its landing team, and a super-admin's switch into an org reads thatorgsrow; both switches readsession_revocationsfor the session the presented token names. The org tree — theorgsand every team of each org a user belongs to — is built byGET /api/auth/mealone. - Written on a password login:
users(failed_logins,locked_until,last_login_at, andupdated_at, which the store stamps on every patch — a failed sign-in moves it too) andsessions(one hashed refresh row per issued session). - Written on every sign-in, every provider:
audit_events— oneauth.login.succeededrow on the org the session opened in, or oneauth.login.failedplatform row with the typed reason and the attempted address only as a peppered digest (ADR-157). A refresh writes none. - Written on an SSO login:
oidc_links, plusmembershipsandorg_membershipsfor a grant; a new non-gated identity also createsorgs,teamsand their membership rows throughcreatePersonalTeam. - Read on the SSO path:
org_sso_providers(the per-org row and the GitHub allow-list index) andorg_verified_domains(the auto-link gate). - Single-use credential stores:
oidc_states,desktop_sso_tickets,ws_tickets. - Read on every programmatic request:
personal_access_tokens, thenusers,membershipsandteamsagain.
Related
- data-model-identity.md — every collection above, its key line, its indexes and its twin.
- access-control.md — what the stamped identity is then allowed to do.
- cloud-user.md · cloud-admin.md · cloud-rest-api.md — the user, operator and API-client views of the same routes.
- browser-security.md — the origin gate, the
__Host-prefix and the asymmetric cookie read. - adr/035-per-org-sso-generic-oidc-plus-dedicated-connectors.md · adr/036-jws-id-token-verification-alg-allowlist.md · adr/037-sso-auto-link-requires-domain-dns-txt-not-idp-email-verified.md · adr/038-github-team-gating-via-materialized-reverse-lookup-index.md · adr/040-oidc-callback-cookie-samesite-lax-not-strict.md · adr/103-run-addressed-by-id-across-teams.md
