temper-api trusts.Doing a full ground-up enterprise install? This guide is one phase. For the single
end-to-end sequence (deploy → SAML → org → agents) see enterprise-install.md.
This guide is the operator runbook. For the security model it implements — how
tokens are verified, the two-level authorization seam, the reconcile channel's trust
model, and profile deactivation as an authn lever — see ../auth/,
the canonical home for Temper's auth flows.
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-apisub. A validly signed assertion impliesemail_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 numbered sections that--no-interactive for a scripted run. It generatesAS_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.kb_saml_group_mappings INSERT (add --apply to run it). --from-seen readskb_saml_seen_groups so you can add mappings reactively.gating_team_slug otherwise fails silently with 403s), and — with --db — that exactly onekb_saml_idp row exists.Ordering. SAML setup brackets the org-bootstrap runbook: 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 runbook'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 is not persistent.https://<instance>/oauth/saml/metadata for import into IdPskb_saml_idp)is_active to rotate to a replacement):source='idp' memberships. Native memberships (added in-app or bygroups_attr NULL to keep authentication-only behavior (no membership changes).(team, role). Teams must already exist. Two groups mapping to the same teamowner > maintainer > member > watcher):temper-admins owners of the gatingkb_saml_seen_groups (with first/last-seen) so you can discover what the IdP actually sends andidpidp memberships.| 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 (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. |
AS_CLIENTS registers the exact redirect URIs each client may use (exact string match — this is the{
"temper-cli": ["https://<instance>/api/auth/cli-callback"],
"temper-ui": ["https://<app-url>/auth/callback"]
}temper-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 |
AUTH_PROVIDER_NAME | saml:<idp-key> (e.g. saml:acme-okta) — namespaces the JIT auth link. Max 32 chars. |
temper-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 ../auth/reconcile-channel.md.
| 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). |
SLACK_LINK_CLIENT_ID and is redirected back to <PUBLIC_BASE_URL>/api/auth/slack/callback.AS_CLIENTS entry — AS_CLIENTS is an| 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,callback_url = https://<instance>/api/auth/cli-callback. The existing PKCE + loopback login flowtemper login then authenticates through SAML.OIDC_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 listed inAS_CLIENTS. OIDC_PUBLIC_CLIENT=true is required for this secret-less path — without it, the UISingle-origin ACS and CSRF (SAML only). In the single-origin topology, temper-uireverse-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-ui
handles 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 against theclient_id and fallanthropics/claude-code#26675, #38102, #68853). The AS metadataregistration_endpoint (/oauth/register), a thin proxy that echoes a pre-registeredclient_id; it never persists client-supplied redirect URIs, so the /oauth/authorizeMCP_CLIENT_ID on the deployment to a client id that is also a key in AS_CLIENTStemper-mcp). Without MCP_CLIENT_ID, /oauth/register returns 503
temporarily_unavailable.AS_CLIENTS with the redirect URIs its clients use:{
"temper-cli": ["https://<instance>/api/auth/cli-callback"],
"temper-ui": ["https://<app-url>/auth/callback"],
"temper-mcp": [
"https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback",
"http://127.0.0.1/callback"
]
}http://127.0.0.1/callback entry matches http://127.0.0.1:<random>/callback. Loopback127.0.0.1,localhost, [::1]), so one loopback entry covers whichever the client sends. Non-loopbackiss = AS_ISSUER, aud = AS_AUDIENCE; both surfaces validate against the instance's oneAUTH_AUDIENCE. If these disagree, the process refuses to start and names the offending| AS mints | The instance validates | Enforced requirement |
|---|---|---|
AS_ISSUER | AUTH_ISSUER | AS_ISSUER == AUTH_ISSUER |
AS_AUDIENCE | AUTH_AUDIENCE | AS_AUDIENCE == AUTH_AUDIENCE |
| (its JWKS) | JWKS_URL | JWKS_URL == $AS_ISSUER/oauth/jwks |
MCP_AUDIENCE is optional. temper-mcp no longer has an audience of its own — it reads theMCP_AUDIENCE, it must equalAUTH_AUDIENCE; it is an assertion, not a second value. (It used to fall back to AUTH_AUDIENCEtemper login → a browser opens to your IdP; after SAML login the CLI receives a token.temper whoami (or any authenticated command) succeeds.kb_profiles row and a kb_profile_auth_links row (with auth_provider = saml:<idp-key>) are401) even with a valid token.is_activeis enforced by the shared authorization seam (Level 1), so both surfaces —temper-apiandtemper-mcp— reject a deactivated profile identically. See
../auth/authorization-seam.md.
saml-setup.sh automates the temper admin samlprovision / map-group / verify fromsaml-profile.yaml:yq to read the profile and temper on PATH. Emit-by-default and idempotency aretemper admin saml commands, not reimplemented by the script. Itsystem-bootstrap.shAS_ACCESS_TTL_SECONDS) and cannot re-login. Automated deprovisioning (SCIM) is Phase 3.AS_ISSUER set) or an