Adds a per-alias `auth_mode` on model definitions so a model backend can authenticate to an Entra-fronted gateway with a per-request minted token instead of one shared static API key, letting the gateway attribute calls to the actual user or to the app as a machine identity. - `static` (default, unchanged) sends the stored `api_key`. - `entra_obo` mints a per-user On-Behalf-Of token for `obo_audience` from the caller's captured refresh credential. - `entra_app` mints an app-identity token via the client-credentials grant, and covers userless turns that OBO cannot. Reuses the existing OBO grant legs, refresh-token rotation CAS, cluster advisory lock and the `mcp_user_tokens` mint-cache, keyed under synthetic `__model_obo__:<audience>` / `__model_app__:<audience>` rows. The token binds at the call site through `client.with_options(api_key=...)` so each SDK emits it on its own auth path rather than through header injection. Migration 068 adds `auth_mode` and `obo_audience`. Both are additive and existing rows default to `static`, so behaviour is unchanged unless an alias opts in. Operator controls: `model.auth_audience_allowlist` is an exact-match allow-list that gates which audiences may be configured and denies all by default, and changing a mode or audience requires `admin.mcp`. `model.auth_fail_closed` decides whether a failed mint may fall back to an explicitly configured static key. A delegated call with no user, or a dynamic alias with no real static key, always refuses. Two changes here apply regardless of whether any alias opts in: - Storage and app state are now wired into the console MCP client manager. This fixes per-user `oauth_user` / `oauth_obo` dispatch for coordinator-hosted sessions, which previously raised `RuntimeError` on first call because `set_app_state` was only ever called on the node. - Unattended watch restores and `--resume` resolve the persisted workstream owner instead of constructing the session under an empty principal. A workstream with no owner is now a permanent refusal rather than an anonymous, auto-approved run.
18 KiB
MCP OAuth — per-user authorization for MCP servers
Turnstone supports per-(user, MCP server) OAuth 2.1 + PKCE delegation so each Turnstone user authorizes a remote MCP server with their own identity, rather than sharing a single bearer token across the deployment. This is the right shape for MCP servers that expose user-specific data (a personal CRM, an email inbox, a calendar) and for MCP servers that want per-user audit attribution.
Per-user OAuth is opt-in per mcp_servers row. Local-auth Turnstone installs with no oauth_user rows exercise zero new code paths — the entire feature is dark by default.
Note
: This is a separate authorization layer from Turnstone's own user authentication. A user who logs into Turnstone with a local username + password can still authorize a per-server OAuth MCP server. OIDC SSO and per-server OAuth are orthogonal.
When to use which auth_type
The MCP server admin form exposes three authorization modes ("Multitenant Authorization"):
auth_type |
What it means | When to use |
|---|---|---|
none |
No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
static |
One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
oauth_user (recommended for user-data servers) |
Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
oauth_obo (sign-in passthrough) |
Each user's Turnstone org sign-in (OIDC) mints a per-server access token on demand — no separate per-server consent. One captured credential per user covers every oauth_obo server. |
Enterprise deployments where the identity provider governs access (Entra, Keycloak) and you want zero per-user connect clicks. See the dedicated section below. |
Switching auth_type away from oauth_user / oauth_obo deletes that server's per-user rows (consents / minted cache) — see the transition table below. Switching back later starts clean: users re-consent (or re-mint) on next use. The admin bulk-revoke / flush cache affordance clears rows without an auth-type change.
Prerequisites for auth_type=oauth_user
-
Encryption key. Tokens are stored encrypted with Fernet. Set
[security] mcp_token_encryption_keyinconfig.toml(Turnstone won't start with anoauth_userrow configured but no key installed). Rotate viaMultiFernet— add the new key first, then later remove the old one once all rows have been re-encrypted. -
MCP server publishes RFC 9728 PRM and RFC 8414 AS metadata or you configure the AS URL override on the server row. PKCE S256 is mandatory; Turnstone refuses to connect to authorization servers that don't advertise
code_challenge_methods_supported: ["S256"]. -
OAuth client registration. Two paths:
- Pre-registered (most common): you create an OAuth client at the authorization server (manually, via admin console, or via Terraform), then paste the
client_id/client_secretinto the Turnstone admin form. - Dynamic client registration (RFC 7591): if the AS supports it and you select that mode in the admin form, Turnstone registers a client at first use and persists the
client_idautomatically.
- Pre-registered (most common): you create an OAuth client at the authorization server (manually, via admin console, or via Terraform), then paste the
-
Redirect URI registered at the authorization server:
https://your-turnstone-host/v1/api/mcp/oauth/callback.
Configuration
Per-server fields (admin UI)
| Field | Required | Description |
|---|---|---|
| Server URL | Yes | The MCP server's streamable-http base URL. |
| Multitenant Authorization | Yes | none / static / oauth_user (recommended). |
| Authorization Server URL | No | Override for RFC 9728 PRM discovery. Set when your AS endpoint differs from the MCP server URL (e.g., corporate AS protecting a third-party MCP). When unset, Turnstone falls back to PRM discovery against the MCP server itself. |
| Client Registration | Yes (oauth_user) | preregistered or dynamic. |
| Client ID | Yes (preregistered) | OAuth 2.0 client ID. Stored unencrypted. |
| Client Secret | Optional (write-only) | OAuth 2.0 client secret (confidential client). Encrypted at rest. Written but never re-read by the API; field stays masked. |
| Scopes | No | Space-separated default scope set requested at the authorize endpoint. Per-tool step-up may union additional scopes from a server's insufficient_scope response. |
| Audience | No | RFC 8707 resource= parameter sent on every authorize and token request. Defaults to the MCP server URL when unset. Validate against the aud claim in returned JWT tokens. |
Encryption key
[security]
mcp_token_encryption_key = "base64-fernet-key"
# For rotation, list the keys in priority order — first is used for new
# writes, all are tried for reads.
# mcp_token_encryption_keys = ["new-key", "old-key"]
Keep this in config.toml rather than environment variables. An in-process LLM with shell-tool access can read the server's environment via env / os.environ and exfiltrate any secret stored there; secrets in config.toml are only loaded into the server at startup and never re-read on a tool-driven path, so a prompt-injection attack against the agent cannot reach them.
auth_type=oauth_obo — single-credential sign-in passthrough
Where oauth_user makes each user complete a separate browser consent per MCP server, oauth_obo reuses the user's Turnstone org sign-in (OIDC). Turnstone captures one refresh credential per user at login and, on each tool call, mints a short-lived access token scoped to that server's audience. There is no per-server connect step, and one credential covers every oauth_obo server. This is the right shape when your identity provider already governs who may reach each backend (an Entra tenant with Entra-protected MCP servers; a Keycloak realm with token exchange).
Access is governed downstream by the IdP: a user can only mint a token for a server their delegated permissions allow. Removing that grant at the IdP cuts the user off regardless of their Turnstone state.
Deployment configuration ([oidc] in config.toml)
oauth_obo requires OIDC SSO to be configured (it is the credential source), plus:
[oidc]
# ... your existing issuer / client_id / client_secret ...
capture_user_credential = true # persist the IdP refresh token at login
obo_grant_profile = "entra" # "entra" | "rfc8693" — how tokens are minted
capture_user_credential(defaultfalse): when enabled, Turnstone appendsoffline_accessto the login scopes and stores the returned refresh token, encrypted with the same[security] mcp_token_encryption_keyasoauth_usertokens. The encryption key is required — Turnstone refuses to start with anoauth_oborow (or capture enabled) and no key.obo_grant_profilepicks the mint mechanism (the IdP determines which one is valid; this is deployment-wide, not per-server):entra— redeems the user's refresh token directly for a token scoped to<audience>/.default.oauth_scopeson the server row is not used (the admin form rejects it under this profile).rfc8693— a refresh grant for a subject token, then an RFC 8693 token exchange for the server audience. Per-serveroauth_scopesare sent on the exchange (some IdPs require the audience scope explicitly).
Adding an oauth_obo server
In the admin MCP form, choose Sign-in passthrough and set Audience (required — the downstream resource the token is minted for, e.g. api://<app-id> on Entra or the client id on Keycloak). The client-id / secret / registration fields do not apply and are hidden.
oauth_obo servers are accepted only when OIDC sign-in is configured and enabled and [oidc] obo_grant_profile is a valid profile — the write is rejected otherwise, since a row that can never mint would surface to users as a permanent "please retry" that never heals.
Identity-provider setup
Entra (obo_grant_profile = "entra"):
- Turnstone's app registration must hold delegated permissions to each MCP server's exposed API, with admin consent granted (or the MCP app listed in Turnstone's
preAuthorizedApplications). - Set the server row's Audience to the MCP app's Application ID URI (
api://<guid>). - Gotcha (verified): admin-consent issued immediately after creating the app/service principal can silently skip a not-yet-propagated resource — the only symptom is
AADSTS65001at mint time. Verify the delegated grant landed (az ad app permission list-grants/ the portal's API permissions blade shows Granted), or grant it explicitly per resource. A missing grant surfaces in Turnstone as a re-login prompt on the affected server (same rail as a revoked credential), and themcp_server.oauth.obo_mint_rejectedlog line carries the rawAADSTS…text.
Keycloak / RFC 8693 (obo_grant_profile = "rfc8693"):
- Enable standard token exchange on Turnstone's client.
- Grant the audience: add an audience client scope for each MCP client and attach it to Turnstone's client (optional scopes must be requested — set the server row's Scopes to that scope, or the exchange returns "Requested audience not available").
- Set the server row's Audience to the downstream client id.
Revocation & custody
The captured credential is a single per-user secret that can mint for every oauth_obo server, so treat it like any long-lived credential:
- Cut off one user: unlink their OIDC identity in the admin console (Users → OIDC identities → delete). This revokes the captured credential and purges their minted cache rows, so future mints fail and cached tokens are dropped. (Warmed in-memory sessions on server nodes self-expire at the access-token TTL; there is no cross-node per-user session-kill.) Removing the user's access at the IdP is the authoritative cut-off.
- The same unlink also purges that user's synthetic
__model_obo__:gateway-token rows and requests eviction from every registered host's in-process mint memo. Sharedentra_appmodel tokens live under the__app__pseudo-user and are intentionally not user-deprovisioned; revoking the app credential prevents new mints, while a cached app bearer lasts untilexpires_at. - Flush a server's minted tokens (e.g. after narrowing its audience): the server row's flush cache action drops all users' cached tokens for that server. This is not a revocation — users re-mint on next use from their still-valid sign-in. It is surfaced honestly (audit
mcp_server.oauth.obo_cache_flushed, responseeffect: cache_flush_remints) so it is never mistaken for cutting access. - Per-server revocation in the
oauth_usersense does not exist foroauth_obo— the credential is issuer-scoped and IdP-governed. Revoke at the IdP.
Interim for Entra without OBO: if you don't want host-side minting, admin consent +
preAuthorizedApplicationson each MCP app registration removes the second consent prompt for the plainoauth_userflow too (a tenant-config change, no Turnstone code). Tracked in issue #682. It does not remove the per-server connect clicks or per-(user, server) token custody — that is whatoauth_obois for.
Lifecycle
-
First tool call for a user against an
oauth_userMCP server: pool dispatch finds no stored token, returnsmcp_consent_requiredto the agent. Dashboard renders an inline "Connect" action card. -
User clicks Connect: opens
/v1/api/mcp/oauth/start?server=<name>in a popup. Browser redirects through the AS authorize endpoint, user grants consent, AS redirects back to/v1/api/mcp/oauth/callback. Turnstone exchanges code → tokens via PKCE, validates audience, encrypts, persists inmcp_user_tokens, redirects user back to the originating URL. -
Subsequent tool calls by the same user against the same server reuse the persisted token via the per-(user, server) session pool. Tokens auto-refresh via the refresh-token grant when expired; failed refresh emits
mcp_consent_requiredto drive re-consent. -
Step-up scope: when a tool call hits
403withWWW-Authenticate: error="insufficient_scope", Turnstone emitsmcp_insufficient_scopewith the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>so the union of original + new scopes flows into the AS authorize request. -
User revoke (settings modal):
DELETE /v1/api/mcp/oauth/connections/{server_name}runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).oauth_oboservers and synthetic model-auth rows are excluded: their rows are mint caches, not consents — deleting one only forces a re-mint — so the connections list hides them and the endpoint refuses them with409(revocation for sign-in passthrough happens at the identity layer: unlink the identity or revoke at the IdP). -
Admin bulk-revoke (Phase 9):
POST /v1/api/admin/mcp-servers/{name}/bulk-revokedrops every user's token for the server. Upstream RFC 7009 revoke is intentionally not attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
Admin status indicators
The MCP Servers admin tab shows per-server status pills (Phase 9):
- Consented users count — distinct users with a non-expired token for this server. Surfaced as a
bulk-revoke (N)button when ≥1; clicking it opens a confirmation dialog. Hidden when 0. - Last refresh — timestamp + outcome (
ok/error:ClassName) of the most recent manual or auto-reconnect refresh. Per node. Absent until at least one refresh has occurred (renders as "never" in the admin UI).
Additional indicators (circuit-breaker state, encryption-key mismatch) are exposed via get_server_status on the API but do not yet have a dedicated admin pill — operators see them today via the per-server status text + error tooltip and in audit logs. A future phase may surface these as discrete pills.
Auth-type transitions
| From | To | What happens |
|---|---|---|
none / static → oauth_user |
— | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
oauth_user → none / static |
— | Existing mcp_user_tokens rows are deleted: the tokens are bound to the auth model + URL active at consent time, and rows left behind could silently rebind if a row with the old name/URL reappears. Switching back to oauth_user later starts clean — users re-consent on next use. This is not reversible; the AS-side grants are untouched (revoke upstream via the AS if needed). |
OAuth client_id or client_secret rotated |
— | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
oauth_user ↔ oauth_obo |
— | The per-user rows are deleted on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). oauth_audience and oauth_scopes mean different things in each model (a resource indicator vs. an IdP app identifier; AS-consent scopes vs. an rfc8693 exchange scope), so on a flip they never carry — each is taken from the request for the target model or set NULL. The admin console clears these fields when you change the auth type, so re-enter the correct values for the new mode; via the API, supply them explicitly (a flip into oauth_obo with no oauth_audience is rejected, and a non-empty oauth_scopes under the entra profile is rejected since that leg pins <audience>/.default). |
oauth_obo → none / static |
— | Minted cache rows are deleted. |
oauth_obo audience, URL, or oauth_scopes changed |
— | Minted cache rows are deleted (tokens are bound to the audience/URL/scopes at mint time), forcing a fresh mint — so an audience or scope narrowing takes effect immediately, not at token expiry. |
Every transition that changes what a stored row means deletes the rows outright — a stale consent or minted token must never be served under new semantics. There is no orphan-and-reactivate path.
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
mcp_consent_required even after consenting |
Token persistence failed, or refresh-token rejected by AS | Check audit log for mcp_server.oauth.persist_failed or mcp_server.oauth.token_revoked. Re-consent via settings modal. |
mcp_token_undecryptable_key_unknown |
Encryption key rotated without keeping the previous key in the keyring | Add the previous key back to mcp_token_encryption_keys until all rows have been re-encrypted, then drop. |
mcp_oauth_url_insecure |
MCP server URL is http:// (not https://) on a non-loopback host |
Use https://. Per-user bearers must not transit cleartext. |
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
oauth_obo: every tool call fails, log shows obo_misconfigured |
Server row has no Audience, or obo_grant_profile is unset/unknown |
Set the Audience on the server row; set [oidc] obo_grant_profile to entra or rfc8693. |
oauth_obo: obo_mint_rejected with AADSTS65001 |
Turnstone's app lacks the (admin-consented) delegated grant to this MCP app — often admin consent that didn't propagate | Grant + admin-consent the delegated permission for this resource; verify it shows Granted. See the Entra gotcha above. |
oauth_obo: "Sign in to Turnstone again" on one server |
Captured credential missing/rejected, or a Conditional Access challenge | User re-logs into Turnstone (re-captures the credential). If it persists, check the IdP grant / CA policy. |
oauth_obo: tools don't appear at all for a user |
User has not signed in since capture_user_credential was enabled (no credential captured) |
User logs out and back in via OIDC so the refresh credential is captured. |
See also: docs/operations/mcp-oauth-headless.md for the cron / channel-driven run caveat.