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.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 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):source='idp' memberships. Native memberships (added in-app orgroups_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{
"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 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). |
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 back to DCR regardless. The AS metadata advertises aregistration_endpoint (/oauth/register), a thin proxy that echoes a pre-registered staticclient_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 inAS_CLIENTS (e.g. temper-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 matcheshttp://127.0.0.1:<random>/callback. Loopback matching is confined to the local machine and127.0.0.1, localhost, [::1]), so one loopback entryiss = AS_ISSUER, aud = AS_AUDIENCE; both surfaces validate against theAUTH_AUDIENCE. If these disagree, the process refuses to start and| 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 reads the instance's one audience, same asMCP_AUDIENCE, it must equal AUTH_AUDIENCE; it is antemper 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 valid token.is_activeis enforced by the shared authorization seam, so both surfaces —temper-api
andtemper-mcp— reject a deactivated profile 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_ACCESS_TTL_SECONDS) and cannot re-login. Automated deprovisioning (SCIM) isAS_ISSUER set)