temper-api trusts.Doing a full ground-up enterprise install? This playbook is one phase. For the single
end-to-end sequence (deploy → SAML → org → agents) see
enterprise install.
This playbook is the operator runbook. For the security model it implements — how
tokens are verified, the authorization boundary, and where it is enforced — see
The Trust Boundary. For the auth-identity contract this mode
configures, see Auth identity.
Browser ──(1)──▶ /oauth/authorize ──▶ /oauth/saml/login ──(2)──▶ SAML IdP
│
┌───────────────(4) code ◀── /oauth/saml/acs ◀──(3) signed assertion
▼
/oauth/token ──(5) EdDSA JWT ──▶ temper-api (validates via /oauth/jwks, JIT-provisions the profile)/oauth/authorize./oauth/token for an EdDSA-signed access token.temper-api validates it against the AS's published JWKS and just-in-time provisions thesub. A validlyemail_verified: true.temper admin saml (recommended)temper admin saml--env-out / runs them with --apply), keeping the AS↔API shared values (AS_AUDIENCE ==AUTH_AUDIENCE, AUTH_ISSUER == AS_ISSUER, the one INTERNAL_RECONCILE_SECRET,AUTH_PROVIDER_NAME == saml:<idp-key>) consistent by construction. The sections that follow--no-interactive for a scripted run. ItAS_SIGNING_KEY_PKCS8), AS_SIGNING_KID, and a strongINTERNAL_RECONCILE_SECRET, then emits the full env bundle and the kb_saml_idp INSERT to--env-out .env.saml writes the env (mode 0600 — it holds the private key); --apply$DATABASE_URL. Paste the env into both Vercel functions and deploy.MCP_AUDIENCE, MCP_CLIENT_ID, and the MCP client'sAS_CLIENTS entry are added by hand — see--client <mcp-client-id>=<callback> (repeatable) to have it in the emittedAS_CLIENTS from the start.saml_group_mapping_changed). Onto temper-system or the gating team a mapping iswatcher only, and the IdP reconcile confers no more there whatever a mapping says: give atemper team add-member, as a system admin), so a spoofedmaintainer: an owner role, or an owner,--confirm-elevated. A watcher mapping never needs it. --from-seen reads kb_saml_seen_groups so you can add mappingstemper admin saml unmap-group --idp-key <acme-okta> engineering +engineeringgating_team_slug otherwise fails silently with 403s), and — with --db — that exactly onekb_saml_idp row exists.Ordering. SAML setup brackets the org-bootstrap playbook: run provision+ deploy + apply the IdP row before the first admin can log in, then run the
org-bootstrap (which creates the teams), then runmap-groupafter those teams exist. See
that playbook's interleave note.
| Setting | Value |
|---|---|
| ACS (Assertion Consumer Service) URL | https://<instance>/oauth/saml/acs |
| SP Entity ID / Audience | a stable URI you choose, e.g. https://<instance>/saml/metadata |
| NameID format | persistent (recommended) — becomes the token sub |
| Sign assertions | yes (both the <Response> and the <Assertion> must be signed) |
email) — becomes the token email.uid) — the fallback for sub when the NameID ishttps://<instance>/oauth/saml/metadata for import intokb_saml_idp)is_active to rotate to a replacement). To roll the IdP's signing certificate without anis_active:email_attr at an attribute your directory admins control. Temper treats the email aBob@corp and bob@corp to different people, they will be treated as the same person.José@corp and josé@corp stay two different addresses, so ankb_saml_idp therefore holds two certificate slots: idp_cert andidp_cert_secondary. An assertion signed by either is accepted, and that overlap is what letsidp_cert_secondary is NULL outside a rotation. Requires migration 20260827000010; on an[!IMPORTANT]
Paste the PEM with real line breaks. A plain SQL literal does not interpret\n— under the
defaultstandard_conforming_stringsyou get the two characters, not a newline, and a
certificate in that shape is not usable. Use a dollar-quoted literal, as the examples below do,
or paste the PEM across real lines. A slot that does not hold a usable certificate is refused by
a constraint on write, so you will hear about it here rather than at step 3.
SET idp_cert_secondary = NULL.AND matters: without it, running this a second time would try to move a NULL intoidp_cert. Steps 1–3 are each reversible on their own. Step 4 is not — it discards theloadActiveIdp reads the row on every request. That bounds new logins only. Sessions alreadyAS_ACCESS_TTL_SECONDS, and a refresh chain for up toAS_REFRESH_CHAIN_MAX_SECONDS from its last full login. If you are rotating because a signing key[!NOTE]
Two slots widen the accepted signers by exactly one named certificate, and only for this IdP.
A certificate belonging to some other IdP, or to none, is refused during an overlap window exactly
as it is outside one, and the instance still has a single active IdP throughout.temper admin saml verify --dbchecks that there is exactly one active IdP row. It does not
inspect either certificate, so it stays green through every step above, including a rotation you
have half-finished. It is not a check on the rotation.
source='idp' memberships. Native memberships (added in-app ortemper admin access reconcile-auto-join) are never touched — if a user is already a nativegroups_attr NULL to keep authentication-only behavior (no membership changes).(team, role) with the admin door. Teams must already exist. Two groups mappingowner > maintainer > member > watcher):maintainer: an owner role, or an authoring role onto a team with a pool (the gating team or--confirm-elevated (for example--role owner --confirm-elevated). Onto temper-system or the gating team only --role watchertemper admin saml unmap-group removes one.temper admin promote). The first admin still requires the SQL bootstrap step; SAML doeskb_saml_seen_groups (with first/last-seen) so you can discover what the IdP actually sends andidpidp memberships.idp memberships are depends onkb_saml_principal_reconcile, and vw_saml_reconcile_staleness reads them together withgroups_attr is set. With it left NULL the IdP is authentication-only,last_reconciled_at as NULL: the column is not| Variable | Value | Notes |
|---|---|---|
AS_ISSUER | https://<instance> | The AS issuer URL. Setting this flips the instance into AS mode (it serves AS metadata/JWKS instead of Auth0). |
AS_AUDIENCE | https://<instance>/api | Audience claim minted into tokens for API flows (must equal the temper-api AUTH_AUDIENCE). |
AS_SIGNING_KEY_PKCS8 | (PEM contents) | Ed25519 private signing key (PKCS#8 PEM). Keep secret. |
AS_SIGNING_KID | e.g. as-2026-07 | Key id published in the JWKS. |
AS_CLIENTS | (JSON, see below) | Required allowlist of client_id → [redirect_uris]. Without it every /oauth/authorize is rejected (fail-closed). |
AS_ACCESS_TTL_SECONDS | 900 (default) | Access-token lifetime. |
AS_REFRESH_TTL_SECONDS | 2592000 (default, 30d) | Refresh-token lifetime. Slides on every rotation. |
AS_REFRESH_CHAIN_MAX_SECONDS | 7776000 (default, 90d) | Absolute lifetime of a refresh chain, from the last full SAML login. Rotation never moves it, so this — not the two TTLs above — is the bound on how long IdP-removed reach can persist. See Limitations. |
AS_SAML_ASSERTION_MAX_SECONDS | 3600 (default, 1h) | The widest assertion validity window your IdP issues — read it off the IdP, not off Temper. It is the floor below which a consumed assertion is never forgotten, so the retention sweep cannot delete a replay row that could still be replayed. Too large costs a little storage; too small re-opens replay. A value that is present but not a usable number of seconds fails the sweep rather than substituting the default — rows stay on disk until you fix it; unset or blank uses the default. Read by temper-api, not by the AS — if you run the two as separate processes, set it on temper-api. See What is kept, and for how long. |
AS_REFRESH_REPLAY_GRACE_SECONDS | 10 (default) | How soon after a rotation a client may present the spent refresh token again and be treated as a retry rather than a thief. Later than this and the whole chain is ended. 0 ends the chain on any replay; the widest value honoured is 3600. See Watch for replayed refresh tokens. |
AS_CLIENTS registers the exact redirect URIs each client may use (exact string match — this{
"temper-cli": ["https://<instance>/api/auth/cli-callback"],
"temper-ui": ["https://<app-url>/auth/callback"],
"temper-desktop": ["https://<instance>/api/auth/cli-callback"]
}temper-desktop is the desktop app's own public client — the registration a deploymentdesktop_client_id in the [auth] provider entry (see thetemper-apitemper-api at the AS as its single issuer:| Variable | Value |
|---|---|
JWKS_URL | https://<instance>/oauth/jwks |
AUTH_ISSUER | the same value as AS_ISSUER |
AUTH_AUDIENCE | the same value as AS_AUDIENCE |
MCP_AUDIENCE | https://<instance>/mcp — the MCP resource indicator. Set it on the same project/processes as everything above; see Connect MCP clients |
AUTH_PROVIDER_NAME | saml:<idp-key> (e.g. saml:acme-okta) — namespaces the JIT auth link. Max 32 chars. |
@mcp). Set TEMPER_MCP_SERVICE_SECRET to the same value on the API and the MCP| Variable | Where | Purpose |
|---|---|---|
TEMPER_MCP_SERVICE_SECRET | API + MCP function (shared) | Shared secret gating MCP attribution: only requests carrying it may claim the relay's attribution carrier. Unset on the API ⇒ the carrier is never trusted — relaying still works, but every relayed act attributes to the direct (@web) surface. Must differ from every other shared secret the API configures (the API refuses to boot on overlap), and must be at least 16 characters (the API refuses to boot on a weak one). Generate with openssl rand -base64 32. |
TEMPER_API_BASE_URL names; thatDATABASE_URL. ItCORS_ORIGINS, the blob variables (it reads theTEMPER_API_BASE_URL, TEMPER_MCP_SERVICE_SECRET,TEMPER_EDGE_PROXY_SECRET, the discovery variables, and the telemetry variables. The completeMcpServerConfig::from_lookup and DiscoveryConfig::from_env incrates/temper-mcp-server. Vercel scopes environment variables to a project and an environment,INTERNAL_RECONCILE_SECRET, SLACK_*, thetemper-api before minting a token. SetINTERNAL_RECONCILE_SECRET to the same value on both the AS and the temper-api deploymentWhy a shared secret rather than an origin/IP allow-list, and the endpoint's bounded blast
radius, are explained in The SAML reconcile channel.
| Variable | Where | Purpose |
|---|---|---|
INTERNAL_RECONCILE_SECRET | AS + API (shared) | Shared secret gating the internal reconcile call. Same value on both. Unset ⇒ reconcile disabled, no group provisioning. |
INTERNAL_RECONCILE_URL | AS | Full URL of the temper-api /internal/saml/reconcile endpoint the AS calls before minting (e.g. https://<your-api-origin>/internal/saml/reconcile). |
INTERNAL_RESOLVE_URL | AS | Full URL of the temper-api /internal/principal/resolve endpoint (e.g. https://<your-api-origin>/internal/principal/resolve). Gated by the same INTERNAL_RECONCILE_SECRET. The AS calls it to learn which profile a login belongs to, so an administrator's revoke can end that principal's refresh chains. Unset ⇒ chains are minted without an owner and are bounded by AS_REFRESH_CHAIN_MAX_SECONDS alone. |
SLACK_LINK_CLIENT_ID and is redirected back to <PUBLIC_BASE_URL>/api/auth/slack/callback.AS_CLIENTS entry — AS_CLIENTS is| Variable | Where | Purpose |
|---|---|---|
SLACK_LINK_CLIENT_ID | API | The client_id the link flow authorizes as. Must be present in AS_CLIENTS, with <PUBLIC_BASE_URL>/api/auth/slack/callback among its redirect URIs. |
SLACK_LINK_SECRET | API + mention agent (shared) | Shared secret gating the agent's /internal/slack/link-state call. Same value on both. Unset ⇒ link endpoint disabled. |
PUBLIC_BASE_URL | API | This instance's public origin (e.g. https://<instance>). The callback redirect_uri is derived from it. |
[[auth.providers]] block with provider = "temper-as",client_id = "temper-cli", authorize_url = https://<instance>/oauth/authorize,token_url = https://<instance>/oauth/token, andcallback_url = https://<instance>/api/auth/cli-callback. The existing PKCE + loopback logintemper auth login then authenticatesOIDC_DISCOVERY_URL points the UI at the AS's RFC 8414 metadata (the AS does not serve/.well-known/openid-configuration). Ensure temper-ui's <app-url>/auth/callback is listedAS_CLIENTS. OIDC_PUBLIC_CLIENT=true is required for this secret-less path — without it,Single-origin ACS and CSRF (SAML only). In the single-origin topology, temper-ui
reverse-proxies/oauthto the API, so the SAML ACS is reached at<app-url>/oauth/saml/acs.
The SAML HTTP-POST binding delivers the assertion as a browser-submitted formPOSTfrom
the IdP's origin — a legitimately cross-origin POST that SvelteKit's built-in origin CSRF
check would otherwise reject with403 Cross-site POST form submissions are forbiddenbefore
the proxy could forward it.temper-uihandles this: the built-in check is disabled and the
equivalent origin guard is re-implemented inhooks.server.ts(scoped to the UI's own
routes), after the proxied surface — including the ACS — has already been short-circuited
upstream. The ACS POST is authenticated by the SAML layer itself (signature, audience,
destination/recipient, replay guard), not by anOriginmatch. No operator action is
required — this is built in. The OIDC path does not hit this at all: its callback
completes as aGETredirect, which CSRF does not touch.
/mcp) is served from the same deployment and authenticates againstclient_id and fall backregistration_endpoint ishttps://<instance>/oauth/clients. For a public MCP client it mints nothing and stores nothing:client_id and echoes back only the requested/oauth/authorize open-redirect protection is unweakened. To enable it:MCP_AUDIENCE=https://<instance>/mcpAS_ISSUER stays the authorization-server issuer (https://<instance>) — an issuer is notAS_AUDIENCE and AUTH_AUDIENCE stay the API/default audience (https://<instance>/api).MCP_AUDIENCE names the MCP resource. It is independent of AS_AUDIENCE — they need not/oauth/* (the authorization server, which serves the resource), whatever serves /mcp/api (which accepts it — the MCP server relaysAUTH_AUDIENCE=https://<instance>/api they refuse to sign in.MCP_CLIENT_ID to a client id of your choosing, <mcp-client-id> (conventionallytemper-mcp), wherever /oauth/* is served. It must also be a key in AS_CLIENTS. Without503 temporarily_unavailable.AS_CLIENTS. Start from thevercel env pull). Do not regenerate it withtemper admin saml provision: that also mints a new signing key and reconcile secret. Add one{
"temper-cli": ["https://<instance>/api/auth/cli-callback"],
"temper-ui": ["https://<app-url>/auth/callback"],
"temper-desktop": ["https://<instance>/api/auth/cli-callback"],
"<mcp-client-id>": [
"<existing-callbacks>",
"<remote-client-callback>"
]
}AS_CLIENTS is the whole allowlist for every client on thehttps://claude.ai/api/mcp/auth_callback andhttps://claude.com/api/mcp/auth_callback.http://127.0.0.1/callback for Claude Code — matcheshttp://127.0.0.1:<random>/callback and its localhost / [::1] spellings. Loopbackiss = AS_ISSUER and, by default, aud = AS_AUDIENCE; both doors validate the one| AS mints | The instance validates | Enforced requirement |
|---|---|---|
AS_ISSUER | AUTH_ISSUER | AS_ISSUER == AUTH_ISSUER, byte for byte |
AS_AUDIENCE | AUTH_AUDIENCE | AS_AUDIENCE == AUTH_AUDIENCE |
MCP_AUDIENCE, for a flow that requests it | MCP_AUDIENCE | must be a URI; no equality rule |
| (its JWKS) | JWKS_URL | JWKS_URL == $AS_ISSUER/oauth/jwks |
AS_AUDIENCE, plus MCP_AUDIENCE when set), mints it into the token, and keeps it"resource": "https://<instance>/mcp" — not https://<instance>/api. The API audience hereMCP_AUDIENCE is unset or has not reached the MCP function.401 with a WWW-Authenticate: Bearer … resource_metadata="https://<instance>/.well-known/oauth-protected-resource""client_id": "<mcp-client-id>" and "redirect_uris": ["<remote-client-callback>"] — theredirect_uris means that callback is not allowed under <mcp-client-id> in AS_CLIENTS:302 — a redirect to the instance's /oauth/saml/login, the hand-off to your IdP. A 400 means the authorization serverrequested resource is not served by this authorization server whenMCP_AUDIENCE has not reached the functions serving /oauth/*, unregistered client_id or
redirect_uri when check 3's callback is wrong there. This call records a short-lived pendingtemper auth login → a browser opens to your IdP; after SAML login the CLI receives atemper auth status (or any authenticated command) succeeds.kb_profiles row and a kb_profile_auth_links row (withauth_provider = saml:<idp-key>) are created for the user on first login.401) even with a validtemper admin access reactivate <profile-uuid> restores the prior standing).Deactivation is enforced by the shared authorization seam, so both surfaces — temper-api
andtemper-mcp— reject a deactivated principal identically. See
The Trust Boundary.
saml-setup.sh script automates the temper admin saml sequence above from a declarativesaml-profile.yaml) — it loops provision / map-group / verify. Run from a reposaml-setup.sh --profile saml-profile.yaml --dry-runkb_saml_idp SQL, safe to run anytime, no DB writes:saml-setup.sh --profile saml-profile.yamlkb_saml_idp row, applies group mappings, verifies against the liveDATABASE_URL + psql; run post-migrate, and after the org-bootstrap teams exist):DATABASE_URL=postgresql://… saml-setup.sh --profile saml-profile.yaml --apply-dbyq to read the profile and temper on PATH. Emit-by-default and idempotency aretemper admin saml commands, not reimplemented by the script. Itsystem-bootstrap.sh (kept separate so that script stays usable forAS_REFRESH_CHAIN_MAX_SECONDS, not AS_ACCESS_TTL_SECONDS. A sessionAS_REFRESH_CHAIN_MAX_SECONDS + AS_ACCESS_TTL_SECONDS — 90 days plus 15 minutes on theAS_REFRESH_CHAIN_MAX_SECONDS to shorten it, at the cost of how often your users/api/access/review-request to contest the revocation requires one. That token isAS_ACCESS_TTL_SECONDS and is not carried forward. If you need the sign-in[!IMPORTANT]
Upgrading an existing SAML deployment?INTERNAL_RESOLVE_URLis new, and nothing sets it for
you —temper admin saml provisionemits it only into a freshly generated bundle. Without it the
AS cannot record which principal a refresh chain belongs to, so an administrator's revoke ends
no chains and the only remaining bound isAS_REFRESH_CHAIN_MAX_SECONDS. Logins and refreshes
keep returning200throughout, so there is no failure to notice.The signal to look for is the API's standing terminal ended no refresh chainswarning when
you revoke someone — but read itsownerless_live_chainsfield, not merely its presence. That
warning is also the normal outcome for a machine principal and for anyone who simply was not
signed in; a non-zeroownerless_live_chainsis the part that means the authorization server
is not recording owners at all. Add the variable, pointing at your API origin's/internal/principal/resolve, before relying on revoke.
AS_ISSUER set)AS_REFRESH_REPLAY_GRACE_SECONDS is where you set the line between the two readings. Inside0 for the strictesthostile_count first; it is the count of presentations that fell outside the grace window.could not record or act
on a refused rotation), which is the one case where this table and the logs have to be read| Column | Read it as |
|---|---|
profile_handle | Who held the chain. NULL if the AS could not record an owner — see the INTERNAL_RESOLVE_URL note above; the replay is still real. |
first_replay_age | How long after the rotation the token reappeared — the age the server judged it on, not a later reading. Seconds is consistent with a client retry, not proof of one: a credential stolen and used immediately also reappears in seconds. Hours or days is consistent with theft, and also with a client that lost a response and retried much later — a CLI whose refresh timed out on its side presents the old token on its next run. Read it with tokens_revoked. When the database committed a rotation but its reply never reached the AS, the client was told temporarily_unavailable, no successor exists, and the row shows tokens_revoked = 0 (a lost success response shows 1, since the successor the client never received was live); the AS also logs the token store did not answer the refresh rotation shortly after rotated_at. That line carries no token or chain identifier, so it can only be matched by time, and its absence proves nothing. |
replay_count / hostile_count | How many times it was presented, and how many of those fell outside the grace window. hostile_count = 0 on a chain you did not expect to be racing is still worth a look. |
chain_ended / chain_ended_at | Whether the chain was ended in response, and when. |
tokens_revoked | How many live tokens the ending actually revoked. 0 alongside chain_ended means the chain held nothing live at that moment — it is still ended, and no successor can be minted into it. |
[!NOTE]
What this view covers: chains issued by this instance from this version onward. Earlier
chains are bounded byAS_REFRESH_CHAIN_MAX_SECONDSand are gone within that window, so an empty
result means "nothing recorded", which becomes "nothing happened" once your longest chain has
turned over.
/api/as/reap, gated by the same EMBED_DISPATCH_SECRET bearer as the other internalcount(*) on the three| Table | A row per | Deleted once |
|---|---|---|
kb_saml_replay | consumed SAML assertion | AS_SAML_ASSERTION_MAX_SECONDS has passed since the row's stamped expires_at — so never sooner than that long after the assertion was consumed |
kb_oauth_flow | authorization-code flow (including abandoned ones) | 1 day past expires_at |
kb_oauth_refresh_tokens | issued refresh token | 30 days past both its own expiry and its chain's chain_expires_at |
[!IMPORTANT]
Apply migrations before the first sweep runs. The sweep's indexes and its evidence constraint
ship as a migration and it is built to run with them in place; run it first and it will simply
make less progress per night.
kb_saml_replay row is what stops a captured assertion being presented twice. Temper does notNotOnOrAfter, so the row's own expires_at is an assumption about it rather than aAS_SAML_ASSERTION_MAX_SECONDS instead ofinvalid_grant, logs nothing and records nothing. Before this sweepkb_oauth_refresh_replays is a different matter: the sweep neverON DELETE RESTRICT, so the database refuses the delete even if some future caller forgets thevw_oauth_refresh_replays is aged out from under you.kb_profiles — retention and erasure are different acts, and only the first is what this sweep{"level":"INFO","fields":{"message":"AS retention sweep complete","saml_replay":1204,
"oauth_flow":880,"refresh_tokens":0,"more_pending":false,"assertion_window_seconds":3600.0}}more_pending is true when a table stopped because it hit the 50,000-row cap rather than becauseassertion_window_seconds echoes the floorAS_SAML_ASSERTION_MAX_SECONDS reached the process.