1. playbooks
temper
  • Temper documentation
  • cli
    • README
    • admin
    • auth
    • check
    • cogmap
    • config
    • context
    • edge
    • init
    • invitations
    • invocation
    • memory
    • pull
    • query
    • resource
    • search
    • skill
    • slack
    • status
    • steward
    • team
    • trail
    • update
    • version
    • warmup
  • config
    • README
  • auth
  • guides
  • doors
    • Building against Temper
    • Running Temper
    • Using Temper
  • concepts
    • Auth Identity
    • Authoring Authorization
    • Contexts and Refs
    • Machine Tokens
    • Operational Memory
    • Release Verification
    • The SAML Reconcile Channel
    • Slack Identity and Revocation
    • Teams and Roles
    • Telemetry
    • Token Verification
    • The Trust Boundary
  • playbooks
    • Adopt operational memory
    • Authenticate
    • Bootstrap an Org
    • Bootstrap a Team's Self-Cognition
    • Build a cognitive map
    • Connect Claude Code
    • Connect Temper to Claude Desktop
    • Deliver L0 Kernel Content
    • Deploy a Steward Agent
    • Deploy the Web UI
    • Enterprise Install
    • Provision a Read-Only GitHub Connection
    • Ingest a corpus
    • Install Temper
    • Run a Team
    • Self-Host Temper
    • Self-host Temper with Okta
    • Self-host Temper with a SAML IdP
    • Send traces to an OTLP backend
    • Slack Mentions
    • Standing Up a Machine Credential
  • sdks
    • Integrating Ruby with temper-rb
  • Temper Cloud API
    • Access
      • POST /api/access/requests — submit a join request for the gating team.
      • GET /api/access/requests/me — check own join request status.
      • DELETE /api/access/requests/me — withdraw a pending join request.
      • POST /api/access/reviews — a revoked principal asks an admin to reconsider (spec D15).
      • GET /api/access/settings — read public system settings.
    • Slack Link
      • Disconnect any principal. Operator path — offboarding and stuck users.
      • Disconnect EVERY Slack principal bound to the caller's own profile.
    • Auditor
      • auditor_dispatch
      • auditor_sweep
      • complete_auditor_job
    • Graph
      • POST /api/cogmaps/{id}/graph/slice — R4 cogmap-scoped neighborhood slice.
      • GET /api/graph/cogmaps/{id}/panorama — enter-a-cogmap Tier-0 interior.
      • GET /api/graph/contexts/composition — Beat E Tier-1: the force-graph composition of a container's (or a residual bucket's) members.
      • GET /api/graph/contexts/panorama — Beat E Tier-0: goal-container territories + residual tray.
      • GET /api/graph/home — the you→teams→cogmaps membership home.
      • GET /api/graph/regions/composition — Beat D region→resources composition drill.
    • Cognitive Maps
      • list_cognitive_maps
      • genesis
      • get_cognitive_map
      • reconcile
      • analytics
      • grant_cogmap_access
      • revoke_cogmap_access
      • materialize
      • materialize_delta
      • region_metrics
      • shape
      • bind_team
      • unbind_team
    • Contexts
      • list_contexts
      • create_context
      • get_context
      • context_materialize
      • reassign
      • context_region_metrics
      • rename
      • context_shape
      • share_team
      • unshare_team
    • Events
      • cursor
      • GET /api/graph/elements/{kind}/{id}/trail — R5 element event-trail. kind ∈ {node, edge}.
    • Facets
      • set_facet
      • Read the live facets of one edge.
      • Set a facet whose owner is an **edge** rather than a resource.
      • Read the live facets of one resource — the confirming read for a write that steers region formation and Atlas grouping.
    • Health
      • health_check
    • Ingest
      • create_ingest
      • update_ingest
      • list_blocks
      • append_block
      • finalize_resource
    • Invitations
      • accept
      • decline
      • list_mine
      • list_team_invitations
      • revoke_team_invitation
      • create_team_invitation
    • Invocations
      • list_invocations
      • open
      • show
      • close
    • Profile
      • get_profile
      • update_profile
      • list_auth_links
    • Query
      • `POST /api/query`.
    • Relationships
      • assert
      • fold
      • retype
      • reweight
    • Resources
      • `GET /api/resources` — **one response type, unconditionally.**
      • create_resource
      • get_resource
      • delete_resource
      • update_resource
      • List the finding at `{id}`'s citation-audit trail — one row per audit, each naming its auditor.
      • Record an auditor's signed defensibility verdict on one `(block, source)` citation of the finding at `{id}`. CONFORM to `handlers::edges::assert` (the sibling authored-write handler): thin — build the command, dispatch it, map the error. No persistence he
      • get_content
      • list_resource_edges
      • resource_evidence
      • grant_resource_access
      • revoke_resource_access
      • resource_lineage
      • provenance
      • annotate_resource
    • Meta
      • get_meta
      • update_meta
    • Reassign
      • reassign_resource
      • reassign_team
    • Search
      • `POST /api/search`.
    • Steward
      • candidates
      • dispatch
      • sweep
      • delta
      • advance
    • Teams
      • list_teams
      • create_team
      • detail
      • delete_team
      • update_team
      • add_member
      • remove_member
      • change_role
    • Schemas
      • AcceptInvitationResponse
      • ActInput
      • ActInvocation
      • ActName
      • ActQuantity
      • ActRefusal
      • ActorAuthority
      • AddMemberRequest
      • AdvanceWatermarkAck
      • AdvanceWatermarkRequest
      • AgentAuthorship
      • AppendBlockPayload
      • AssertRelationshipRequest
      • AtlasEdge
      • AtlasHome
      • AtlasNode
      • AtlasSubgraph
      • AuditCitation
      • AuditSweepRow
      • AuditorDispatchTickRequest
      • AuditorDispatchTickResponse
      • AuditorJobCompleteAck
      • BindTeamOutcome
      • BindTeamRequest
      • BlockId
      • BlockProvenanceRow
      • BlocksResponse
      • BodyStorage
      • Bridge
      • BulkReassignAck
      • BulkReassignRequest
      • ChangeRoleRequest
      • CharterBlock
      • CharterDisposition
      • CitationAuditRequest
      • CitationAuditRow
      • ClaimedAuditJob
      • ClaimedJob
      • CloseInvocationRequest
      • CogmapAnalyticsRow
      • CogmapDetail
      • CogmapFoundationRow
      • CogmapGrantBody
      • CogmapId
      • CogmapRegionMetricsRow
      • CogmapRegionRow
      • CogmapRegulationRow
      • CogmapRevokeBody
      • CogmapRow
      • CogmapStaleness
      • CombineNode
      • CombineOp
      • Composition
      • CompositionTrace
      • ConfidenceBand
      • ContentResponse
      • ContextCreateRequest
      • ContextId
      • ContextOwnerRef
      • ContextPanorama
      • ContextRow
      • ContextRowWithCounts
      • CorrelationId
      • CreateCogmapOutcome
      • CreateCogmapRequest
      • CreateInvitationRequest
      • CreateRequestBody
      • CreateReviewBody
      • DeleteResponse
      • DeviceOverrides
      • DispatchTickRequest
      • DispatchTickResponse
      • Disposition
      • DriftSweepRow
      • EdgeFacetRow
      • EdgeFacetSetRequest
      • EdgeFacetsResponse
      • EdgeFilter
      • EdgeId
      • EdgeKind
      • ElementEvent
      • ElementKind
      • EmbedDispatchSummary
      • Entitlements
      • ErrorBody
      • ErrorDetail
      • ErrorDetails
      • EventCursorResponse
      • EventTrail
      • ExactArm
      • ExactHit
      • Extent
      • FacetAck
      • FacetPredicate
      • FacetSetRequest
      • FinalizePayload
      • FoldRelationshipRequest
      • GrantOutcome
      • GraphEdgeRow
      • GroupKeyMeta
      • HealthResponse
      • HomeCogmap
      • HomeContext
      • IdKind
      • IdProvenance
      • IdSet
      • IdpRevocation
      • IngestCreateResponse
      • IngestDelta
      • IngestPayload
      • IngestState
      • InheritedReadGrant
      • InheritedShare
      • InputSource
      • Intention
      • InvitationStatus
      • InvitationTokenRequest
      • InviteeInvitation
      • InvocationAck
      • InvocationActRow
      • InvocationCloseAck
      • InvocationId
      • InvocationSummary
      • InvocationView
      • JoinRequest
      • JoinRequestStatus
      • LensId
      • LineageNode
      • ManagedMeta
      • MatchLocation
      • MaterializeAck
      • MaterializeDelta
      • MaterializeRequest
      • MergePolicy
      • MetaUpdatePayload
      • NarrowedBy
      • NodeHome
      • OpenInvocationRequest
      • OrdOp
      • OrphanNode
      • OutcomeDeclaration
      • PlanRefusal
      • PlanRefusalDetails
      • Polarity
      • Profile
      • ProfileAuthLink
      • ProfileId
      • ProfileUpdateRequest
      • ProfileWithEntitlements
      • PropertyOp
      • PropertyPredicate
      • ProvenanceSource
      • PublicSystemSettings
      • QuantityScale
      • QueryResponse
      • ReassignAck
      • ReassignContextOutcome
      • ReassignContextRequest
      • ReassignResourceRequest
      • ReconcileCogmapRequest
      • ReconcileEdge
      • ReconcileEdgeTombstone
      • ReconcileEntry
      • ReconcileOutcome
      • ReconcileTelos
      • ReconcileTelosBlock
      • ReconcileTombstone
      • Refusal
      • RefusalReason
      • RegionHit
      • RegionId
      • RelationshipAck
      • RemoveMemberOutcome
      • RenameContextOutcome
      • RenameContextRequest
      • ResidualBucket
      • ResidualContext
      • ResidualGroups
      • ResidualOwnedReach
      • ResourceAnnotateRequest
      • ResourceCreateRequest
      • ResourceFacetRow
      • ResourceFacets
      • ResourceFacetsResponse
      • ResourceFilter
      • ResourceGrantBody
      • ResourceHit
      • ResourceId
      • ResourceLineage
      • ResourceListResponse
      • ResourceRevokeBody
      • ResourceSection
      • ResourceSortField
      • ResourceUpdateRequest
      • ResourceView
      • ReturnSpec
      • RetypeRelationshipRequest
      • RevokeOutcome
      • ReweightRelationshipRequest
      • ScoreKind
      • Scoring
      • SearchParams
      • SearchReason
      • SearchResponse
      • SearchResultRow
      • SearchScope
      • SearchScopeInfo
      • SegmentInfo
      • SegmentedBegin
      • SegmentedBeginResponse
      • ShareContextOutcome
      • ShareContextRequest
      • SlackDisconnectRequest
      • SlackDisconnectResponse
      • SlackDisconnectedPrincipal
      • SliceRequest
      • SortOrder
      • StageDisposition
      • StageInput
      • StageInputTrace
      • StageName
      • StageNode
      • StageOutput
      • StageRelation
      • StageResult
      • StageTrace
      • Standing
      • StandingShape
      • Subscription
      • SubscriptionOverride
      • SystemAccessDetails
      • TeamCreateRequest
      • TeamDetail
      • TeamInvitation
      • TeamMemberDetail
      • TeamMemberRow
      • TeamMemberSource
      • TeamRole
      • TeamRow
      • TeamUpdateRequest
      • Territory
      • TerritoryKind
      • TerritoryOverview
      • UnbindTeamOutcome
      • UnshareContextOutcome
      • VaultConfig
      • ViaEntry
      • WideArm
      • WideHit
  • Temper /api/query — the compositional read surface
    • Run a declared composition of situated acts.
    • Schemas
      • Composition
      • OutcomeDeclaration
      • ReturnSpec
      • Intention
      • StageNode
      • ActInvocation
      • CombineNode
      • StageInput
      • StageRelation
      • Disclosure
      • QueryResponse
      • StageResult
      • StageOutput
      • ScoreKind
      • Scoring
      • ResourceHit
      • CompositionTrace
      • StageTrace
      • InputSource
      • NarrowedBy
      • StageName
      • ActName
      • IdKind
      • IdSet
      • BoundTerm
      • Extent
      • StageDisposition
      • ActRefusal
      • PlanRefusal
      • RefusalReason
      • ActQuantity
      • QuantityScale
      • ResourceFilter
      • EdgeFilter
      • PropertyPredicate
      • PropertyOp
      • ResourceSection
      • EdgeKind
      • OrdOp
      • CogmapId
      • ContextId
      • IdProvenance
      • FacetPredicate
      • CombineOp
      • BlockId
      • MatchLocation
      • BodyStorage
      • ResourceId
      • IngestState
      • ManagedMeta
      • ProfileId
      • ResourceView
      • Polarity
      • ViaEntry
      • LensId
      • RegionId
      • CogmapRegionRow
      • RegionHit
      • StageInputTrace
      • Standing
      • ActorAuthority
      • Refusal
      • SystemAccessDetails
      • PlanRefusalDetails
      • ErrorDetails
      • ErrorDetail
      • ErrorBody
  1. playbooks

