Files
turnstone/docs/oidc.md
T
Patrick Buckley 33ace975d2 feat(models): default-deny governance and admin UI for per-alias backend auth
Follow-up to the per-alias Entra OBO/app-identity backend auth: the
console write path now applies default-deny field classification, the
admin shelf gains full backend-auth support, and the session/registry
rebind machinery is hardened for config changes landing under live
sessions.

Console write gate:
- Default-deny classification: any non-neutral change to a row that is
  or becomes dynamic requires admin.mcp plus validation; the provably
  auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a
  live-schema classification test forces every future column to be
  classified. The derivation is a pure function (_derive_auth_gate)
  with unit-pinned exclusivity invariants.
- Two-tier validation mirroring the MCP oauth_obo validator: the row
  tier (audience allow-list) runs on every gated write; the posture
  tier (OIDC configured, token store present) runs on pair changes and
  on enable-arming.
- Pure-disable carve-out: disabling a dynamic row is de-escalation and
  is never blocked — admin.models suffices and validation is skipped,
  including for rows with corrupt or skewed stored values.
- Capabilities are compared canonically (key order, integral floats),
  the audience compare normalizes both sides, and staging an audience
  on a static row is refused on both write twins.
- Calibrate writes the capabilities column under an enforced
  confinement invariant with a compare-and-swap persist.

Admin shelf:
- Backend-auth section with a per-open constraints fetch
  (GET /model-definitions/auth-constraints: audience allow-list, grant
  profile, dynamic modes), datalist audience suggestions,
  server-defined modes preserved on round-trip, and permission-aware
  visibility built on cache-skew-safe helpers shared through auth.js.
- Refused live-registry swaps surface as an amber registry_warning on
  the write, delete, reload, and calibrate responses; audit rows carry
  auth_gated / auth_disarmed markers visible in the audit view.

Registry and sessions:
- The encryption-key requirement for dynamic auth is enforced inside
  ModelRegistry.reload() itself — nodes refuse with 503 and the
  console records coord_registry_error — and reload bumps the
  generation before the map swap so a racing reader can never pair a
  stale generation with new maps.
- resolve()/resolve_binding() return the generation from inside the
  registry lock; sessions rebind per send on generation change with
  atomic client/provider/config commits, fallback-first handling of
  removed or unconstructable aliases, and judge/limiter resets only
  when the binding actually changed.
- Mint refusals record per-user causes surfaced in the per-turn
  heartbeat logs; misconfiguration warnings are deduplicated with
  bounded state.

Verification: 10417 tests (99 added on this branch), a 71-scenario
browser harness over the real admin shelf, and a live rfc8693
token-exchange e2e run (MCP legs verified end to end; the model-leg
scope gap is tracked as #955 under a narrow known-gap signature).

Closes #950.
2026-08-03 20:11:28 -07:00

23 KiB

OpenID Connect (OIDC) Single Sign-On

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.


Prerequisites

  1. A registered confidential OIDC client at your identity provider
  2. The client's redirect URI must include: https://your-turnstone-host/v1/api/auth/oidc/callback
  3. A local admin user must exist in Turnstone (complete the initial setup wizard before enabling OIDC)

Configuration

OIDC is configured via environment variables (preferred) or the [oidc] section of config.toml. Environment variables take precedence when both are set.

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.

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.

Redirect base (required)

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:

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

OIDC will refuse to start when this variable 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.

Cross-host endpoints

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)
Google 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:

TURNSTONE_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).

Self-hosted and internal IdPs

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 = true

or 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, CGNAT (100.64/10 — tailnets), and loopback addresses. Link-local, multicast, and reserved ranges stay refused even with the opt-in — cloud metadata services (169.254.169.254) live there, and no legitimate IdP does. 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 are untrusted input and are always held to the strict public-address rule.

Model gateway credentials

The same OIDC registration can authenticate model gateways. A model definition with auth_mode = "entra_obo" 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. Both 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 an entra_obo definition to client credentials.

