Turnstone supports OpenID Connect for federated authentication, allowing users to log in with their existing corporate identity provider instead of managing a separate password. OIDC is opt-in: when configured, the login screen shows a "Continue with SSO" button alongside the existing username/password form. When not configured, the login experience is unchanged.
Any OIDC-compliant provider works: Google, Okta, Azure AD, Keycloak,
Auth0, OneLogin, and others that publish a
.well-known/openid-configuration discovery document.
- A registered confidential OIDC client at your identity provider
- The client's redirect URI must include:
https://your-turnstone-host/v1/api/auth/oidc/callback - A local admin user must exist in Turnstone (complete the initial setup wizard before enabling OIDC)
Configure OIDC in the [oidc] section of the shared config.toml used by the
console and server nodes. Docker deployments can prepare and mount this file
through the installer's optional OAuth/SSO step or the
shared config overlay. For other
deployments, select the file with TURNSTONE_CONFIG on each process.
Edit the existing [oidc] section in place, preserving other sections and
the token encryption key. Replace the provider placeholders before enabling
SSO; create a local admin first.
[oidc]
issuer = "https://identity.example.com"
client_id = "your-client-id"
client_secret = "your-client-secret"
redirect_base = "https://app.example.com"
provider_name = "SSO"
password_enabled = true
capture_user_credential = falseSSO login alone does not need refresh-credential capture. For delegation, follow MCP sign-in passthrough or model gateway credentials. Both reuse this file and the shared token encryption key. After changing bootstrap config, restart every consumer. For Docker, restart after TOML edits and recreate services after changing Compose mounts or environment variables; see the shared config instructions.
Environment variables remain supported and take precedence over TOML when
both are set. Keep the client secret in the private TOML file rather than
inherited process environments. Setting TURNSTONE_CONFIG selects that file;
the installer does not copy OIDC variables from .env into the services.
| Variable | Required | Default | Description |
|---|---|---|---|
TURNSTONE_OIDC_ISSUER |
Yes | — | Issuer URL (e.g. https://accounts.google.com). Must serve /.well-known/openid-configuration. |
TURNSTONE_OIDC_CLIENT_ID |
Yes | — | OAuth 2.0 client ID from your provider |
TURNSTONE_OIDC_CLIENT_SECRET |
Yes | — | OAuth 2.0 client secret (confidential client) |
TURNSTONE_OIDC_SCOPES |
No | openid email profile |
Space-separated OAuth scopes to request |
TURNSTONE_OIDC_PROVIDER_NAME |
No | SSO |
Display name for the login button (e.g. "Google", "Okta") |
TURNSTONE_OIDC_ROLE_CLAIM |
No | — | ID token claim containing role/group values (see Role Mapping) |
TURNSTONE_OIDC_ROLE_MAP |
No | — | Mapping from claim values to Turnstone role IDs (see Role Mapping) |
TURNSTONE_OIDC_PASSWORD_ENABLED |
No | true |
Set to false to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
TURNSTONE_OIDC_REDIRECT_BASE |
Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. https://app.example.com). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS |
No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See Cross-host endpoints. |
TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK |
No | false |
Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See Self-hosted and internal IdPs. |
TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL |
No | false |
Capture an encrypted refresh credential at SSO login for delegated MCP/model access; requires the shared token encryption key. |
TURNSTONE_OIDC_OBO_GRANT_PROFILE |
No | entra |
Token minting profile supported by the IdP: entra or rfc8693. See MCP delegation. |
All four required fields — issuer, client ID, client secret, and
TURNSTONE_OIDC_REDIRECT_BASE — must be set. If any are missing OIDC
is disabled at startup (an error is logged when only redirect_base
is missing) and the login screen shows only the password form.
[oidc] redirect_base (or TURNSTONE_OIDC_REDIRECT_BASE) pins the redirect
URI sent to the identity provider to a known externally-visible origin. Set
it to the public origin of your Turnstone deployment:
[oidc]
redirect_base = "https://app.example.com"The resulting callback URL will be
https://app.example.com/v1/api/auth/oidc/callback — register this as the
authorized redirect URI in your identity provider.
Per-user MCP authorization uses the same origin with a different path,
/v1/api/mcp/oauth/callback, registered with the MCP provider. Setting only
redirect_base supports that flow without enabling SSO.
OIDC will refuse to start when this setting is unset. There is no
Host-header fallback: a permissive reverse proxy or direct backend access
would otherwise let an attacker spoof Host and steer the IdP redirect
to a callback origin they control.
By default, every endpoint in the IdP discovery document
(token_endpoint, jwks_uri, userinfo_endpoint) must share the
issuer's (scheme, host, port). This prevents a hostile or compromised
IdP from redirecting the token-exchange POST (which carries
client_secret) to an arbitrary host, and prevents JWKS fetches from
being aimed at internal services.
A few public IdPs legitimately split endpoints across hostnames. Google and Microsoft Entra ID are the canonical examples:
| IdP | Issuer host | Cross-host endpoint(s) |
|---|---|---|
accounts.google.com |
oauth2.googleapis.com, www.googleapis.com, openidconnect.googleapis.com |
|
| Microsoft Entra | login.microsoftonline.com |
graph.microsoft.com (userinfo) |
Both sets are built in — operators using https://accounts.google.com or
https://login.microsoftonline.com/<tenant>/v2.0 need no extra
configuration. (Entra's discovery document advertises userinfo_endpoint
on graph.microsoft.com, distinct from the issuer host.)
For other IdPs whose discovery document references a non-issuer host, extend the allow-list explicitly:
[oidc]
trusted_endpoint_hosts = ["token.example.com", "keys.example.com"]The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts — this knob only relaxes the same-origin check, not the security gates. Each entry is a hostname (no scheme, no path).
By default Turnstone refuses an issuer whose hostname resolves to a private or internal address:
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
This is SSRF hardening, not a licensing or product restriction: the OIDC
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
and refusing non-public destinations keeps a mistyped or maliciously
steered issuer from aiming those fetches at internal services. For a
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
opt in explicitly in config.toml:
[oidc]
allow_private_network = trueor via TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true (the env var wins
when both are set).
The opt-in admits private-range (RFC 1918), unique-local, site-local,
CGNAT (100.64/10, where overlay VPNs commonly assign hosts), and
loopback addresses. Link-local, multicast, reserved ranges and known
cloud-metadata endpoints stay refused even with the opt-in — no
legitimate IdP lives there. An address is judged by what it actually
reaches, so an IPv6 transition address (NAT64, 6to4, Teredo) wrapping
an internal IPv4 is treated as an internal destination. A local-use
NAT64 wrapper in 64:ff9b:1::/48 is refused when any valid layout
decodes into a refused range, even if its actual IPv4 destination is
public; the private-network opt-in cannot restore access. The standard
64:ff9b::/96 DNS64 prefix still admits public IPv4 destinations. The
HTTPS requirement and the same-origin endpoint checks are unaffected.
This knob only affects the login-flow IdP configured here. OAuth endpoints
advertised by remote MCP servers use the separate runtime setting
mcp.oauth_allow_private_network,
which also defaults to the strict public-address rule.
For Docker, use the same shared config
for the console and every node. Enable credential capture for users who
will use delegated models, and have them sign in again. Application identity
(entra_app) uses the registration's own permissions and needs no user
credential capture. Configure model auth modes and audiences in the admin
Models tab, following the runtime settings linked below.
The same OIDC registration can authenticate model gateways. A model definition
with auth_mode = "entra_obo" (Entra grant profile) or auth_mode = "rfc8693_obo" (RFC 8693 token-exchange profile) redeems the driving user's
captured credential for its exact obo_audience; auth_mode = "entra_app"
uses the registration's client ID and secret with Entra client credentials.
All three bind the result through the provider SDK's native credential option
rather than injecting an override header. The grant mode is never inferred:
missing user context or a failed OBO mint cannot switch a delegated definition
to client credentials.
Each dynamic mode pairs with the grant profile whose dialect it names:
entra_obo and entra_app require obo_grant_profile = "entra";
rfc8693_obo requires obo_grant_profile = "rfc8693". The pairing is
enforced when a write chooses a (auth_mode, obo_audience) pair — a same-pair
edit of a row saved before the pairing rule keeps working — and at runtime a
mismatched legacy row refuses to mint with cause=grant_profile_mismatch and
no IdP traffic. RFC 8693 client-credentials is not implemented.
The delegated modes need the shared token encryption key,
a credential captured for the driving user, and delegated/admin-consented
permission to the audience.
rfc8693_obo additionally carries obo_scopes, the space-separated scope
list its exchange leg requests: exchange-capable IdPs that gate audiences
behind optional scopes refuse the exchange without it ("Requested audience not
available"), which is why the scope-less Entra-named mode could never mint on
that profile (issue #955). Scopes are stored shape-checked only — whether a
value satisfies the IdP stays the IdP's call at mint time. Turning
capture_user_credential off later stops new captures but does not
invalidate credentials already stored, so existing users keep minting.
entra_app requires a confidential-client secret. Configure the permitted
resource IDs in the runtime setting model.auth_audience_allowlist before
saving dynamic model definitions. De-listing an audience later blocks every
write that would arm or re-aim a definition at it, but does not stop aliases
already configured from minting — disabling the row (the admin.models disarm
lever) is what stops minting. See
Settings for permissions, failure
policy, and lane identity rules.
An unrecognised obo_grant_profile is warned about at startup and rejected
at the write choke points: configuring an oauth_obo MCP server or a dynamic
model alias returns a 400 that echoes the configured value, so the typo is the
diagnosis. At runtime an unknown profile never mints — the mint legs resolve by
exact name; the full cause detail is logged once per audience, and every
affected call still logs its per-turn fallback or refusal naming the alias,
the target audience, and the last recorded cause (cause= — for example
unsupported_grant_profile or oidc_not_enabled) — so a pre-existing row
degrades loudly, with the reason visible mid-incident even after the
once-per-process line has rotated out of retained logs, rather than silently
swapping per-user attribution for the shared static key.
The token keyring is shared by the console and every node that reads the database. See Shared OAuth storage for bootstrap settings, startup requirements, key rotation, and the effect of identity unlink on delegated credentials and app-identity caches.
Merge these fields into your existing [oidc] section, retaining its
redirect_base and any delegation settings. Role mappings below are examples;
choose the group/role claims and Turnstone permissions appropriate to your
organization.
- Go to Google Cloud Console > APIs & Services > Credentials
- Click Create Credentials > OAuth 2.0 Client ID
- Application type: Web application
- Add authorized redirect URI:
https://your-turnstone-host/v1/api/auth/oidc/callback - Copy the Client ID and Client secret
[oidc]
issuer = "https://accounts.google.com"
client_id = "123456789.apps.googleusercontent.com"
client_secret = "GOCSPX-..."
provider_name = "Google"- In the Okta Admin Console, go to Applications > Create App Integration
- Sign-in method: OIDC - OpenID Connect
- Application type: Web Application
- Add sign-in redirect URI:
https://your-turnstone-host/v1/api/auth/oidc/callback - Note the Issuer (your Okta domain, e.g.
https://dev-123456.okta.com)
[oidc]
issuer = "https://dev-123456.okta.com"
client_id = "0oaXXXXXXXXXXXXX"
client_secret = "..."
provider_name = "Okta"
role_claim = "groups"
role_map = {admin = "builtin-admin", everyone = "builtin-operator"}- In the Azure Portal, go to App registrations > New registration
- Redirect URI: Web >
https://your-turnstone-host/v1/api/auth/oidc/callback - Under Certificates & secrets, create a new Client secret and copy the value immediately
- The issuer URL is
https://login.microsoftonline.com/{tenant-id}/v2.0
[oidc]
issuer = "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0"
client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
client_secret = "..."
provider_name = "Azure AD"
role_claim = "roles"
role_map = {Admin = "builtin-admin", User = "builtin-operator"}- In the Keycloak Admin Console, select your Realm
- Go to Clients > Create client
- Client type: OpenID Connect
- Set Client authentication to On (confidential)
- Add valid redirect URI:
https://your-turnstone-host/v1/api/auth/oidc/callback - The issuer URL is
https://keycloak.example.com/realms/your-realm
[oidc]
issuer = "https://keycloak.example.com/realms/your-realm"
client_id = "turnstone"
client_secret = "..."
provider_name = "Keycloak"
role_claim = "realm_access.roles"
role_map = {admin = "builtin-admin", operator = "builtin-operator"}OIDC role mapping assigns Turnstone roles to users based on claims in the
ID token. This is optional — without it, OIDC users are provisioned with
the builtin-viewer role (read-only access) by default.
Set [oidc] role_claim to the name of the claim in the ID token that
contains the user's group or role memberships. Then map claim values to
Turnstone role IDs in role_map:
[oidc]
role_claim = "groups"
[oidc.role_map]
admin = "builtin-admin"
engineering = "builtin-operator"
viewer = "builtin-viewer"The environment equivalent uses TURNSTONE_OIDC_ROLE_CLAIM=groups and a
comma-separated TURNSTONE_OIDC_ROLE_MAP such as
admin:builtin-admin,engineering:builtin-operator,viewer:builtin-viewer.
- Synced on every login: roles are added when new claim values appear,
and OIDC-assigned roles are revoked when the corresponding claim value
is no longer present. Roles assigned manually (or by other sources) are
never touched — only roles with
assigned_by="oidc"are subject to revocation. - List or string: the claim value can be a JSON array
(
["admin", "engineering"]) or a single string ("admin"). Both are handled correctly. - Unknown values: claim values not present in the role map are silently ignored.
- Missing roles: if the role map references a Turnstone role ID that does not exist in the database, the assignment is skipped (no error).
- Evaluated on every login: roles are checked and applied each time the user authenticates via OIDC, so new group memberships are picked up on the next login.
Role assignments record an assigned_by value that controls how the
sync logic treats them. OIDC-driven flows use two distinct markers:
oidc— set by claim-driven role mapping; revoked automatically on the next login when the corresponding claim value is no longer present.oidc-default— applied to brand-new OIDC users who have no claim-mapped roles, as a safety net so they still getbuiltin-vieweraccess on first login. Survives subsequent logins regardless of claim contents and is never revoked byapply_role_mapping.
| Role ID | Permissions |
|---|---|
builtin-admin |
All permissions |
builtin-operator |
read, write, workstreams.create, workstreams.close |
builtin-viewer |
read |
When a user logs in via OIDC for the first time, Turnstone automatically creates a local user account:
- The OIDC identity (
issuer+subclaim) is stored in theoidc_identitiestable and linked to the new user - The username is derived from the
preferred_usernameclaim, falling back to the email local part, with deduplication if needed - The display name comes from the
nameclaim, falling back topreferred_usernameor email - The user's password hash is set to a sentinel value (
!oidc) — OIDC users cannot log in with a password
On subsequent logins, the existing user is matched by (issuer, sub) and
the last_login timestamp is updated. Role mapping is re-evaluated on
every login.
To enforce OIDC for all logins and hide the password form, set:
[oidc]
password_enabled = falseIn this mode the login screen shows only the "Continue with SSO" button. The password form, token toggle, and sign-in button are all hidden. All username/password logins are blocked at the API level, including admin accounts.
The first admin account must be created via the setup wizard (with a password) before OIDC is enabled. The setup wizard always works regardless of this setting because it is only available when zero users exist in the database.
API token login (POST /v1/api/auth/login with a ts_ token)
continues to work regardless of this setting. JWTs and API tokens are
the supported authentication methods. OIDC-only mode affects
password-based authentication only.
Both the server and console support OIDC login. The flow is identical:
- The browser fetches
GET /v1/api/auth/statusat page load - If the response includes
oidc_enabled: true, the login screen shows a "Continue with {provider_name}" button - Clicking the button navigates to
GET /v1/api/auth/oidc/authorize - Turnstone generates a state token, nonce, and PKCE verifier, stores them in the database, and redirects the browser to the identity provider's authorization endpoint
- The user authenticates at the identity provider
- The IdP redirects back to
GET /v1/api/auth/oidc/callback?code=...&state=... - Turnstone validates the state, exchanges the authorization code for tokens using the PKCE verifier, validates the ID token against the provider's JWKS public keys, provisions or matches the user, and issues a Turnstone JWT
- The browser is redirected to
/?oidc_success=1with the JWT set in anHttpOnlysession cookie - The browser JavaScript detects the
oidc_successquery parameter, strips it from the URL, hides the login overlay, and callsonLoginSuccess()to initialize the application
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/api/auth/oidc/authorize |
Public | Redirects to identity provider |
| GET | /v1/api/auth/oidc/callback |
Public | Handles IdP callback, issues JWT |
Both endpoints are public (no authentication required) because they are part of the login flow itself.
When OIDC is enabled, GET /v1/api/auth/status includes additional
fields:
{
"auth_enabled": true,
"has_users": true,
"setup_required": false,
"oidc_enabled": true,
"oidc_provider_name": "Google",
"password_enabled": true
}Migration 018 creates two tables:
CREATE TABLE oidc_identities (
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
user_id TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL,
last_login TEXT NOT NULL,
PRIMARY KEY (issuer, subject)
);
CREATE INDEX idx_oidc_identities_user_id ON oidc_identities(user_id);
CREATE TABLE oidc_pending_states (
state TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
audience TEXT NOT NULL,
created_at TEXT NOT NULL
);The oidc_identities table links an OIDC subject (identified by
issuer + subject) to a Turnstone user_id. A single user can have
multiple OIDC identities (e.g. from different providers).
The oidc_pending_states table stores authorization flow state for
callback validation. Entries are automatically cleaned up after 5 minutes.
- Authorization Code Flow with PKCE: the recommended OAuth 2.0 flow for web applications. PKCE prevents authorization code interception attacks even without a client secret (though the client secret is still used for additional security).
- ID token validation: all tokens are validated using the provider's JWKS public keys (RS256 or ES256). The signature, issuer, audience, and expiry are all checked.
- State parameter: a cryptographically random state token prevents CSRF attacks on the callback endpoint. The state is stored server-side and verified on callback.
- Nonce: a random nonce is included in the authorization request and verified in the ID token to prevent replay attacks.
- Client secret: never leaves the server — it is only used in the server-to-IdP token exchange, not exposed to the browser.
- OIDC users cannot use password login: the sentinel password hash
(
!oidc) ensuresverify_password()always rejects password attempts for OIDC-provisioned users. - Rate limiting: the callback endpoint shares the login rate limiter (5 attempts per 5-minute window per IP).
- State TTL: pending authorization states expire after 5 minutes. Expired states are lazily cleaned up on each callback.
- Setup guard: OIDC login requires at least one local admin user to exist. This ensures the initial admin account is always created via the setup wizard with a password, not hijacked by an external identity.
All four required [oidc] fields must be set: issuer, client_id,
client_secret, and redirect_base. Check that none are empty or
whitespace-only, that TURNSTONE_CONFIG selects the intended file, and that
no environment overrides replace its values.
This error is logged when the three credential fields are set but
redirect_base is missing from both TOML and the environment. OIDC is
disabled at startup to prevent Host-header-derived redirect URI spoofing.
Set [oidc] redirect_base to your service's externally-visible origin
(e.g. https://app.example.com) and restart the consumers. See
Redirect base for the rationale.
The IdP discovery document points token_endpoint, jwks_uri, or
userinfo_endpoint at a hostname that doesn't share the issuer's
origin. If the IdP is legitimate, add the additional hostname(s) to
[oidc] trusted_endpoint_hosts. Google and Entra endpoints are allow-listed
automatically; see Cross-host endpoints.
The authorization flow must complete within 5 minutes. If the user takes too long at the identity provider, the pending state expires. Try again.
OIDC login is blocked until at least one local admin user exists. Complete the setup wizard first (navigate to the Turnstone URL and follow the prompts to create an admin user with a password).
Check that the issuer URL is reachable from the Turnstone server and
serves a valid /.well-known/openid-configuration document. The server
logs the discovery attempt at startup:
OIDC discovery failed for https://your-issuer.example.com: ...
OIDC is automatically disabled when discovery fails. Restart the server after fixing the connectivity issue.
The redirect URI configured at the identity provider must exactly match
https://your-host/v1/api/auth/oidc/callback. Common issues:
- Scheme mismatch: the redirect uses
https://— make sure TLS is configured or a reverse proxy sets theX-Forwarded-Protoheader - Port mismatch: if running on a non-standard port, include it in the redirect URI
- Path mismatch: the path must include the
/v1API version prefix
Check that:
[oidc] role_claimmatches the exact claim name in the ID token (case-sensitive)[oidc] role_mapmaps the correct claim values to valid Turnstone role IDs- The roles referenced in the map exist in the database (check the admin panel > Roles tab)
- The identity provider is configured to include the claim in the ID token (some providers require explicit scope or claim configuration)