Self-host Temper with a SAML IdP

For operators — anyone running a self-hosted Temper deployment whose identity provider
speaks SAML 2.0. This covers native SAML single sign-on: Temper fronts your SAML Identity
Provider (IdP) with a minimal OAuth 2.0 Authorization Server (AS) built into Temper. Your SAML
IdP authenticates the user; the Temper AS mints a short-lived EdDSA-signed Temper JWT that
temper-api trusts.
By the end you will have a Temper instance in Temper-AS mode — your SAML IdP authenticating
users, the built-in AS minting tokens both surfaces validate, CLI and (optionally) UI login
flowing through SAML, MCP clients connected, and IdP groups mapped to Temper teams and roles.
Use this when your organization's IdP speaks SAML 2.0 (e.g. Okta, Entra ID, PingFederate,
Shibboleth) and you want a native SP integration rather than an OIDC bridge. For the OIDC
bridge through Okta, see Self-host with Okta.
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.

Prerequisites#

A base Temper deployment. This page assumes you have completed
Self-hosting Temper through the non-auth steps. SAML replaces the
Auth0/OIDC auth provisioning with the Temper AS; everything else (Neon, Vercel, routing,
verification mechanics) is shared.
The auth-identity contract. A Temper instance validates tokens from exactly one issuer
against one audience. In SAML mode the Temper AS is that issuer, and the agreement rules
differ from an external-IdP install. Read Auth identity for
the contract and the rules the server enforces at boot.