entra_obo needs the MCP encryption key, a credential captured for the driving user, and delegated/admin-consented permission to the audience. It works under either grant profile, with one RFC 8693 caveat: the model mint sends no scope parameter (model definitions carry no per-row scopes, unlike MCP servers), so the IdP must grant the alias's audience to the app client by default; on Keycloak the exchange otherwise fails with "Requested audience not available" (see issue #955 for the tracked fix). Turning capture_user_credential off later stops new captures but does not invalidate credentials already stored, so existing users keep minting. entra_app requires obo_grant_profile = "entra" and a confidential-client secret; RFC 8693 client-credentials is not implemented. 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 [security] token encryption key is deployment-wide, not per-host: rows are encrypted with MultiFernet and carry no key id, so every host that reads them needs the same keyring. That includes the console, which mints for coordinator-hosted sessions. A node that needs the key and lacks it refuses to start; the console starts but withholds its coordinator subsystem and shows the key requirement as the remediation error instead of failing silently at call time.

config.toml alternative

[oidc]
issuer = "https://accounts.google.com"
client_id = "your-client-id"
client_secret = "your-client-secret"
scopes = "openid email profile"
provider_name = "Google"
role_claim = "groups"
password_enabled = true
redirect_base = "https://app.example.com"
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
allow_private_network = false

[oidc.role_map]
admin = "builtin-admin"
engineering = "builtin-operator"

Provider-Specific Setup

Google

  1. Go to Google Cloud Console > APIs & Services > Credentials
  2. Click Create Credentials > OAuth 2.0 Client ID
  3. Application type: Web application
  4. Add authorized redirect URI: https://your-turnstone-host/v1/api/auth/oidc/callback
  5. Copy the Client ID and Client secret
TURNSTONE_OIDC_ISSUER=https://accounts.google.com
TURNSTONE_OIDC_CLIENT_ID=123456789.apps.googleusercontent.com
TURNSTONE_OIDC_CLIENT_SECRET=GOCSPX-...
TURNSTONE_OIDC_PROVIDER_NAME=Google

