1. guides
temper
  • Temper documentation
  • 2026-07-06-cogmap-telos-differentiation
  • handoffs
  • plans
    • archived
    • evidence
  • reviews
  • specs
  • spikes
  • agents
  • auth
    • Temper auth & security
    • The authorization seam (`temper-services::auth`)
    • Cognitive-map & resource authoring authorization
    • JWT verification
    • The issuer / resource-server boundary & the machine-token contract
    • The internal reconcile channel
  • code-reviews
  • cognitive-maps
  • decisions
  • development
  • guides
    • Building a cognitive map from a large corpus
    • Using temper from Claude Desktop (and claude.ai)
    • Cloud Agent Development Guide
    • Ingesting a corpus into a context
    • Development setup
    • Drain operator queries (TraceQL)
    • Enterprise Install — Ground Up
    • Operator guide: read-only GitHub credential via a BYO App + Vercel Connect
    • Operator guide: provisioning a GitHub connection in temper
    • Installing Temper
    • Delivering L0 Kernel Cogmap Content
    • Machine Credentials
    • OpenTelemetry Setup
    • Operational Memory
    • Bootstrapping a Temper org
    • Releasing Temper
    • Self-Hosting Temper with Okta
    • Self-Hosting Temper with a SAML IdP
    • Self-Hosting Temper
    • The Slack Integration — identity, credentials, and what revocation actually means
    • Operator guide: `@temper` on Slack — end-to-end setup
    • Bootstrapping a team's self-cognition cognitive map
    • Working with Teams
    • Integrating Ruby with temper-rb
    • Deploying an Eve agent to Vercel (the steward and the citation auditor)
    • WS6 Collapsed-Schema Dev Environment
  • research
  • security
  • specs
  • doors
    • Building against Temper
    • Running Temper
    • Using Temper
  • 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
  • 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
  1. guides

Self-Hosting Temper with a SAML IdP

This guide covers native SAML single sign-on for a self-hosted Temper instance. Unlike
self-hosting with Okta (which uses Okta's OIDC endpoints directly), this
option 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.
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.
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.

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 numbered 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 runbook):
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 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 run map-group after those teams exist. See
that runbook's interleave note.

1. 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.

2. 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):

3. Map IdP groups to Temper teams/roles (Phase 2)#

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.

4. 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 (Phase 2)#

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 ../auth/reconcile-channel.md.
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.

5. 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 login then authenticates through SAML.

6. 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.

7. 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 (upstream anthropics/claude-code#26675, #38102, #68853). 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 no longer has an audience of its own — it 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. (It used to fall back to AUTH_AUDIENCE
when unset, which is why it read like a separate knob. It never usefully was one.)
These already held on any correctly-configured SAML instance — a divergent audience would mean no
AS-minted token ever verified. Temper now names the rule and fails fast instead of leaving you to
discover it as a 401.
Then add the server to Claude Code and authenticate:

8. Verify#

1.
temper login → a browser opens to your IdP; after SAML login the CLI receives a token.
2.
temper whoami (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.

9. 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 (Level 1), so both surfaces —
temper-api and temper-mcp — reject a deactivated profile identically. See
../auth/authorization-seam.md.

Running it as the applier#

saml-setup.sh automates the temper admin saml
sequence above from a declarative profile — it loops provision / map-group / verify from
saml-profile.yaml:
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-OAuth installs) — see
enterprise-install.md for how the two appliers interleave
across the full install timeline.

Limitations (Phase 1)#

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 Phase 3.
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 Phase 2.
Modified at 2026-08-19 17:59:52
Previous
Self-Hosting Temper with Okta
Next
Self-Hosting Temper
Built with