How it works#

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)
1.
The CLI/UI starts an OAuth authorization-code + PKCE flow at /oauth/authorize.
2.
Temper redirects to your SAML IdP (SP-initiated).
3.
The IdP posts a signed assertion back to the AS's ACS endpoint.
4.
The AS validates the assertion, maps it to claims, and issues a one-time code.
5.
The client exchanges the code at /oauth/token for an EdDSA-signed access token.
temper-api validates it against the AS's published JWKS and just-in-time provisions the
profile.
The AS is SP-initiated only, supports a single active IdP per instance, and maps a
persistent NameID (or a configured stable-id attribute) to the token sub. A validly
signed assertion implies email_verified: true.

Quickstart with temper admin saml (recommended)#

Rather than hand-assembling the keys, environment, and SQL documented in the sections below,
an operator working from a repo checkout can generate them with the temper admin saml
command group. It is an emitter: it prints the exact env bundle and SQL (or writes them with
--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
remain the authoritative reference and the manual fallback.
1.
Provision keys + env + the IdP row — before anyone can log in:
Omit flags for interactive prompts, or add --no-interactive for a scripted run. It
generates the Ed25519 signing key (AS_SIGNING_KEY_PKCS8), AS_SIGNING_KID, and a strong
INTERNAL_RECONCILE_SECRET, then emits the full env bundle and the kb_saml_idp INSERT to
stdout. --env-out .env.saml writes the env (mode 0600 — it holds the private key); --apply
runs the SQL against $DATABASE_URL. Paste the env into both Vercel functions and deploy.
2.
Map IdP groups to teams — after the teams exist (see the org-bootstrap playbook):
Emits a kb_saml_group_mappings INSERT (add --apply to run it). --from-seen reads
kb_saml_seen_groups so you can add mappings reactively.
3.
Verify:
Confirms AS metadata/JWKS are reachable, that you resolve as a system admin (a missing
gating_team_slug otherwise fails silently with 403s), and — with --db — that exactly one
active kb_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 run map-group after those teams exist. See
that playbook's interleave note.

Register Temper as an SP with your IdP#

In your IdP, create a new SAML application ("SP") with:
SettingValue
ACS (Assertion Consumer Service) URLhttps://<instance>/oauth/saml/acs
SP Entity ID / Audiencea stable URI you choose, e.g. https://<instance>/saml/metadata
NameID formatpersistent (recommended) — becomes the token sub
Sign assertionsyes (both the <Response> and the <Assertion> must be signed)
Add two attribute statements to the assertion:
an email attribute (e.g. email) — becomes the token email.
a stable identifier attribute (e.g. uid) — the fallback for sub when the NameID is
not persistent.
Temper publishes its SP metadata at https://<instance>/oauth/saml/metadata for import into
IdPs that accept SP metadata XML.

Configure the active IdP (kb_saml_idp)#

The IdP configuration lives in the database, not env. Insert exactly one active row (flip
is_active to rotate to a replacement):

Map IdP groups to Temper teams and roles#

Temper reconciles team membership from SAML-asserted groups on each login. This is
eventual, not immediate: a user removed from a group keeps access until their session expires
and they next log in. For immediate deprovisioning use SCIM (not yet available).
Reconcile only ever manages source='idp' memberships. Native memberships (added in-app or
by join-request approval) and auto-join teams are never touched — if a user is already a native
member of a team, the IdP reconcile skips that team for them entirely.
Group provisioning is
purely authorization; it never creates, deletes, or deactivates the profile itself.
1.
Tell the SP which assertion attribute carries the group list:
Leave groups_attr NULL to keep authentication-only behavior (no membership changes).
2.
Map groups to (team, role). Teams must already exist. Two groups mapping to the same team
collapse to the strongest role (owner > maintainer > member > watcher):
The last row is "admin via group" — it makes members of temper-admins owners of the gating
team. Note: the first admin still requires the SQL bootstrap step; SAML does not
bootstrap the system.
Unmapped asserted groups are ignored for provisioning, but they ARE recorded in
kb_saml_seen_groups (with first/last-seen) so you can discover what the IdP actually sends and
add mappings reactively — the mapping table never needs to be pre-populated:
Removal semantics. Removing a group from the assertion revokes the corresponding idp
membership on the next login. The distinction matters: if the assertion omits the groups
attribute entirely
(e.g. a transient IdP misconfiguration), reconcile is skipped and no
memberships are revoked; only an assertion that carries the attribute with no values ("in no
mapped groups now") revokes all of the user's idp memberships.

Environment variables#

Authorization Server (temper-cloud / the API deployment)#

Generate an Ed25519 signing key:
VariableValueNotes
AS_ISSUERhttps://<instance>The AS issuer URL. Setting this flips the instance into AS mode (it serves AS metadata/JWKS instead of Auth0).
AS_AUDIENCEhttps://<instance>/apiAudience 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_KIDe.g. as-2026-07Key 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_SECONDS900 (default)Access-token lifetime.
AS_REFRESH_TTL_SECONDS2592000 (default, 30d)Refresh-token lifetime.
AS_CLIENTS registers the exact redirect URIs each client may use (exact string match — this
is the control that prevents authorization-code exfiltration):
{
  "temper-cli": ["https://<instance>/api/auth/cli-callback"],
  "temper-ui":  ["https://<app-url>/auth/callback"]
}

temper-api#

Point temper-api at the AS as its single issuer:
VariableValue
JWKS_URLhttps://<instance>/oauth/jwks
AUTH_ISSUERthe same value as AS_ISSUER
AUTH_AUDIENCEthe same value as AS_AUDIENCE
AUTH_PROVIDER_NAMEsaml:<idp-key> (e.g. saml:acme-okta) — namespaces the JIT auth link. Max 32 chars.

Group provisioning#

These gate the internal reconcile call the AS makes to temper-api before minting a token. Set
INTERNAL_RECONCILE_SECRET to the same value on both the AS and the temper-api deployment
(they share a Vercel project env). If unset, the reconcile endpoint is disabled and no group
provisioning occurs (authentication still works).
Why a shared secret rather than an origin/IP allow-list, and the endpoint's bounded blast
radius, are explained in The SAML reconcile channel.
VariableWherePurpose
INTERNAL_RECONCILE_SECRETAS + API (shared)Shared secret gating the internal reconcile call. Same value on both. Unset ⇒ reconcile disabled, no group provisioning.
INTERNAL_RECONCILE_URLASFull URL of the temper-api /internal/saml/reconcile endpoint the AS calls before minting (e.g. https://<your-api-origin>/internal/saml/reconcile).

Slack account link (optional)#

These are needed only if you run the @temper mention agent. Leave all three unset and the
link endpoint is disabled — everything else works unchanged.
The flow makes temper an OAuth client of your own AS: it authorizes as
SLACK_LINK_CLIENT_ID and is redirected back to <PUBLIC_BASE_URL>/api/auth/slack/callback.
That redirect URI must therefore appear in that client's AS_CLIENTS entry — AS_CLIENTS is
an exact-match allowlist and unset is fail-closed, so an unregistered URI is refused by the AS
before temper sees the request.
VariableWherePurpose
SLACK_LINK_CLIENT_IDAPIThe 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_SECRETAPI + mention agent (shared)Shared secret gating the agent's /internal/slack/link-state call. Same value on both. Unset ⇒ link endpoint disabled.
PUBLIC_BASE_URLAPIThis instance's public origin (e.g. https://<instance>). The callback redirect_uri is derived from it.

Configure the CLI#

Run the guided setup and pick the Temper AS (native SAML) provider:
Or non-interactively:
This writes an [[auth.providers]] block with provider = "temper-as",
client_id = "temper-cli", authorize_url = https://<instance>/oauth/authorize,
token_url = https://<instance>/oauth/token, and
callback_url = https://<instance>/api/auth/cli-callback. The existing PKCE + loopback login
flow is issuer-agnostic — no other CLI change is needed. temper auth login then authenticates
through SAML.

Configure the UI (optional)#

The SvelteKit UI logs in against the AS as a public PKCE client (no client secret). Set:
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
in AS_CLIENTS. OIDC_PUBLIC_CLIENT=true is required for this secret-less path — without it,
the UI fails fast at startup rather than silently running with no client secret.
Single-origin ACS and CSRF (SAML only). In the single-origin topology, temper-ui
reverse-proxies /oauth to 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 form POST from
the IdP's origin
— a legitimately cross-origin POST that SvelteKit's built-in origin CSRF
check would otherwise reject with 403 Cross-site POST form submissions are forbidden before
the proxy could forward it. temper-ui handles this: the built-in check is disabled and the
equivalent origin guard is re-implemented in hooks.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 an Origin match. No operator action is
required
— this is built in. The OIDC path does not hit this at all: its callback
completes as a GET redirect, which CSRF does not touch.

Connect MCP clients (Claude Desktop / Claude Code)#

The remote MCP server (/mcp) is served from the same deployment and authenticates against
the Temper AS via OAuth. MCP clients discover the AS through RFC 8414 metadata and require
dynamic client registration (DCR)
— current Claude Code/Desktop ignore a client-side
client_id and fall back to DCR regardless. The AS metadata advertises a
registration_endpoint (/oauth/register), a thin proxy that echoes a pre-registered static
client_id; it never persists client-supplied redirect URIs, so the /oauth/authorize
open-redirect protection is unweakened. To enable it on a SAML instance:
1.
Set MCP_CLIENT_ID on the deployment to a client id that is also a key in
AS_CLIENTS
(e.g. temper-mcp). Without MCP_CLIENT_ID, /oauth/register returns 503 temporarily_unavailable.
2.
Add that client to 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"
  ]
}
The two HTTPS callbacks serve the Claude Desktop / web connector (fixed callbacks, exact
match). The loopback entry serves Claude Code: it runs a local callback server on an
ephemeral port, so the allowlist matches loopback redirect URIs by scheme + path with the
port ignored — a port-less http://127.0.0.1/callback entry matches
http://127.0.0.1:<random>/callback. Loopback matching is confined to the local machine and
normalizes across loopback hosts (127.0.0.1, localhost, [::1]), so one loopback entry
covers whichever the client sends. Non-loopback (HTTPS) redirect URIs are always
exact-match.
3.
Audience/issuer alignment is enforced at boot — you do not have to remember it. The AS
mints iss = AS_ISSUER, aud = AS_AUDIENCE; both surfaces validate against the
instance's one AUTH_AUDIENCE. If these disagree, the process refuses to start and
names the offending variable:
AS mintsThe instance validatesEnforced requirement
AS_ISSUERAUTH_ISSUERAS_ISSUER == AUTH_ISSUER
AS_AUDIENCEAUTH_AUDIENCEAS_AUDIENCE == AUTH_AUDIENCE
(its JWKS)JWKS_URLJWKS_URL == $AS_ISSUER/oauth/jwks
MCP_AUDIENCE is optional. temper-mcp reads the instance's one audience, same as
temper-api. If you do set MCP_AUDIENCE, it must equal AUTH_AUDIENCE; it is an
assertion, not a second value.
These already hold on any correctly-configured SAML instance — a divergent audience means
no AS-minted token ever verifies. Temper names the rule and fails fast rather than leaving
you to discover it as a 401.
Then add the server to Claude Code and authenticate:

Verify#

1.
temper auth login → a browser opens to your IdP; after SAML login the CLI receives a
token.
2.
temper auth status (or any authenticated command) succeeds.
3.
A kb_profiles row and a kb_profile_auth_links row (with
auth_provider = saml:<idp-key>) are created for the user on first login.

Deactivating an account (authn control)#

Team membership is authorization; it does not control whether an account can log in. To
stop an account from authenticating at all — regardless of what the IdP asserts — soft-delete
the profile:
A deactivated profile is rejected by the API auth middleware (401) even with a valid token.
This never deletes the profile or its history, and it is independent of SAML group provisioning
(re-activating restores access). Reconcile/deprovisioning of a team never deactivates a
profile.
is_active is enforced by the shared authorization seam, so both surfaces — temper-api
and temper-mcp — reject a deactivated profile identically. See
The Trust Boundary.

Running it as the applier#

The saml-setup.sh script automates the temper admin saml sequence above from a declarative
profile (saml-profile.yaml) — it loops provision / map-group / verify. Run from a repo
checkout:
Dry-run — prints the commands without executing:
saml-setup.sh --profile saml-profile.yaml --dry-run
Emit only (default) — env bundle + kb_saml_idp SQL, safe to run anytime, no DB writes:
saml-setup.sh --profile saml-profile.yaml
Apply — writes the kb_saml_idp row, applies group mappings, verifies against the live
DB (needs DATABASE_URL + psql; run post-migrate, and after the org-bootstrap teams exist):
DATABASE_URL=postgresql://… saml-setup.sh --profile saml-profile.yaml --apply-db
It needs yq to read the profile and temper on PATH. Emit-by-default and idempotency are
inherited from the underlying temper admin saml commands, not reimplemented by the script. It
is the SAML sibling of system-bootstrap.sh (kept separate so that script stays usable for
Auth0/Okta-OIDC installs) — see enterprise install for how the two
appliers interleave across the full install timeline.

Limitations#

Reconcile-on-login only. Profile attributes refresh when the user logs in; there is no
live deprovisioning. A user removed at the IdP retains access until their token expires
(bounded by AS_ACCESS_TTL_SECONDS) and cannot re-login. Automated deprovisioning (SCIM) is
not yet available.
Single active IdP per instance, SP-initiated flows only.
Single issuer per instance: an instance is either an AS/SAML instance (AS_ISSUER set)
or an Auth0/OIDC instance, not both.
Role/team mapping from SAML attributes is available once groups are configured (see
Map IdP groups to Temper teams and roles).

Further reading#

The auth-identity contract this mode configures:
Auth identity.
The security model this playbook implements:
The Trust Boundary.
The internal reconcile channel between the AS and the API:
The SAML reconcile channel.
The base deployment this page builds on:
Self-hosting Temper.
The full enterprise install sequence:
enterprise install.
What the architecture fixes vs. what a deployment chooses:
temperkb.io/operating/deployment.
Modified at 2026-08-20 02:05:41
Previous
Self-host Temper with Okta
Next
Send traces to an OTLP backend
Built with