Okta

  1. In the Okta Admin Console, go to Applications > Create App Integration
  2. Sign-in method: OIDC - OpenID Connect
  3. Application type: Web Application
  4. Add sign-in redirect URI: https://your-turnstone-host/v1/api/auth/oidc/callback
  5. Note the Issuer (your Okta domain, e.g. https://dev-123456.okta.com)
TURNSTONE_OIDC_ISSUER=https://dev-123456.okta.com
TURNSTONE_OIDC_CLIENT_ID=0oaXXXXXXXXXXXXX
TURNSTONE_OIDC_CLIENT_SECRET=...
TURNSTONE_OIDC_PROVIDER_NAME=Okta
TURNSTONE_OIDC_ROLE_CLAIM=groups
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,everyone:builtin-operator"

Azure AD (Entra ID)

  1. In the Azure Portal, go to App registrations > New registration
  2. Redirect URI: Web > https://your-turnstone-host/v1/api/auth/oidc/callback
  3. Under Certificates & secrets, create a new Client secret and copy the value immediately
  4. The issuer URL is https://login.microsoftonline.com/{tenant-id}/v2.0
TURNSTONE_OIDC_ISSUER=https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0
TURNSTONE_OIDC_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
TURNSTONE_OIDC_CLIENT_SECRET=...
TURNSTONE_OIDC_PROVIDER_NAME="Azure AD"
TURNSTONE_OIDC_ROLE_CLAIM=roles
TURNSTONE_OIDC_ROLE_MAP="Admin:builtin-admin,User:builtin-operator"

Keycloak

  1. In the Keycloak Admin Console, select your Realm
  2. Go to Clients > Create client
  3. Client type: OpenID Connect
  4. Set Client authentication to On (confidential)
  5. Add valid redirect URI: https://your-turnstone-host/v1/api/auth/oidc/callback
  6. The issuer URL is https://keycloak.example.com/realms/your-realm
TURNSTONE_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm
TURNSTONE_OIDC_CLIENT_ID=turnstone
TURNSTONE_OIDC_CLIENT_SECRET=...
TURNSTONE_OIDC_PROVIDER_NAME=Keycloak
TURNSTONE_OIDC_ROLE_CLAIM=realm_access.roles
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,operator:builtin-operator"

Role Mapping

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.

Configuration

Set TURNSTONE_OIDC_ROLE_CLAIM to the name of the claim in the ID token that contains the user's group or role memberships. Then set TURNSTONE_OIDC_ROLE_MAP to map claim values to Turnstone role IDs.

The role map is a comma-separated list of claim_value:turnstone_role pairs:

TURNSTONE_OIDC_ROLE_CLAIM=groups
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer:builtin-viewer"

Behavior

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

assigned_by markers

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 get builtin-viewer access on first login. Survives subsequent logins regardless of claim contents and is never revoked by apply_role_mapping.

Built-in Roles

Role ID Permissions
builtin-admin All permissions
builtin-operator read, write, workstreams.create, workstreams.close
builtin-viewer read

User Provisioning

When a user logs in via OIDC for the first time, Turnstone automatically creates a local user account:

  1. The OIDC identity (issuer + sub claim) is stored in the oidc_identities table and linked to the new user
  2. The username is derived from the preferred_username claim, falling back to the email local part, with deduplication if needed
  3. The display name comes from the name claim, falling back to preferred_username or email
  4. 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.


OIDC-Only Mode

To enforce OIDC for all logins and hide the password form, set:

TURNSTONE_OIDC_PASSWORD_ENABLED=false

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


Login Flow

Both the server and console support OIDC login. The flow is identical:

  1. The browser fetches GET /v1/api/auth/status at page load
  2. If the response includes oidc_enabled: true, the login screen shows a "Continue with {provider_name}" button
  3. Clicking the button navigates to GET /v1/api/auth/oidc/authorize
  4. 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
  5. The user authenticates at the identity provider
  6. The IdP redirects back to GET /v1/api/auth/oidc/callback?code=...&state=...
  7. 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
  8. The browser is redirected to /?oidc_success=1 with the JWT set in an HttpOnly session cookie
  9. The browser JavaScript detects the oidc_success query parameter, strips it from the URL, hides the login overlay, and calls onLoginSuccess() to initialize the application

API Endpoints

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.

Auth status response

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
}

Database Schema

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.


Security Notes

  • 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) ensures verify_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.

Troubleshooting

"OIDC not configured"

All four required environment variables must be set: TURNSTONE_OIDC_ISSUER, TURNSTONE_OIDC_CLIENT_ID, TURNSTONE_OIDC_CLIENT_SECRET, and TURNSTONE_OIDC_REDIRECT_BASE. Check that none are empty or whitespace-only.

"OIDC enabled but TURNSTONE_OIDC_REDIRECT_BASE is unset"

This error is logged when the three credential variables are set but TURNSTONE_OIDC_REDIRECT_BASE is missing. OIDC is disabled at startup to prevent Host-header-derived redirect URI spoofing. Set the variable to your service's externally-visible origin (e.g. https://app.example.com) and restart the server. See Redirect base for the rationale.

Discovery silently disables OIDC with "host does not match issuer"

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 TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS. Google is allow-listed automatically; see Cross-host endpoints.

"Login session expired"

The authorization flow must complete within 5 minutes. If the user takes too long at the identity provider, the pending state expires. Try again.

"Initial setup required"

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

Discovery fails at startup

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.

Redirect URI mismatch

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 the X-Forwarded-Proto header
  • Port mismatch: if running on a non-standard port, include it in the redirect URI
  • Path mismatch: the path must include the /v1 API version prefix

User not assigned expected roles

Check that:

  1. TURNSTONE_OIDC_ROLE_CLAIM matches the exact claim name in the ID token (case-sensitive)
  2. TURNSTONE_OIDC_ROLE_MAP maps the correct claim values to valid Turnstone role IDs
  3. The roles referenced in the map exist in the database (check the admin panel > Roles tab)
  4. The identity provider is configured to include the claim in the ID token (some providers require explicit scope or claim configuration)