Files
turnstone/docs/oidc.md
T
Patrick Buckley 62d2a0fe6a fix: remove non-auth support from bootstrap wizard (#274)
* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
2026-04-01 19:38:24 -07:00

15 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 No Externally-reachable origin for the OIDC redirect URI (e.g. https://app.example.com). Recommended when running behind a reverse proxy. When unset, derived from the request Host header.

OIDC is enabled when all three required fields (issuer, client ID, client secret) are non-empty. If any is missing, OIDC is silently disabled and the login screen shows only the password form.

Reverse Proxy / Load Balancer

When Turnstone runs behind a reverse proxy, the internal Host header may not match the externally-reachable URL. Set TURNSTONE_OIDC_REDIRECT_BASE to the public origin so the redirect URI sent to the identity provider is correct:

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.

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"

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

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 three required environment variables must be set: TURNSTONE_OIDC_ISSUER, TURNSTONE_OIDC_CLIENT_ID, and TURNSTONE_OIDC_CLIENT_SECRET. Check that none are empty or whitespace-only.

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