feat: OIDC SSO authentication with PKCE, auto-provisioning, and role … (#71)

* feat: OIDC SSO authentication with PKCE, auto-provisioning, and role mapping

Add OpenID Connect as a fourth authentication method, enabling single sign-on
via any OIDC provider (Okta, Azure AD, Google, Keycloak). Opt-in via env vars
(TURNSTONE_OIDC_ISSUER, CLIENT_ID, CLIENT_SECRET).

Security:
- Authorization Code Flow with PKCE (S256)
- State/nonce parameters with database-backed pending store (multi-node safe)
- JWKS signature validation with async fetch + key rotation retry
- Algorithm allowlist from JWKS key (not token header) prevents confusion
- Identity matching exclusively by (issuer, sub) — prevents account takeover
- password_enabled=false enforced server-side, not just UI
- Rate limiting on both authorize and callback endpoints
- OIDC users get "!oidc" password sentinel (bcrypt rejects naturally)
- ID token validated for iss, aud, exp, nonce

Features:
- Auto-provisioning with username deduplication on first login
- Claim-based role mapping with IdP demotion propagation (revokes stale roles)
- "Continue with [Provider]" SSO button on login page
- OIDC-only mode hides password form
- Setup wizard required before OIDC login (admin bootstrap)

Storage: migration 018 (oidc_identities + oidc_pending_states tables),
8 new protocol methods on both SQLite and PostgreSQL backends.
66 new tests (2273 total).

* fix: address PR #71 review feedback (18 items)

Bugs fixed:
- OIDC success redirect now fetches permissions via new /auth/whoami
  endpoint before completing login (fixes permission-gating in UI)
- Remove double decodeURIComponent on oidc_error (URLSearchParams
  already decodes; extra call throws on stray %)
- Authorize rate limiter returns redirect instead of JSON 429
  (endpoint reached via browser navigation, not fetch)
- Lazy JWKS fetch in callback when startup discovery failed (IdP
  recovery without restart)
- Startup exception handlers now log with exc_info=True
- PostgreSQL pop_oidc_pending_state uses DELETE...RETURNING for
  true atomicity (eliminates TOCTOU)

Behavior:
- New OIDC users without role mapping get builtin-viewer by default
  (assigned_by="oidc-default", not revoked by role sync)

Documentation fixes:
- Role mapping: sync semantics (add + revoke stale), not "additive only"
- PASSWORD_ENABLED=false blocks ALL password logins including admin
- Algorithm: asymmetric allowlist, not per-key derivation
- PlantUML diagram updated for role revocation

API spec fixes:
- Removed error_codes=[302] from callback (302 is success redirect)
- Added /auth/whoami to both server + console specs
- Regenerated TypeScript SDK OpenAPI snapshots (23 + 51 paths)

* fix: address PR #71 round 2 review feedback (10 items)

Rate limiting:
- Authorize endpoint now calls record() after check() so the rate
  limiter actually counts attempts (was a no-op before)

OIDC resilience:
- Split startup try/except: discovery failure disables OIDC, JWKS
  prefetch failure leaves OIDC enabled for lazy retry on first login
- JWKS unavailable message changed to "temporarily unavailable"
  (was misleadingly "not configured")
- create_oidc_pending_state raises on collision instead of OR IGNORE
  (prevents silent insert drop on state collision)
- SQLite pop_oidc_pending_state uses BEGIN IMMEDIATE for write lock
  (eliminates TOCTOU race)

Frontend:
- OIDC error display deferred 300ms so showLogin()'s async status
  fetch doesn't clear it via _switchMode → _clearError

API spec:
- OIDC authorize/callback endpoints now declare response_code=302
- Added AuthWhoamiResponse Pydantic model for /auth/whoami
- Regenerated TypeScript SDK OpenAPI snapshots

Documentation:
- Diagram: JWKS "cached at startup, refreshed on-demand" (was "hourly")
- Added TODO(tech-debt) comments on Host header redirect_uri sites
This commit is contained in:
Patrick Buckley
2026-03-15 03:44:18 -07:00
committed by GitHub
parent 68c991fbdd
commit 20df7b3034
25 changed files with 3564 additions and 15 deletions
+3 -1
View File
@@ -18,7 +18,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
- **Cluster dashboard** — real-time view of all nodes and workstreams, reverse proxy for server UIs
- **Intent validation** — an LLM judge evaluates every tool call before approval, presenting risk assessments and evidence-based recommendations so users can make informed decisions instead of blindly approving raw tool calls
- **Governance & compliance** — RBAC, tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs
- **Governance & compliance** — RBAC, OIDC SSO (Okta, Azure AD, Google, Keycloak), tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
Works with any OpenAI-compatible API (vLLM, llama.cpp, NVIDIA NIM) or Anthropic's native Messages API. Supports [MCP](https://modelcontextprotocol.io/) for external tool servers with native deferred tool loading on Anthropic and OpenAI APIs (BM25 fallback for local models).
@@ -136,12 +136,14 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
| [Governance Architecture](docs/diagrams/png/19-governance-architecture.png) | RBAC, policies, audit, usage enforcement flow |
| [WS Template Architecture](docs/diagrams/png/21-ws-template-architecture.png) | Workstream template application and lifecycle |
| [Judge Architecture](docs/diagrams/png/22-judge-architecture.png) | Intent validation two-tier evaluation pipeline |
| [OIDC Architecture](docs/diagrams/png/25-oidc-architecture.png) | OIDC SSO authorization code flow with PKCE |
### Governance
Turnstone includes a built-in governance layer for enterprise deployments — manage who can do what, which tools run unattended, and where every token goes.
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
+147
View File
@@ -0,0 +1,147 @@
@startuml
!theme plain
title Turnstone — OIDC Authorization Code Flow with PKCE
skinparam participant {
BackgroundColor<<browser>> #E8EAF6
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<idp>> #C8E6C9
}
participant "Browser" as Browser <<browser>>
participant "Turnstone\n(Server / Console)" as Server <<server>>
database "SQLite /\nPostgreSQL" as DB <<storage>>
participant "Identity Provider\n(IdP)" as IdP <<idp>>
== Page Load ==
Browser -> Server : GET /v1/api/auth/status
Server --> Browser : {oidc_enabled: true,\noidc_provider_name: "...",\npassword_enabled: true}
note right of Browser
Login screen renders
"Continue with {provider_name}"
button alongside password form.
If password_enabled=false,
only the SSO button is shown.
end note
== Authorization Request ==
Browser -> Server : GET /v1/api/auth/oidc/authorize
Server -> Server : Generate state (random)\nnonce (random)\nPKCE code_verifier + code_challenge
Server -> DB : create_oidc_pending_state(\nstate, nonce, code_verifier, audience)
note right of DB
Stored with created_at timestamp.
Expires after 5 minutes.
end note
Server --> Browser : 302 Redirect to IdP\nauthorization_endpoint
Browser -> IdP : GET /authorize?\nresponse_type=code&\nclient_id=...&\nredirect_uri=...&\nscope=openid email profile&\nstate=...&nonce=...&\ncode_challenge=...&\ncode_challenge_method=S256
== User Authentication (at IdP) ==
IdP -> Browser : Login page (if no\nexisting IdP session)
Browser -> IdP : User authenticates\n(username/password, MFA, etc.)
IdP --> Browser : 302 Redirect to callback\n?code=AUTH_CODE&state=STATE
== Callback Processing ==
Browser -> Server : GET /v1/api/auth/oidc/callback\n?code=AUTH_CODE&state=STATE
Server -> Server : Rate limit check\n(5 per 5min per IP)
Server -> DB : cleanup_expired_oidc_states(300)
note right of DB
Lazy cleanup of states
older than 5 minutes.
end note
Server -> DB : pop_oidc_pending_state(state)
DB --> Server : {nonce, code_verifier, audience}
note right of Server
Atomic fetch-and-delete.
Returns None if state is
expired or unknown.
end note
== Token Exchange ==
Server -> IdP : POST /token\ngrant_type=authorization_code&\ncode=AUTH_CODE&\nclient_id=...&\nclient_secret=...&\ncode_verifier=...&\nredirect_uri=...
note right of Server
Client secret + PKCE verifier
sent server-side only.
Never exposed to browser.
end note
IdP --> Server : {id_token: "eyJ...",\naccess_token: "..."}
== ID Token Validation ==
Server -> IdP : Fetch JWKS public keys\n(cached at startup, refreshed\non-demand when unknown kid\nencountered — key rotation)
Server -> Server : Validate ID token:\n1. Verify signature (RS256/ES256)\n2. Check iss == configured issuer\n3. Check aud == client_id\n4. Check exp (not expired)\n5. Verify nonce matches
== User Provisioning ==
Server -> DB : get_oidc_identity(issuer, sub)
alt Existing identity found
DB --> Server : {user_id, ...}
Server -> DB : update_oidc_identity_login()\nupdate last_login timestamp
Server -> DB : get_user(user_id)
DB --> Server : user record
else New user (first login)
Server -> Server : Derive username from\npreferred_username / email
Server -> DB : create_user(user_id, username,\ndisplay_name, "!oidc")
note right of DB
Password hash set to sentinel
value "!oidc" — not a valid
bcrypt hash, so password login
is always rejected.
end note
Server -> DB : create_oidc_identity(\nissuer, sub, user_id, email)
end
opt Role mapping configured
Server -> Server : Read role_claim from ID token\nMap values via role_map
Server -> DB : Sync roles: add new,\nrevoke stale OIDC-assigned,\npreserve manually assigned
end
== Issue Turnstone JWT ==
Server -> Server : Load user permissions\nDerive scopes from permissions
Server -> Server : Create JWT (HS256)\nsub: user_id\nscopes: read,write,...\nsrc: "oidc"\naud: turnstone-server\nexp: +24h
Server --> Browser : 302 Redirect to /?oidc_success=1\nSet-Cookie: session=JWT\n(HttpOnly, SameSite=Lax, Secure)
== Browser Success Detection ==
Browser -> Browser : Detect ?oidc_success=1\nStrip param from URL\n(history.replaceState)
Browser -> Browser : Hide login overlay\nCall onLoginSuccess()
note right of Browser
Browser is now authenticated.
JWT cookie sent on all
subsequent requests.
end note
== Error Paths ==
note over Browser, IdP
**Error handling:**
- IdP returns error param → redirect to /?oidc_error=...
- State missing/expired → redirect to /?oidc_error=Login+session+expired
- Token exchange fails → redirect to /?oidc_error=...
- ID token validation fails → redirect to /?oidc_error=...
- No admin user exists → redirect to /?oidc_error=Initial+setup+required
- Rate limit exceeded → redirect to /?oidc_error=Too+many+login+attempts
All errors are shown as toast messages on the login screen.
end note
@enduml
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259
size 309584
+412
View File
@@ -0,0 +1,412 @@
# 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](#role-mapping)) |
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#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 and config-file tokens still work. |
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.
### config.toml alternative
```toml
[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
[oidc.role_map]
admin = "builtin-admin"
engineering = "builtin-operator"
```
---
## Provider-Specific Setup
### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/) >
**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**
```bash
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`)
```bash
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`
```bash
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`
```bash
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:
```bash
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:
```bash
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) and
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
regardless of this setting. 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:
```json
{
"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:
```sql
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)
+101 -2
View File
@@ -54,7 +54,7 @@ Claims:
|-------|-------------|
| `sub` | User ID |
| `scopes` | Comma-separated scope list (`read,write,approve`) |
| `src` | Token source (`password`, `api_token`, `config`) |
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
| `iss` | Issuer — always `turnstone` |
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
| `iat` | Issued-at timestamp |
@@ -90,7 +90,8 @@ Scopes are hierarchical — higher scopes imply all lower ones.
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`,
`/api/auth/oidc/authorize`, `/api/auth/oidc/callback`.
### RBAC (Granular Permissions)
@@ -199,6 +200,94 @@ Response:
The response also sets an `HttpOnly` session cookie containing the JWT,
so the browser is immediately authenticated after setup completes.
### OIDC SSO (Single Sign-On)
Turnstone supports OIDC Authorization Code Flow with PKCE for
single sign-on with external identity providers (Okta, Azure AD,
Google, etc.). SSO is opt-in — enabled when the three required
environment variables are set. Users are auto-provisioned on first
login.
#### Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `TURNSTONE_OIDC_ISSUER` | Yes | OIDC issuer URL (e.g., `https://accounts.google.com`) |
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | Client ID from the identity provider |
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | Client secret (confidential client) |
| `TURNSTONE_OIDC_SCOPES` | No | OIDC scopes (default: `openid email profile`) |
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | Display name for the SSO button (default: `SSO`) |
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | Claim name in the ID token for role mapping (e.g., `groups`) |
| `TURNSTONE_OIDC_ROLE_MAP` | No | Comma-separated `claim_value:role_id` pairs (e.g., `admin:builtin-admin,eng:builtin-operator`) |
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | Set to `false` to hide password login and force SSO-only |
OIDC is enabled when all three required variables (`ISSUER`,
`CLIENT_ID`, `CLIENT_SECRET`) are set.
#### Login flow
1. User clicks "Continue with [Provider]" on the login page
2. `GET /v1/api/auth/oidc/authorize` generates state, nonce, and PKCE
challenge, stores them in the database, and redirects to the IdP
3. User authenticates at the identity provider
4. IdP redirects to `/v1/api/auth/oidc/callback` with `code` + `state`
5. Server validates state, exchanges the authorization code (with PKCE
verifier), and validates the ID token (JWKS signature, issuer,
audience, nonce)
6. Provisions or matches the user by `(issuer, sub)` — never by
username or email
7. Issues a JWT (`src: oidc`), sets a session cookie, and redirects to
the application
#### Security measures
- **PKCE (S256)** — prevents authorization code interception
- **State parameter** — one-time use, 5-minute TTL, database-backed
(multi-node safe)
- **Nonce** — prevents ID token replay
- **JWKS validation** — asymmetric algorithm allowlist (RS/ES/PS
256-512), HMAC excluded
- **Algorithm allowlist enforced** — the signing key is resolved from
the JWKS by ``kid``; PyJWK infers the key's algorithm from the JWKS
``alg``/``kty`` fields; the token header's ``alg`` must be in the
allowlist AND match the key type, preventing algorithm confusion
- **Identity matching by (issuer, sub) only** — prevents account
takeover via email or username reuse
- **`password_enabled=false` enforced server-side** — not just a UI
toggle
- **Rate limiting** on both authorize and callback endpoints
- **OIDC-provisioned users cannot password-login** — the password hash
is set to the `!oidc` sentinel, which never matches bcrypt verify
#### Role mapping
When `TURNSTONE_OIDC_ROLE_CLAIM` is set (e.g., `groups`), the server
reads that claim from the ID token and maps values to Turnstone roles
via `TURNSTONE_OIDC_ROLE_MAP`. Roles are synced on every login:
matching claim values are added, and stale OIDC-assigned roles are
revoked. Roles assigned manually (not by OIDC) are never touched.
If no role mapping is configured, OIDC users are provisioned with the
`builtin-viewer` role by default.
#### OIDC-only mode
Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
form on the login page and blocks password-based login at the API
level. The setup wizard always works regardless of this setting — the
first admin user is created with a password before OIDC is relevant.
API tokens and config-file tokens are unaffected by this setting.
#### Known limitations
- **No session revocation** — deprovisioned IdP users retain their JWT
until the 24-hour expiry
- **Single IdP** — configuration supports one issuer (the database
schema supports multiple for future expansion)
- **Redirect URI derived from Host header** — deployments behind
reverse proxies should set `TURNSTONE_OIDC_REDIRECT_BASE` to the
externally-reachable origin (tech debt — not yet implemented)
---
## Token Detection Order
@@ -483,3 +572,13 @@ and browsers enforce same-origin policy.
refresh, eliminating long-lived static tokens for inter-service auth.
- **Secret strength validation** — warning logged when JWT secret is
shorter than 32 characters.
- **OIDC PKCE enforcement** — S256 code challenge on every
authorization request prevents code interception in transit.
- **OIDC state/nonce in database** — one-time-use, TTL-bounded tokens
stored in the database, safe for multi-node deployments.
- **OIDC JWKS-only validation** — ID tokens are verified using the
provider's published JWKS keys with asymmetric algorithms only;
HMAC-based algorithms are rejected to prevent algorithm confusion.
- **OIDC identity binding by (issuer, sub)** — user matching uses the
immutable subject identifier, not email or username, preventing
account takeover via IdP attribute changes.
+94
View File
@@ -460,6 +460,85 @@
}
}
},
"/v1/api/auth/oidc/authorize": {
"get": {
"summary": "Redirect to OIDC provider for SSO login",
"operationId": "v1_api_auth_oidc_authorize_get",
"tags": [
"Auth"
],
"responses": {
"302": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/auth/oidc/callback": {
"get": {
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
"operationId": "v1_api_auth_oidc_callback_get",
"tags": [
"Auth"
],
"responses": {
"302": {
"description": "Success"
}
}
}
},
"/v1/api/auth/whoami": {
"get": {
"summary": "Return authenticated user info and permissions",
"operationId": "v1_api_auth_whoami_get",
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthWhoamiResponse"
}
}
}
},
"401": {
"description": "Error 401",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/users": {
"get": {
"summary": "List all users",
@@ -3179,6 +3258,21 @@
"setup_required": {
"title": "Setup Required",
"type": "boolean"
},
"oidc_enabled": {
"default": false,
"title": "Oidc Enabled",
"type": "boolean"
},
"oidc_provider_name": {
"default": "",
"title": "Oidc Provider Name",
"type": "string"
},
"password_enabled": {
"default": true,
"title": "Password Enabled",
"type": "boolean"
}
},
"required": [
+94
View File
@@ -623,6 +623,85 @@
}
}
},
"/v1/api/auth/oidc/authorize": {
"get": {
"summary": "Redirect to OIDC provider for SSO login",
"operationId": "v1_api_auth_oidc_authorize_get",
"tags": [
"Auth"
],
"responses": {
"302": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/auth/oidc/callback": {
"get": {
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
"operationId": "v1_api_auth_oidc_callback_get",
"tags": [
"Auth"
],
"responses": {
"302": {
"description": "Success"
}
}
}
},
"/v1/api/auth/whoami": {
"get": {
"summary": "Return authenticated user info and permissions",
"operationId": "v1_api_auth_whoami_get",
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthWhoamiResponse"
}
}
}
},
"401": {
"description": "Error 401",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/memories": {
"get": {
"summary": "List structured memories",
@@ -1017,6 +1096,21 @@
"setup_required": {
"title": "Setup Required",
"type": "boolean"
},
"oidc_enabled": {
"default": false,
"title": "Oidc Enabled",
"type": "boolean"
},
"oidc_provider_name": {
"default": "",
"title": "Oidc Provider Name",
"type": "string"
},
"password_enabled": {
"default": true,
"title": "Password Enabled",
"type": "boolean"
}
},
"required": [
+46
View File
@@ -1375,3 +1375,49 @@ class TestCorsConfigurable:
)
assert resp.headers.get("Access-Control-Allow-Origin") == "http://example.com"
client.close()
# ---------------------------------------------------------------------------
# TestVerifyPassword — OIDC sentinel handling
# ---------------------------------------------------------------------------
class TestVerifyPassword:
def test_valid_bcrypt_hash(self):
from turnstone.core.auth import hash_password, verify_password
hashed = hash_password("mypassword")
assert verify_password("mypassword", hashed) is True
assert verify_password("wrongpassword", hashed) is False
def test_oidc_sentinel_rejected(self):
from turnstone.core.auth import verify_password
# OIDC sentinel must return False, not crash with ValueError
assert verify_password("anypassword", "!oidc") is False
def test_non_bcrypt_hash_rejected(self):
from turnstone.core.auth import verify_password
assert verify_password("password", "not_a_hash") is False
assert verify_password("password", "") is False
def test_empty_password_against_oidc_sentinel(self):
from turnstone.core.auth import verify_password
assert verify_password("", "!oidc") is False
# ---------------------------------------------------------------------------
# TestOIDCPublicPaths — OIDC endpoints are public
# ---------------------------------------------------------------------------
class TestOIDCPublicPaths:
def test_oidc_authorize_is_public(self):
assert is_public_path("/api/auth/oidc/authorize") is True
assert is_public_path("/v1/api/auth/oidc/authorize") is True
def test_oidc_callback_is_public(self):
assert is_public_path("/api/auth/oidc/callback") is True
assert is_public_path("/v1/api/auth/oidc/callback") is True
+813
View File
@@ -0,0 +1,813 @@
"""Tests for turnstone.core.oidc — OIDC authentication support."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import urllib.parse
from unittest.mock import MagicMock, patch
import httpx
import jwt as pyjwt
import pytest
from turnstone.core.oidc import (
OIDCConfig,
OIDCError,
apply_role_mapping,
build_authorize_url,
discover_oidc,
generate_pkce_pair,
load_oidc_config,
provision_oidc_user,
validate_id_token,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(**overrides) -> OIDCConfig:
"""Build a test OIDCConfig with sensible defaults."""
defaults = {
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "my-client",
"client_secret": "my-secret",
"scopes": "openid email profile",
"provider_name": "TestIDP",
"role_claim": "",
"role_map": {},
"password_enabled": True,
"authorization_endpoint": "https://idp.example.com/authorize",
"token_endpoint": "https://idp.example.com/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
}
defaults.update(overrides)
return OIDCConfig(**defaults)
def _mock_storage(**overrides):
"""Build a MagicMock with sensible storage defaults."""
s = MagicMock()
s.get_oidc_identity.return_value = overrides.get("identity")
s.get_user.return_value = overrides.get("user")
s.get_user_by_username.return_value = overrides.get("user_by_username")
s.get_role.return_value = overrides.get("role")
return s
def _mock_async_client(mock_get):
"""Build a patched httpx.AsyncClient context manager for async tests."""
class _AsyncCtx:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def get(self, url):
return await mock_get(url)
return _AsyncCtx()
# ---------------------------------------------------------------------------
# Config Loading
# ---------------------------------------------------------------------------
class TestLoadOIDCConfig:
def test_load_oidc_config_from_env(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_SCOPES", "openid")
monkeypatch.setenv("TURNSTONE_OIDC_PROVIDER_NAME", "Okta")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.enabled is True
assert cfg.issuer == "https://auth.example.com"
assert cfg.client_id == "cid"
assert cfg.client_secret == "csecret"
assert cfg.scopes == "openid"
assert cfg.provider_name == "Okta"
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.enabled is False
def test_load_oidc_config_partial_env(self, monkeypatch):
"""Only issuer set, no client_id -> enabled=False."""
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_SECRET", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.enabled is False
assert cfg.issuer == "https://auth.example.com"
assert cfg.client_id == ""
def test_load_oidc_config_role_map_parsing(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_CLAIM", "roles")
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin,eng:builtin-operator")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.role_claim == "roles"
assert cfg.role_map == {"admin": "builtin-admin", "eng": "builtin-operator"}
def test_load_oidc_config_password_enabled_false(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "false")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.enabled is True
assert cfg.password_enabled is False
def test_load_oidc_config_password_enabled_true(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_PASSWORD_ENABLED", "true")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.password_enabled is True
def test_load_oidc_config_role_map_empty_entries(self, monkeypatch):
"""Role map with empty/whitespace entries should be silently skipped."""
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_ROLE_MAP", "admin:builtin-admin, , :, foo:")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.role_map == {"admin": "builtin-admin"}
def test_load_oidc_config_defaults(self, monkeypatch):
"""Defaults for scopes and provider_name when not set."""
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_PROVIDER_NAME", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.scopes == "openid email profile"
assert cfg.provider_name == "SSO"
# ---------------------------------------------------------------------------
# PKCE
# ---------------------------------------------------------------------------
class TestPKCE:
def test_generate_pkce_pair(self):
verifier, challenge = generate_pkce_pair()
# Verifier should be URL-safe base64
assert isinstance(verifier, str)
assert len(verifier) > 40 # 48 bytes -> ~64 chars
# Challenge should be base64url SHA-256 of verifier
expected_digest = hashlib.sha256(verifier.encode("ascii")).digest()
expected_challenge = base64.urlsafe_b64encode(expected_digest).rstrip(b"=").decode("ascii")
assert challenge == expected_challenge
def test_pkce_challenge_matches_verifier(self):
"""Manually compute challenge and verify it matches."""
verifier, challenge = generate_pkce_pair()
digest = hashlib.sha256(verifier.encode("ascii")).digest()
manual_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
assert challenge == manual_challenge
def test_pkce_pair_uniqueness(self):
"""Each call should produce a unique pair."""
v1, c1 = generate_pkce_pair()
v2, c2 = generate_pkce_pair()
assert v1 != v2
assert c1 != c2
# ---------------------------------------------------------------------------
# Authorization URL
# ---------------------------------------------------------------------------
class TestBuildAuthorizeURL:
def test_build_authorize_url_contains_required_params(self):
config = _make_config()
verifier, _ = generate_pkce_pair()
url = build_authorize_url(
config=config,
redirect_uri="https://app.example.com/callback",
state="test-state",
nonce="test-nonce",
code_verifier=verifier,
)
assert url.startswith("https://idp.example.com/authorize?")
assert "response_type=code" in url
assert "client_id=my-client" in url
assert "redirect_uri=" in url
assert "scope=openid" in url
assert "state=test-state" in url
assert "nonce=test-nonce" in url
assert "code_challenge=" in url
assert "code_challenge_method=S256" in url
def test_build_authorize_url_pkce(self):
"""code_challenge in URL should be correct S256 of the verifier."""
config = _make_config()
verifier, _ = generate_pkce_pair()
url = build_authorize_url(
config=config,
redirect_uri="https://app.example.com/callback",
state="s",
nonce="n",
code_verifier=verifier,
)
# Extract code_challenge from URL
parsed = urllib.parse.urlparse(url)
params = urllib.parse.parse_qs(parsed.query)
actual_challenge = params["code_challenge"][0]
# Compute expected challenge
digest = hashlib.sha256(verifier.encode("ascii")).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
assert actual_challenge == expected
def test_build_authorize_url_redirect_uri_encoded(self):
config = _make_config()
verifier, _ = generate_pkce_pair()
redirect = "https://app.example.com/callback?extra=1"
url = build_authorize_url(
config=config,
redirect_uri=redirect,
state="s",
nonce="n",
code_verifier=verifier,
)
# The redirect_uri should be URL-encoded
parsed = urllib.parse.urlparse(url)
params = urllib.parse.parse_qs(parsed.query)
assert params["redirect_uri"][0] == redirect
# ---------------------------------------------------------------------------
# ID Token Validation
# ---------------------------------------------------------------------------
class TestValidateIDToken:
_FAKE_JWKS = {"keys": [{"kid": "key1", "kty": "RSA", "n": "abc", "e": "AQAB"}]}
def test_validate_id_token_nonce_mismatch(self):
"""Nonce mismatch should raise OIDCError."""
config = _make_config()
mock_pyjwk = MagicMock()
mock_pyjwk.return_value.key = "fake-key"
with (
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
patch("jwt.PyJWK", mock_pyjwk),
patch("jwt.decode", return_value={"sub": "user1", "nonce": "wrong-nonce"}),
pytest.raises(OIDCError, match="nonce mismatch"),
):
validate_id_token(
raw_token="fake.jwt.token",
jwks_data=self._FAKE_JWKS,
config=config,
nonce="expected-nonce",
)
def test_validate_id_token_success(self):
"""Successful validation returns decoded claims."""
config = _make_config()
mock_pyjwk = MagicMock()
mock_pyjwk.return_value.key = "fake-key"
expected_claims = {
"sub": "user1",
"email": "user@example.com",
"nonce": "test-nonce",
}
with (
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
patch("jwt.PyJWK", mock_pyjwk),
patch("jwt.decode", return_value=expected_claims) as mock_decode,
):
claims = validate_id_token(
raw_token="fake.jwt.token",
jwks_data=self._FAKE_JWKS,
config=config,
nonce="test-nonce",
)
assert claims == expected_claims
mock_decode.assert_called_once_with(
"fake.jwt.token",
"fake-key",
algorithms=[
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
],
audience="my-client",
issuer="https://idp.example.com",
)
def test_validate_id_token_kid_not_found(self):
"""Unknown kid raises OIDCError with descriptive message."""
config = _make_config()
jwks_data = {"keys": [{"kid": "other-key", "kty": "RSA"}]}
with (
patch("jwt.get_unverified_header", return_value={"kid": "unknown", "alg": "RS256"}),
pytest.raises(OIDCError, match="not found in JWKS"),
):
validate_id_token(
raw_token="bad.token",
jwks_data=jwks_data,
config=config,
nonce="n",
)
def test_validate_id_token_invalid_jwt(self):
"""Invalid JWT raises OIDCError."""
config = _make_config()
mock_pyjwk = MagicMock()
mock_pyjwk.return_value.key = "fake-key"
with (
patch("jwt.get_unverified_header", return_value={"kid": "key1", "alg": "RS256"}),
patch("jwt.PyJWK", mock_pyjwk := MagicMock(return_value=MagicMock(key="fake-key"))),
patch("jwt.decode", side_effect=pyjwt.InvalidTokenError("expired")),
pytest.raises(OIDCError, match="ID token validation failed"),
):
validate_id_token(
raw_token="expired.token",
jwks_data=self._FAKE_JWKS,
config=config,
nonce="n",
)
def test_validate_id_token_invalid_header(self):
"""Malformed token header raises OIDCError."""
config = _make_config()
with (
patch("jwt.get_unverified_header", side_effect=pyjwt.DecodeError("bad header")),
pytest.raises(OIDCError, match="Invalid ID token header"),
):
validate_id_token(
raw_token="garbage",
jwks_data=self._FAKE_JWKS,
config=config,
nonce="n",
)
# ---------------------------------------------------------------------------
# User Provisioning
# ---------------------------------------------------------------------------
class TestProvisionOIDCUser:
def test_provision_oidc_user_existing(self):
"""Existing identity -> returns existing user, updates last_login."""
config = _make_config()
existing_user = {
"user_id": "u1",
"username": "alice",
"display_name": "Alice",
"password_hash": "!oidc",
}
existing_identity = {
"issuer": "https://idp.example.com",
"subject": "sub-123",
"user_id": "u1",
"email": "alice@example.com",
"created": "2024-01-01T00:00:00",
"last_login": "2024-01-01T00:00:00",
}
storage = _mock_storage(identity=existing_identity, user=existing_user)
claims = {"sub": "sub-123", "email": "alice@example.com", "name": "Alice"}
user = provision_oidc_user(storage, config, claims)
assert user["user_id"] == "u1"
assert user["username"] == "alice"
storage.update_oidc_identity_login.assert_called_once()
# Should not create a new user
storage.create_user.assert_not_called()
storage.create_oidc_identity.assert_not_called()
def test_provision_oidc_user_new(self):
"""No identity -> creates user + identity."""
config = _make_config()
storage = _mock_storage()
# After create_user, get_user should return the new user
new_user = {
"user_id": "u-new",
"username": "bob",
"display_name": "Bob",
"password_hash": "!oidc",
}
storage.get_user.return_value = new_user
claims = {"sub": "sub-456", "preferred_username": "bob", "email": "bob@example.com"}
with patch("turnstone.core.oidc.uuid") as mock_uuid:
mock_uuid.uuid4.return_value = MagicMock(hex="u-new-hex-00000000000000000000")
user = provision_oidc_user(storage, config, claims)
assert user["username"] == "bob"
storage.create_user.assert_called_once()
storage.create_oidc_identity.assert_called_once()
# Verify create_oidc_identity was called with correct issuer and sub
call_args = storage.create_oidc_identity.call_args
assert call_args[0][0] == "https://idp.example.com" # issuer
assert call_args[0][1] == "sub-456" # subject
def test_provision_oidc_user_username_dedup(self):
"""First username taken -> appends suffix."""
config = _make_config()
storage = _mock_storage()
# First call: username "bob" exists; second call: "bob2" doesn't exist
storage.get_user_by_username.side_effect = [
{"user_id": "u-other", "username": "bob"}, # "bob" taken
None, # "bob2" available
]
new_user = {
"user_id": "u-new",
"username": "bob2",
"display_name": "Bob",
"password_hash": "!oidc",
}
storage.get_user.return_value = new_user
claims = {"sub": "sub-789", "preferred_username": "bob", "email": "bob@example.com"}
user = provision_oidc_user(storage, config, claims)
assert user["username"] == "bob2"
# create_user should have been called with "bob2" as username
call_args = storage.create_user.call_args
assert call_args[0][1] == "bob2"
def test_provision_oidc_user_email_prefix(self):
"""No preferred_username -> uses email prefix."""
config = _make_config()
storage = _mock_storage()
new_user = {
"user_id": "u-new",
"username": "charlie",
"display_name": "charlie@example.com",
"password_hash": "!oidc",
}
storage.get_user.return_value = new_user
claims = {"sub": "sub-abc", "email": "charlie@example.com"}
provision_oidc_user(storage, config, claims)
# create_user should have been called with "charlie" (email prefix)
call_args = storage.create_user.call_args
assert call_args[0][1] == "charlie"
def test_provision_oidc_user_missing_user_raises(self):
"""Identity references missing user -> raises OIDCError."""
config = _make_config()
existing_identity = {
"issuer": "https://idp.example.com",
"subject": "sub-orphan",
"user_id": "u-gone",
"email": "gone@example.com",
"created": "2024-01-01T00:00:00",
"last_login": "2024-01-01T00:00:00",
}
storage = _mock_storage(identity=existing_identity, user=None)
claims = {"sub": "sub-orphan", "email": "gone@example.com"}
with pytest.raises(OIDCError, match="missing user"):
provision_oidc_user(storage, config, claims)
def test_provision_oidc_user_fallback_username(self):
"""No preferred_username and no email -> falls back to 'user'."""
config = _make_config()
storage = _mock_storage()
new_user = {
"user_id": "u-new",
"username": "user",
"display_name": "",
"password_hash": "!oidc",
}
storage.get_user.return_value = new_user
claims = {"sub": "sub-noemail"}
provision_oidc_user(storage, config, claims)
call_args = storage.create_user.call_args
assert call_args[0][1] == "user"
# ---------------------------------------------------------------------------
# Role Mapping
# ---------------------------------------------------------------------------
class TestApplyRoleMapping:
def test_apply_role_mapping_basic(self):
"""Maps claim value to role."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin"},
)
storage = _mock_storage(role={"role_id": "builtin-admin", "name": "Admin"})
claims = {"sub": "u1", "groups": "admin"}
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_called_once_with("u1", "builtin-admin", "oidc")
def test_apply_role_mapping_list_claim(self):
"""Claim is a list of strings -> maps each."""
config = _make_config(
role_claim="roles",
role_map={"admin": "builtin-admin", "editor": "builtin-operator"},
)
storage = _mock_storage()
# get_role returns non-None for both roles
storage.get_role.return_value = {"role_id": "some-role"}
claims = {"sub": "u1", "roles": ["admin", "editor"]}
apply_role_mapping(storage, "u1", claims, config)
assert storage.assign_role.call_count == 2
def test_apply_role_mapping_no_config(self):
"""No role_claim configured -> no-op."""
config = _make_config(role_claim="", role_map={})
storage = _mock_storage()
claims = {"sub": "u1", "roles": "admin"}
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_not_called()
def test_apply_role_mapping_unknown_role(self):
"""Claim maps to nonexistent role -> skipped."""
config = _make_config(
role_claim="groups",
role_map={"admin": "nonexistent-role"},
)
storage = _mock_storage(role=None) # role doesn't exist
claims = {"sub": "u1", "groups": "admin"}
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_not_called()
def test_apply_role_mapping_no_matching_claim_value(self):
"""Claim value not in role_map -> no assignment."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin"},
)
storage = _mock_storage()
claims = {"sub": "u1", "groups": "viewer"} # "viewer" not in role_map
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_not_called()
def test_apply_role_mapping_claim_missing(self):
"""Claim key not present in claims -> no-op."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin"},
)
storage = _mock_storage()
claims = {"sub": "u1"} # no "groups" key
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_not_called()
def test_apply_role_mapping_no_role_map(self):
"""role_claim set but role_map empty -> no-op (early return)."""
config = _make_config(role_claim="groups", role_map={})
storage = _mock_storage()
claims = {"sub": "u1", "groups": "admin"}
apply_role_mapping(storage, "u1", claims, config)
storage.assign_role.assert_not_called()
def test_apply_role_mapping_revokes_stale_oidc_roles(self):
"""Roles previously assigned by OIDC but no longer in claims are revoked."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin", "eng": "builtin-operator"},
)
storage = _mock_storage()
storage.get_role.return_value = {"role_id": "some-role"}
# User currently has admin (via OIDC) and a manual role
storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "assigned_by": "oidc"},
{"role_id": "custom-role", "assigned_by": "admin-ui"},
]
# IdP now only says "eng", not "admin"
claims = {"sub": "u1", "groups": ["eng"]}
apply_role_mapping(storage, "u1", claims, config)
# builtin-admin should be revoked (OIDC-assigned, no longer in claims)
storage.unassign_role.assert_called_once_with("u1", "builtin-admin")
# custom-role should NOT be revoked (not assigned by OIDC)
def test_apply_role_mapping_preserves_manual_roles(self):
"""Manually assigned roles are never revoked by OIDC sync."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin"},
)
storage = _mock_storage()
storage.get_role.return_value = {"role_id": "some-role"}
storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "assigned_by": "admin-ui"},
]
# Claims have no groups at all
claims = {"sub": "u1"}
apply_role_mapping(storage, "u1", claims, config)
# Manual admin role must NOT be revoked
storage.unassign_role.assert_not_called()
def test_apply_role_mapping_revokes_all_oidc_roles_when_claim_absent(self):
"""When the claim is absent from the token, all OIDC-assigned roles are revoked."""
config = _make_config(
role_claim="groups",
role_map={"admin": "builtin-admin"},
)
storage = _mock_storage()
storage.get_role.return_value = {"role_id": "some-role"}
storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "assigned_by": "oidc"},
{"role_id": "builtin-operator", "assigned_by": "oidc"},
]
claims = {"sub": "u1"} # no "groups" key
apply_role_mapping(storage, "u1", claims, config)
assert storage.unassign_role.call_count == 2
# ---------------------------------------------------------------------------
# Discovery (async)
# ---------------------------------------------------------------------------
class TestDiscoverOIDC:
def test_discover_oidc_success(self):
"""Mock httpx response, verify endpoints populated."""
config = _make_config(
authorization_endpoint="",
token_endpoint="",
userinfo_endpoint="",
jwks_uri="",
)
discovery_doc = {
"authorization_endpoint": "https://idp.example.com/authorize",
"token_endpoint": "https://idp.example.com/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
}
mock_response = MagicMock()
mock_response.json.return_value = discovery_doc
mock_response.raise_for_status = MagicMock()
async def _run():
client = _mock_async_client(lambda url: _async_return(mock_response))
with patch("httpx.AsyncClient", return_value=client):
result = await discover_oidc(config)
assert result.authorization_endpoint == "https://idp.example.com/authorize"
assert result.token_endpoint == "https://idp.example.com/token"
assert result.userinfo_endpoint == "https://idp.example.com/userinfo"
assert result.jwks_uri == "https://idp.example.com/.well-known/jwks.json"
assert result.enabled is True
asyncio.run(_run())
def test_discover_oidc_failure(self):
"""Mock httpx error -> enabled=False returned."""
config = _make_config(
authorization_endpoint="",
token_endpoint="",
userinfo_endpoint="",
jwks_uri="",
)
async def _failing_get(url):
raise httpx.ConnectError("connection refused")
async def _run():
client = _mock_async_client(_failing_get)
with patch("httpx.AsyncClient", return_value=client):
result = await discover_oidc(config)
assert result.enabled is False
asyncio.run(_run())
def test_discover_oidc_no_issuer(self):
"""Empty issuer -> enabled=False."""
config = _make_config(issuer="")
async def _run():
result = await discover_oidc(config)
assert result.enabled is False
asyncio.run(_run())
def test_discover_oidc_missing_required_endpoints(self):
"""Discovery doc missing authorization_endpoint -> enabled=False."""
config = _make_config(
authorization_endpoint="",
token_endpoint="",
userinfo_endpoint="",
jwks_uri="",
)
# Document missing authorization_endpoint
discovery_doc = {
"token_endpoint": "https://idp.example.com/token",
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
}
mock_response = MagicMock()
mock_response.json.return_value = discovery_doc
mock_response.raise_for_status = MagicMock()
async def _run():
client = _mock_async_client(lambda url: _async_return(mock_response))
with patch("httpx.AsyncClient", return_value=client):
result = await discover_oidc(config)
assert result.enabled is False
asyncio.run(_run())
async def _async_return(value):
"""Helper: return a value from an async function."""
return value
+315
View File
@@ -0,0 +1,315 @@
"""Tests for OIDC identity and pending state storage CRUD (SQLite backend)."""
from __future__ import annotations
import time
import pytest
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture()
def db(tmp_path):
"""Create a fresh SQLite backend for each test."""
return SQLiteBackend(str(tmp_path / "test.db"))
# ---------------------------------------------------------------------------
# OIDC Identity CRUD
# ---------------------------------------------------------------------------
class TestOIDCIdentityCRUD:
def test_create_and_get_oidc_identity(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert identity is not None
assert identity["issuer"] == "https://idp.example.com"
assert identity["subject"] == "sub-123"
assert identity["user_id"] == "u1"
assert identity["email"] == "alice@example.com"
assert identity["created"] != ""
assert identity["last_login"] != ""
def test_get_oidc_identity_not_found(self, db):
assert db.get_oidc_identity("https://unknown.example.com", "sub-999") is None
def test_create_oidc_identity_idempotent(self, db):
"""Creating twice with same (issuer, subject) does not error (OR IGNORE)."""
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-123", "u2", "bob@example.com")
identity = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert identity is not None
# OR IGNORE preserves the first insert
assert identity["user_id"] == "u1"
assert identity["email"] == "alice@example.com"
def test_update_oidc_identity_login(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
before = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert before is not None
original_login = before["last_login"]
# Small sleep to ensure timestamp differs
time.sleep(0.05)
result = db.update_oidc_identity_login("https://idp.example.com", "sub-123")
assert result is True
after = db.get_oidc_identity("https://idp.example.com", "sub-123")
assert after is not None
assert after["last_login"] >= original_login
def test_update_oidc_identity_login_nonexistent(self, db):
result = db.update_oidc_identity_login("https://idp.example.com", "sub-999")
assert result is False
def test_list_oidc_identities_for_user(self, db):
"""Two identities for same user, list returns both."""
db.create_oidc_identity("https://idp1.example.com", "sub-A", "u1", "alice@idp1.com")
db.create_oidc_identity("https://idp2.example.com", "sub-B", "u1", "alice@idp2.com")
identities = db.list_oidc_identities_for_user("u1")
assert len(identities) == 2
issuers = {i["issuer"] for i in identities}
assert issuers == {"https://idp1.example.com", "https://idp2.example.com"}
def test_list_oidc_identities_for_user_empty(self, db):
assert db.list_oidc_identities_for_user("u-none") == []
def test_list_oidc_identities_excludes_other_users(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "alice@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-2", "u2", "bob@example.com")
identities = db.list_oidc_identities_for_user("u1")
assert len(identities) == 1
assert identities[0]["user_id"] == "u1"
def test_delete_oidc_identity(self, db):
db.create_oidc_identity("https://idp.example.com", "sub-123", "u1", "alice@example.com")
assert db.delete_oidc_identity("https://idp.example.com", "sub-123") is True
assert db.get_oidc_identity("https://idp.example.com", "sub-123") is None
def test_delete_oidc_identity_nonexistent(self, db):
assert db.delete_oidc_identity("https://idp.example.com", "sub-999") is False
def test_delete_oidc_identity_only_deletes_target(self, db):
"""Deleting one identity does not affect others."""
db.create_oidc_identity("https://idp.example.com", "sub-1", "u1", "a@example.com")
db.create_oidc_identity("https://idp.example.com", "sub-2", "u1", "b@example.com")
db.delete_oidc_identity("https://idp.example.com", "sub-1")
assert db.get_oidc_identity("https://idp.example.com", "sub-1") is None
assert db.get_oidc_identity("https://idp.example.com", "sub-2") is not None
# ---------------------------------------------------------------------------
# OIDC Pending State
# ---------------------------------------------------------------------------
class TestOIDCPendingState:
def test_create_and_pop_pending_state(self, db):
db.create_oidc_pending_state(
state="state-abc",
nonce="nonce-xyz",
code_verifier="verifier-123",
audience="server",
)
result = db.pop_oidc_pending_state("state-abc")
assert result is not None
assert result["state"] == "state-abc"
assert result["nonce"] == "nonce-xyz"
assert result["code_verifier"] == "verifier-123"
assert result["audience"] == "server"
assert result["created_at"] != ""
def test_pop_pending_state_not_found(self, db):
assert db.pop_oidc_pending_state("nonexistent-state") is None
def test_pop_pending_state_expired(self, db):
"""Create with old timestamp, pop returns None."""
# Insert a row with an old created_at timestamp directly
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-old",
"nonce": "nonce-old",
"code_verifier": "verifier-old",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Default max_age_seconds=300, so a 2020 timestamp is expired
result = db.pop_oidc_pending_state("state-old")
assert result is None
def test_pop_pending_state_consumed(self, db):
"""Pop twice -> second returns None (one-time use)."""
db.create_oidc_pending_state(
state="state-once",
nonce="nonce-1",
code_verifier="verifier-1",
audience="server",
)
first = db.pop_oidc_pending_state("state-once")
assert first is not None
second = db.pop_oidc_pending_state("state-once")
assert second is None
def test_pop_pending_state_custom_max_age(self, db):
"""Custom max_age_seconds allows longer-lived states."""
db.create_oidc_pending_state(
state="state-long",
nonce="nonce-long",
code_verifier="verifier-long",
audience="server",
)
# With very short max_age, it might still be valid since we just created it
result = db.pop_oidc_pending_state("state-long", max_age_seconds=600)
assert result is not None
def test_create_pending_state_duplicate_raises(self, db):
"""Duplicate state insertion raises IntegrityError (no silent drop)."""
import sqlalchemy.exc
db.create_oidc_pending_state("state-dup", "nonce-1", "verifier-1", "server")
with pytest.raises(sqlalchemy.exc.IntegrityError):
db.create_oidc_pending_state("state-dup", "nonce-2", "verifier-2", "server")
def test_cleanup_expired_states(self, db):
"""Create expired + fresh, cleanup removes only expired."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
# Insert an expired state directly with old timestamp
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-expired",
"nonce": "nonce-old",
"code_verifier": "verifier-old",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Insert a fresh state via normal API
db.create_oidc_pending_state("state-fresh", "nonce-new", "verifier-new", "server")
# Cleanup with default 300s max age
deleted = db.cleanup_expired_oidc_states()
assert deleted == 1
# Fresh state should still exist
result = db.pop_oidc_pending_state("state-fresh")
assert result is not None
def test_cleanup_expired_states_none_expired(self, db):
"""Cleanup with no expired states returns 0."""
db.create_oidc_pending_state("state-1", "nonce-1", "verifier-1", "server")
deleted = db.cleanup_expired_oidc_states()
assert deleted == 0
def test_cleanup_expired_states_all_expired(self, db):
"""Cleanup with all expired states removes all."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
for i in range(3):
conn.execute(
sa.insert(oidc_pending_states),
{
"state": f"state-{i}",
"nonce": f"nonce-{i}",
"code_verifier": f"verifier-{i}",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
deleted = db.cleanup_expired_oidc_states()
assert deleted == 3
def test_cleanup_expired_states_custom_max_age(self, db):
"""Custom max_age_seconds affects what counts as expired."""
from datetime import UTC, datetime, timedelta
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
# Insert a state created 60 seconds ago
old_ts = (datetime.now(UTC) - timedelta(seconds=60)).strftime("%Y-%m-%dT%H:%M:%S")
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-1",
"nonce": "nonce-1",
"code_verifier": "verifier-1",
"audience": "server",
"created_at": old_ts,
},
)
conn.commit()
# With default max_age=300s the 60s-old state is NOT expired
deleted = db.cleanup_expired_oidc_states(max_age_seconds=300)
assert deleted == 0
# With max_age=30s the 60s-old state IS expired
deleted = db.cleanup_expired_oidc_states(max_age_seconds=30)
assert deleted == 1
def test_pop_expired_cleans_up_row(self, db):
"""Popping an expired state should delete the row (not leave orphan)."""
import sqlalchemy as sa
from turnstone.core.storage._schema import oidc_pending_states
with db._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": "state-cleanup",
"nonce": "nonce-c",
"code_verifier": "verifier-c",
"audience": "server",
"created_at": "2020-01-01T00:00:00",
},
)
conn.commit()
# Pop returns None (expired)
assert db.pop_oidc_pending_state("state-cleanup") is None
# Row should be gone (cleaned up even though expired)
with db._engine.connect() as conn:
count = conn.execute(
sa.select(sa.func.count())
.select_from(oidc_pending_states)
.where(oidc_pending_states.c.state == "state-cleanup")
).scalar()
assert count == 0
+24
View File
@@ -71,6 +71,7 @@ from turnstone.api.schemas import (
AuthSetupRequest,
AuthSetupResponse,
AuthStatusResponse,
AuthWhoamiResponse,
CreateScheduleRequest,
CreateTokenRequest,
CreateTokenResponse,
@@ -198,6 +199,29 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
response_model=StatusResponse,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/authorize",
"GET",
"Redirect to OIDC provider for SSO login",
response_code=302,
error_codes=[404, 503],
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/callback",
"GET",
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
response_code=302,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/whoami",
"GET",
"Return authenticated user info and permissions",
response_model=AuthWhoamiResponse,
error_codes=[401],
tags=["Auth"],
),
# --- Admin ---
EndpointSpec(
"/v1/api/admin/users",
+10
View File
@@ -155,6 +155,16 @@ class AuthStatusResponse(BaseModel):
auth_enabled: bool
has_users: bool
setup_required: bool
oidc_enabled: bool = False
oidc_provider_name: str = ""
password_enabled: bool = True
class AuthWhoamiResponse(BaseModel):
"""GET /v1/api/auth/whoami response."""
user_id: str
permissions: str = ""
# ---------------------------------------------------------------------------
+24
View File
@@ -15,6 +15,7 @@ from turnstone.api.schemas import (
AuthSetupRequest,
AuthSetupResponse,
AuthStatusResponse,
AuthWhoamiResponse,
ErrorResponse,
StatusResponse,
)
@@ -196,6 +197,29 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
response_model=StatusResponse,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/authorize",
"GET",
"Redirect to OIDC provider for SSO login",
response_code=302,
error_codes=[404, 503],
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/callback",
"GET",
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
response_code=302,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/whoami",
"GET",
"Return authenticated user info and permissions",
response_model=AuthWhoamiResponse,
error_codes=[401],
tags=["Auth"],
),
# --- Memories ---
EndpointSpec(
"/v1/api/memories",
+18 -1
View File
@@ -93,6 +93,16 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled)
- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth
### OIDC SSO (optional)
- `TURNSTONE_OIDC_ISSUER` — OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
- `TURNSTONE_OIDC_CLIENT_ID` — Client ID from the identity provider
- `TURNSTONE_OIDC_CLIENT_SECRET` — Client secret (confidential client)
- `TURNSTONE_OIDC_PROVIDER_NAME` — Display name for the SSO button (default: "SSO")
- `TURNSTONE_OIDC_SCOPES` — OIDC scopes (default: "openid email profile")
- `TURNSTONE_OIDC_ROLE_CLAIM` — Claim name for role mapping (e.g., "groups")
- `TURNSTONE_OIDC_ROLE_MAP` — Comma-separated claim_value:role_id pairs (e.g., "admin:builtin-admin,eng:builtin-operator")
- `TURNSTONE_OIDC_PASSWORD_ENABLED` — Set to "false" to hide password login and force SSO-only
### Ports
- `SERVER_PORT` — Server port (default: 8080)
- `CONSOLE_PORT` — Console port (default: 8090)
@@ -122,6 +132,10 @@ This is a one-time endpoint that only works when zero users exist.
Subsequent governance setup (roles, policies, templates) uses the console admin API \
with the JWT returned from setup.
If OIDC is configured, users can also log in via the "Continue with [Provider]" button on the login page.
The first admin user must still be created via the setup wizard (OIDC login requires at least one user to exist).
OIDC users are auto-provisioned on first login with a default viewer role unless role mapping is configured.
## Runtime Settings (ConfigStore)
After the stack is running, ~40 runtime settings (model, temperature, max_tokens, \
reasoning_effort, tool timeout, rate limiting, health probes, judge config, memory \
@@ -157,7 +171,10 @@ Walk the user through setting up their deployment step by step:
PostgreSQL is required for cluster mode.
5. **Security**: Recommend enabling auth for any non-local deployment. \
Use `generate_secret` for JWT secret, Redis password, auth token, and Postgres password. \
Ask for initial admin username and password.
Ask for initial admin username and password. \
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
Optionally configure role mapping and OIDC-only mode.
6. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
7. **Optional features**: Discord integration, web search (Tavily key), \
DuckDuckGo Search MCP (for cluster — uses `ddgCluster` profile with \
+56
View File
@@ -323,6 +323,27 @@ async def auth_setup(request: Request) -> Response:
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request)
async def oidc_authorize(request: Request) -> Response:
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
from turnstone.core.auth import handle_oidc_authorize
return await handle_oidc_authorize(request, JWT_AUD_CONSOLE)
async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
# ---------------------------------------------------------------------------
# Route handlers — workstream creation
# ---------------------------------------------------------------------------
@@ -674,6 +695,31 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
scheduler = getattr(app.state, "scheduler", None)
if scheduler is not None:
scheduler.start()
# OIDC discovery (if configured)
oidc_config = app.state.oidc_config
if oidc_config.enabled:
from turnstone.core.oidc import discover_oidc
try:
oidc_config = await discover_oidc(oidc_config)
app.state.oidc_config = oidc_config
except Exception:
log.warning("OIDC discovery failed — OIDC login disabled", exc_info=True)
if oidc_config.enabled and oidc_config.jwks_uri:
try:
from turnstone.core.oidc import fetch_jwks
app.state.jwks_data = await fetch_jwks(oidc_config.jwks_uri)
log.info(
"OIDC enabled: %s (%s)",
oidc_config.provider_name,
oidc_config.issuer,
)
except Exception:
log.warning(
"OIDC JWKS prefetch failed — will retry on first login",
exc_info=True,
)
yield
# Shutdown
if scheduler is not None:
@@ -3557,6 +3603,9 @@ def create_app(
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
Route("/api/auth/setup", auth_setup, methods=["POST"]),
Route("/api/auth/whoami", auth_whoami),
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/admin/users", admin_list_users),
Route("/api/admin/users", admin_create_user, methods=["POST"]),
Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]),
@@ -3747,6 +3796,13 @@ def create_app(
app.state.login_limiter = LoginRateLimiter()
# OIDC configuration (opt-in via env vars)
from turnstone.core.oidc import load_oidc_config
oidc_config = load_oidc_config()
app.state.oidc_config = oidc_config
app.state.jwks_data = None # populated after async discovery
# Scheduler — start background thread if storage is available
if auth_storage is not None:
from turnstone.console.scheduler import TaskScheduler
+245 -9
View File
@@ -28,6 +28,7 @@ import re
import secrets
import threading
import time
import urllib.parse
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -150,6 +151,8 @@ PUBLIC_PATHS: frozenset[str] = frozenset(
"/api/auth/logout",
"/api/auth/status",
"/api/auth/setup",
"/api/auth/oidc/authorize",
"/api/auth/oidc/callback",
}
)
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
@@ -258,10 +261,20 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against a bcrypt hash."""
"""Verify a password against a bcrypt hash.
Returns ``False`` immediately for non-bcrypt hashes (e.g. the ``!oidc``
sentinel used for OIDC-provisioned users) to avoid ``ValueError`` from
``bcrypt.checkpw``.
"""
import bcrypt
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
if not password_hash.startswith("$2"):
return False # Not a bcrypt hash (e.g. OIDC sentinel)
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except (ValueError, TypeError):
return False
def parse_scopes(scopes_str: str) -> frozenset[str]:
@@ -909,6 +922,13 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
password = body.get("password", "")
if username and password and storage is not None:
# Enforce OIDC-only mode: reject password login when disabled
oidc_config = getattr(request.app.state, "oidc_config", None)
if oidc_config and oidc_config.enabled and not oidc_config.password_enabled:
return JSONResponse(
{"error": "Password login is disabled — use SSO"},
status_code=403,
)
user = storage.get_user_by_username(username)
if user and verify_password(password, user["password_hash"]):
# Derive scopes and permissions from assigned roles
@@ -990,13 +1010,21 @@ async def handle_auth_status(request: Request) -> Response:
except Exception:
pass
return JSONResponse(
{
"auth_enabled": auth_config.enabled,
"has_users": has_users,
"setup_required": auth_config.enabled and not has_users,
}
)
# OIDC configuration
oidc_config = getattr(request.app.state, "oidc_config", None)
oidc_enabled = bool(oidc_config and oidc_config.enabled)
resp: dict[str, Any] = {
"auth_enabled": auth_config.enabled,
"has_users": has_users,
"setup_required": auth_config.enabled and not has_users,
}
if oidc_enabled and oidc_config is not None:
resp["oidc_enabled"] = True
resp["oidc_provider_name"] = oidc_config.provider_name
resp["password_enabled"] = oidc_config.password_enabled
return JSONResponse(resp)
async def handle_auth_setup(request: Request, audience: str) -> Response:
@@ -1097,3 +1125,211 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
if jwt_token:
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
return response
async def handle_auth_whoami(request: Request) -> Response:
"""Shared ``GET /api/auth/whoami`` handler — return authenticated user info."""
from starlette.responses import JSONResponse
auth_result: AuthResult | None = getattr(request.state, "auth_result", None)
if not auth_result or not auth_result.user_id:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
resp: dict[str, str] = {
"user_id": auth_result.user_id,
}
if auth_result.permissions:
resp["permissions"] = ",".join(sorted(auth_result.permissions))
return JSONResponse(resp)
async def handle_oidc_authorize(request: Request, audience: str) -> Response:
"""Shared ``GET /api/auth/oidc/authorize`` handler — redirect to IdP."""
from starlette.responses import JSONResponse, RedirectResponse
oidc_config = getattr(request.app.state, "oidc_config", None)
if not oidc_config or not oidc_config.enabled:
return JSONResponse({"error": "OIDC not configured"}, status_code=404)
# Rate limit — prevents flooding oidc_pending_states table
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
client_ip = request.client.host if request.client else "unknown"
if login_limiter is not None:
ip_ok, _ip_retry = login_limiter.check(f"ip:{client_ip}")
if not ip_ok:
return RedirectResponse("/?oidc_error=Too+many+login+attempts", status_code=302)
login_limiter.record(f"ip:{client_ip}") # Count every authorize to bound pending states
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return JSONResponse({"error": "Storage not available"}, status_code=503)
# Require setup to be complete before allowing OIDC login
try:
users = storage.list_users()
except Exception:
return JSONResponse({"error": "Storage unavailable"}, status_code=503)
if not users:
return JSONResponse(
{"error": "Initial setup required before OIDC login"},
status_code=403,
)
from turnstone.core.oidc import build_authorize_url, generate_pkce_pair
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
code_verifier, _code_challenge = generate_pkce_pair()
# Store pending state in database
storage.create_oidc_pending_state(state, nonce, code_verifier, audience)
# Build redirect URI from request
scheme = "https" if is_secure_request(dict(request.headers), request.url.scheme) else "http"
# TODO(tech-debt): derive from TURNSTONE_OIDC_REDIRECT_BASE env var
# when available, rather than the request Host header. See PROGRESS.md.
host = request.headers.get("host", "localhost")
redirect_uri = f"{scheme}://{host}/v1/api/auth/oidc/callback"
url = build_authorize_url(oidc_config, redirect_uri, state, nonce, code_verifier)
return RedirectResponse(url, status_code=302)
async def handle_oidc_callback(request: Request, audience: str) -> Response:
"""Shared ``GET /api/auth/oidc/callback`` handler — exchange code, provision user, issue JWT."""
from starlette.responses import JSONResponse, RedirectResponse
oidc_config = getattr(request.app.state, "oidc_config", None)
if not oidc_config or not oidc_config.enabled:
return JSONResponse({"error": "OIDC not configured"}, status_code=404)
storage = getattr(request.app.state, "auth_storage", None)
jwt_secret = getattr(request.app.state, "jwt_secret", "")
if storage is None:
return JSONResponse({"error": "Storage not available"}, status_code=503)
# Rate limiting
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
client_ip = request.client.host if request.client else "unknown"
if login_limiter is not None:
ip_ok, ip_retry = login_limiter.check(f"ip:{client_ip}")
if not ip_ok:
return RedirectResponse("/?oidc_error=Too+many+login+attempts", status_code=302)
# Lazy cleanup of expired pending states
with contextlib.suppress(Exception):
storage.cleanup_expired_oidc_states(300)
def _record_oidc_failure() -> None:
if login_limiter is not None:
login_limiter.record(f"ip:{client_ip}")
# Check for IdP error
error = request.query_params.get("error", "")
if error:
_record_oidc_failure()
desc = request.query_params.get("error_description", error)
return RedirectResponse(f"/?oidc_error={urllib.parse.quote(desc)}", status_code=302)
# Validate state
state = request.query_params.get("state", "")
pending = storage.pop_oidc_pending_state(state, max_age_seconds=300)
if not pending:
_record_oidc_failure()
return RedirectResponse("/?oidc_error=Login+session+expired", status_code=302)
# Build redirect URI (must match what was sent in authorize)
scheme = "https" if is_secure_request(dict(request.headers), request.url.scheme) else "http"
# TODO(tech-debt): derive from TURNSTONE_OIDC_REDIRECT_BASE env var
# when available, rather than the request Host header. See PROGRESS.md.
host = request.headers.get("host", "localhost")
redirect_uri = f"{scheme}://{host}/v1/api/auth/oidc/callback"
try:
from turnstone.core.oidc import (
OIDCError,
exchange_code,
fetch_jwks,
provision_oidc_user,
validate_id_token,
)
# Exchange code for tokens
code = request.query_params.get("code", "")
tokens = await exchange_code(oidc_config, code, redirect_uri, pending["code_verifier"])
# Validate ID token against cached JWKS keys (no I/O).
# On unknown kid, refresh JWKS once (async) for key rotation.
jwks_data: dict[str, Any] | None = getattr(request.app.state, "jwks_data", None)
if jwks_data is None and oidc_config.jwks_uri:
# Lazy fetch: JWKS may have failed at startup but IdP recovered
try:
jwks_data = await fetch_jwks(oidc_config.jwks_uri)
request.app.state.jwks_data = jwks_data
except OIDCError:
pass
if jwks_data is None:
return RedirectResponse("/?oidc_error=OIDC+temporarily+unavailable", status_code=302)
try:
id_claims = validate_id_token(
tokens["id_token"],
jwks_data,
oidc_config,
pending["nonce"],
)
except OIDCError as first_err:
if "not found in JWKS" not in str(first_err):
raise
# Key rotation: re-fetch JWKS and retry once.
log.info("JWKS key not found — refreshing for possible key rotation")
jwks_data = await fetch_jwks(oidc_config.jwks_uri)
request.app.state.jwks_data = jwks_data
id_claims = validate_id_token(
tokens["id_token"],
jwks_data,
oidc_config,
pending["nonce"],
)
# Verify setup is complete
users = storage.list_users()
if not users:
return RedirectResponse("/?oidc_error=Initial+setup+required", status_code=302)
# Provision or match user
user = provision_oidc_user(storage, oidc_config, id_claims)
except OIDCError as exc:
log.warning("OIDC callback failed: %s", exc)
_record_oidc_failure()
return RedirectResponse("/?oidc_error=Authentication+failed", status_code=302)
except Exception:
log.exception("OIDC callback error")
_record_oidc_failure()
return RedirectResponse("/?oidc_error=Authentication+failed", status_code=302)
# Load permissions and issue Turnstone JWT
perms = _load_user_permissions(storage, user["user_id"])
scopes = _permissions_to_scopes(perms)
jwt_token = ""
if jwt_secret:
# Use the audience stored during authorize (not the handler param)
# to bind the JWT to the service that initiated the flow
jwt_audience = pending.get("audience", audience)
jwt_token = create_jwt(
user_id=user["user_id"],
scopes=scopes,
source="oidc",
secret=jwt_secret,
audience=jwt_audience,
permissions=frozenset(perms),
)
# Set cookie and redirect to app
response = RedirectResponse("/?oidc_success=1", status_code=302)
if jwt_token:
secure = is_secure_request(dict(request.headers), request.url.scheme)
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
return response
+535
View File
@@ -0,0 +1,535 @@
"""OpenID Connect (OIDC) authentication support for Turnstone.
Implements the Authorization Code Flow with PKCE for secure SSO login.
All external HTTP calls use ``httpx.AsyncClient`` to avoid blocking the
event loop.
"""
from __future__ import annotations
import base64
import dataclasses
import hashlib
import logging
import os
import re
import secrets
import urllib.parse
import uuid
from dataclasses import dataclass, field
from typing import Any
import httpx
log = logging.getLogger(__name__)
# Sentinel password hash for OIDC-provisioned users.
# Not a valid bcrypt hash -- verify_password() always rejects it.
OIDC_PASSWORD_SENTINEL = "!oidc"
# Sanitisation pattern: only keep safe username characters.
_USERNAME_SAFE_RE = re.compile(r"[^a-zA-Z0-9._-]")
# Asymmetric algorithms accepted for ID token signatures.
# Symmetric (HMAC) algorithms are deliberately excluded to prevent
# algorithm confusion attacks where the IdP's public key is used as
# an HMAC secret.
_ALLOWED_ID_TOKEN_ALGS = [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
]
# ---------------------------------------------------------------------------
# Exception
# ---------------------------------------------------------------------------
class OIDCError(Exception):
"""Raised when an OIDC operation fails."""
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class OIDCConfig:
"""OIDC provider configuration -- immutable after startup."""
enabled: bool = False
issuer: str = ""
client_id: str = ""
client_secret: str = ""
scopes: str = "openid email profile"
provider_name: str = "SSO"
role_claim: str = ""
role_map: dict[str, str] = field(default_factory=dict)
password_enabled: bool = True
# Discovered from .well-known/openid-configuration
authorization_endpoint: str = ""
token_endpoint: str = ""
userinfo_endpoint: str = ""
jwks_uri: str = ""
def _parse_role_map(raw: str) -> dict[str, str]:
"""Parse ``"admin:builtin-admin,eng:builtin-operator"`` into a dict."""
result: dict[str, str] = {}
for pair in raw.split(","):
pair = pair.strip()
if ":" in pair:
k, v = pair.split(":", 1)
k, v = k.strip(), v.strip()
if k and v:
result[k] = v
return result
def load_oidc_config() -> OIDCConfig:
"""Build :class:`OIDCConfig` from env vars with config.toml fallback.
Returns ``OIDCConfig(enabled=False)`` when the required fields
(issuer, client_id, client_secret) are not all present.
"""
from turnstone.core.config import load_config
cfg = load_config("oidc")
# Start with config.toml values, then override with env vars.
issuer = os.environ.get("TURNSTONE_OIDC_ISSUER", "").strip()
if not issuer:
issuer = str(cfg.get("issuer", "")).strip()
client_id = os.environ.get("TURNSTONE_OIDC_CLIENT_ID", "").strip()
if not client_id:
client_id = str(cfg.get("client_id", "")).strip()
client_secret = os.environ.get("TURNSTONE_OIDC_CLIENT_SECRET", "").strip()
if not client_secret:
client_secret = str(cfg.get("client_secret", "")).strip()
scopes = os.environ.get("TURNSTONE_OIDC_SCOPES", "").strip()
if not scopes:
scopes = str(cfg.get("scopes", "openid email profile")).strip()
provider_name = os.environ.get("TURNSTONE_OIDC_PROVIDER_NAME", "").strip()
if not provider_name:
provider_name = str(cfg.get("provider_name", "SSO")).strip()
role_claim = os.environ.get("TURNSTONE_OIDC_ROLE_CLAIM", "").strip()
if not role_claim:
role_claim = str(cfg.get("role_claim", "")).strip()
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
if role_map_raw:
role_map = _parse_role_map(role_map_raw)
else:
cfg_role_map = cfg.get("role_map", {})
role_map = dict(cfg_role_map) if isinstance(cfg_role_map, dict) else {}
password_raw = os.environ.get("TURNSTONE_OIDC_PASSWORD_ENABLED", "").strip().lower()
if password_raw:
password_enabled = password_raw in ("true", "1", "yes")
else:
password_enabled = bool(cfg.get("password_enabled", True))
# OIDC is enabled when all three required fields are non-empty.
enabled = bool(issuer and client_id and client_secret)
if enabled:
log.info("OIDC enabled: issuer=%s provider=%s", issuer, provider_name)
else:
log.debug("OIDC not configured (issuer/client_id/client_secret incomplete)")
return OIDCConfig(
enabled=enabled,
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
scopes=scopes,
provider_name=provider_name,
role_claim=role_claim,
role_map=role_map,
password_enabled=password_enabled,
)
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
async def discover_oidc(config: OIDCConfig) -> OIDCConfig:
"""Fetch OIDC discovery document and return updated config with endpoints.
On failure, logs a warning and returns config with ``enabled=False``.
"""
if not config.issuer:
return dataclasses.replace(config, enabled=False)
url = config.issuer.rstrip("/") + "/.well-known/openid-configuration"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
doc = resp.json()
except Exception as exc:
log.warning("OIDC discovery failed for %s: %s", config.issuer, exc)
return dataclasses.replace(config, enabled=False)
authorization_endpoint = str(doc.get("authorization_endpoint", ""))
token_endpoint = str(doc.get("token_endpoint", ""))
userinfo_endpoint = str(doc.get("userinfo_endpoint", ""))
jwks_uri = str(doc.get("jwks_uri", ""))
if not authorization_endpoint or not token_endpoint or not jwks_uri:
log.warning(
"OIDC discovery document missing required endpoints for %s",
config.issuer,
)
return dataclasses.replace(config, enabled=False)
log.info("OIDC discovery complete: %s", config.issuer)
return dataclasses.replace(
config,
authorization_endpoint=authorization_endpoint,
token_endpoint=token_endpoint,
userinfo_endpoint=userinfo_endpoint,
jwks_uri=jwks_uri,
)
# ---------------------------------------------------------------------------
# JWKS key management
# ---------------------------------------------------------------------------
async def fetch_jwks(jwks_uri: str) -> dict[str, Any]:
"""Fetch the JWKS key set from the IdP.
Returns the parsed JSON document (``{"keys": [...]}``) . Called during
startup discovery and on-demand when an unknown ``kid`` is encountered
(key rotation). Uses ``httpx.AsyncClient`` never blocks the event loop.
Raises :class:`OIDCError` on network failures or malformed responses.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(jwks_uri)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
except Exception as exc:
raise OIDCError(f"JWKS fetch failed: {exc}") from exc
if not isinstance(result.get("keys"), list):
raise OIDCError("JWKS document missing 'keys' array")
return result
# ---------------------------------------------------------------------------
# PKCE helpers
# ---------------------------------------------------------------------------
def generate_pkce_pair() -> tuple[str, str]:
"""Generate a PKCE code_verifier and code_challenge pair."""
code_verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
# ---------------------------------------------------------------------------
# Authorization URL
# ---------------------------------------------------------------------------
def build_authorize_url(
config: OIDCConfig,
redirect_uri: str,
state: str,
nonce: str,
code_verifier: str,
) -> str:
"""Build the OIDC authorization URL with PKCE."""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
params = {
"response_type": "code",
"client_id": config.client_id,
"redirect_uri": redirect_uri,
"scope": config.scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return config.authorization_endpoint + "?" + urllib.parse.urlencode(params)
# ---------------------------------------------------------------------------
# Token exchange
# ---------------------------------------------------------------------------
async def exchange_code(
config: OIDCConfig,
code: str,
redirect_uri: str,
code_verifier: str,
) -> dict[str, Any]:
"""Exchange authorization code for tokens at the token endpoint.
Raises :class:`OIDCError` on non-200 response.
"""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": config.client_id,
"client_secret": config.client_secret,
"code_verifier": code_verifier,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(config.token_endpoint, data=data)
except Exception as exc:
raise OIDCError(f"Token exchange request failed: {exc}") from exc
if resp.status_code != 200:
raise OIDCError(f"Token endpoint returned {resp.status_code}: {resp.text[:500]}")
result: dict[str, Any] = resp.json()
return result
# ---------------------------------------------------------------------------
# ID token validation
# ---------------------------------------------------------------------------
def validate_id_token(
raw_token: str,
jwks_data: dict[str, Any],
config: OIDCConfig,
nonce: str,
) -> dict[str, Any]:
"""Validate and decode an OIDC ID token. Returns decoded claims.
*jwks_data* is the pre-fetched JWKS document (the ``{"keys": [...]}``
dict). No network I/O happens here the signing key is resolved
locally from the cached key set.
Raises :class:`OIDCError` on validation failure.
"""
import jwt
from jwt import PyJWK
# Extract kid from the token header to find the matching key.
try:
header = jwt.get_unverified_header(raw_token)
except jwt.DecodeError as exc:
raise OIDCError(f"Invalid ID token header: {exc}") from exc
kid = header.get("kid") # None if absent, not ""
# Find matching key in the JWKS by kid.
# PyJWK infers the key's algorithm from the JWKS ``alg``/``kty``
# fields. jwt.decode() requires the token header's ``alg`` to be in
# our _ALLOWED_ID_TOKEN_ALGS allowlist (asymmetric only) AND to match
# the key type — preventing algorithm confusion attacks.
signing_key = None
for key_dict in jwks_data.get("keys", []):
if kid is not None and key_dict.get("kid") == kid:
try:
signing_key = PyJWK(key_dict)
except Exception as exc:
raise OIDCError(f"Failed to parse signing key: {exc}") from exc
break
# Fallback: if token has no kid and JWKS has exactly one key, use it.
if signing_key is None and kid is None:
keys = jwks_data.get("keys", [])
if len(keys) == 1:
try:
signing_key = PyJWK(keys[0])
except Exception as exc:
raise OIDCError(f"Failed to parse signing key: {exc}") from exc
if signing_key is None:
raise OIDCError(f"Signing key '{kid}' not found in JWKS")
try:
claims: dict[str, Any] = jwt.decode(
raw_token,
signing_key.key,
algorithms=_ALLOWED_ID_TOKEN_ALGS,
audience=config.client_id,
issuer=config.issuer,
)
except jwt.InvalidTokenError as exc:
raise OIDCError(f"ID token validation failed: {exc}") from exc
if claims.get("nonce") != nonce:
raise OIDCError("ID token nonce mismatch")
return claims
# ---------------------------------------------------------------------------
# User provisioning
# ---------------------------------------------------------------------------
def provision_oidc_user(
storage: Any,
config: OIDCConfig,
claims: dict[str, Any],
) -> dict[str, str]:
"""Match or create a user from OIDC claims. Returns user dict.
Looks up an existing OIDC identity by (issuer, sub). If found,
updates ``last_login`` and applies role mapping. Otherwise creates
a new user and OIDC identity record.
Raises :class:`OIDCError` if user creation fails.
"""
issuer = config.issuer
sub = str(claims["sub"])
email = str(claims.get("email", ""))
display_name = str(claims.get("name", "") or claims.get("preferred_username", "") or email)
# Try to find existing identity
identity = storage.get_oidc_identity(issuer, sub)
if identity is not None:
user_id = identity["user_id"]
storage.update_oidc_identity_login(issuer, sub)
apply_role_mapping(storage, user_id, claims, config)
user: dict[str, str] | None = storage.get_user(user_id)
if user is None:
raise OIDCError(f"OIDC identity references missing user: {user_id}")
return user
# New user -- derive username
username = _derive_username(storage, claims)
user_id = uuid.uuid4().hex
storage.create_user(user_id, username, display_name, OIDC_PASSWORD_SENTINEL)
storage.create_oidc_identity(issuer, sub, user_id, email)
apply_role_mapping(storage, user_id, claims, config)
# Ensure new OIDC users have at least a default role so they can
# access the application. builtin-viewer grants read-only access.
user_roles = storage.list_user_roles(user_id)
if not user_roles and storage.get_role("builtin-viewer") is not None:
storage.assign_role(user_id, "builtin-viewer", "oidc-default")
created_user: dict[str, str] | None = storage.get_user(user_id)
if created_user is None:
raise OIDCError(f"Failed to retrieve newly created user: {user_id}")
log.info("Provisioned OIDC user: %s (%s) from %s", username, user_id, issuer)
return created_user
def _derive_username(storage: Any, claims: dict[str, Any]) -> str:
"""Derive a unique, valid username from OIDC claims."""
from turnstone.core.auth import is_valid_username
raw = str(claims.get("preferred_username", ""))
if not raw:
email = str(claims.get("email", ""))
raw = email.split("@")[0] if email else ""
if not raw:
raw = "user"
# Sanitise: keep only safe chars, truncate.
sanitised = _USERNAME_SAFE_RE.sub("", raw)[:64]
if not sanitised:
sanitised = "user"
# Check validity and uniqueness.
if is_valid_username(sanitised) and storage.get_user_by_username(sanitised) is None:
return sanitised
# Deduplicate: append suffix.
for suffix in range(2, 11):
candidate = f"{sanitised[:60]}{suffix}"
if is_valid_username(candidate) and storage.get_user_by_username(candidate) is None:
return candidate
# Last resort: full UUID suffix with validation + uniqueness check.
for _ in range(3):
candidate = f"{sanitised[:32]}{uuid.uuid4().hex}"
if not is_valid_username(candidate):
candidate = f"user{uuid.uuid4().hex}"
if storage.get_user_by_username(candidate) is None:
return candidate
raise OIDCError("Failed to generate unique username")
# ---------------------------------------------------------------------------
# Role mapping
# ---------------------------------------------------------------------------
def apply_role_mapping(
storage: Any,
user_id: str,
claims: dict[str, Any],
config: OIDCConfig,
) -> None:
"""Sync Turnstone roles from OIDC claims.
If ``config.role_claim`` is set, reads the corresponding claim value,
normalises it to a list, and maps each value via ``config.role_map``
to a Turnstone role ID. Roles assigned by OIDC on previous logins
that are no longer present in the claims are revoked (IdP demotions
propagate). Roles assigned manually or by other sources are never
touched.
"""
if not config.role_claim or not config.role_map:
return
claim_value = claims.get(config.role_claim)
# Normalise to list (could be string, list, or absent from IdP).
if claim_value is None:
values: list[str] = []
elif isinstance(claim_value, str):
values = [claim_value]
elif isinstance(claim_value, list):
values = [str(v) for v in claim_value]
else:
values = [str(claim_value)]
# Compute the set of roles the IdP says this user should have.
desired_role_ids: set[str] = set()
for value in values:
role_id = config.role_map.get(value)
if role_id and storage.get_role(role_id) is not None:
desired_role_ids.add(role_id)
# Add new roles from claims.
for role_id in desired_role_ids:
storage.assign_role(user_id, role_id, "oidc")
log.debug("Assigned role %s to user %s via OIDC claim", role_id, user_id)
# Revoke OIDC-assigned roles no longer present in claims.
current_roles = storage.list_user_roles(user_id)
for role in current_roles:
if role.get("assigned_by") == "oidc" and role["role_id"] not in desired_role_ids:
storage.unassign_role(user_id, role["role_id"])
log.info(
"Revoked role %s from user %s (removed from IdP claims)", role["role_id"], user_id
)
+175 -1
View File
@@ -540,12 +540,13 @@ class PostgreSQLBackend:
]
def delete_user(self, user_id: str) -> bool:
from turnstone.core.storage._schema import channel_users
from turnstone.core.storage._schema import channel_users, oidc_identities
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
conn.commit()
return result.rowcount > 0
@@ -2460,6 +2461,179 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
from sqlalchemy.dialects import postgresql
from turnstone.core.storage._schema import oidc_identities
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
postgresql.insert(oidc_identities)
.values(
issuer=issuer,
subject=subject,
user_id=user_id,
email=email,
created=now,
last_login=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
row = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
).fetchone()
if row:
return {
"issuer": row[0],
"subject": row[1],
"user_id": row[2],
"email": row[3],
"created": row[4],
"last_login": row[5],
}
return None
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
from turnstone.core.storage._schema import oidc_identities
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(
sa.update(oidc_identities)
.where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
.values(last_login=now)
)
conn.commit()
return result.rowcount > 0
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
)
.where(oidc_identities.c.user_id == user_id)
.order_by(oidc_identities.c.created.desc())
).fetchall()
return [
{
"issuer": r[0],
"subject": r[1],
"user_id": r[2],
"email": r[3],
"created": r[4],
"last_login": r[5],
}
for r in rows
]
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(oidc_identities).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC pending state ----------------------------------------------------
def create_oidc_pending_state(
self, state: str, nonce: str, code_verifier: str, audience: str
) -> None:
from turnstone.core.storage._schema import oidc_pending_states
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
"audience": audience,
"created_at": now,
},
)
conn.commit()
def pop_oidc_pending_state(
self, state: str, max_age_seconds: int = 300
) -> dict[str, str] | None:
from turnstone.core.storage._schema import oidc_pending_states
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._engine.connect() as conn:
# Atomic DELETE...RETURNING for true one-time consumption
row = conn.execute(
sa.text(
"DELETE FROM oidc_pending_states "
"WHERE state = :state AND created_at > :cutoff "
"RETURNING state, nonce, code_verifier, audience, created_at"
),
{"state": state, "cutoff": cutoff},
).fetchone()
# Also clean up the row if it existed but was expired
if not row:
conn.execute(
sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state)
)
conn.commit()
if not row:
return None
return {
"state": row[0],
"nonce": row[1],
"code_verifier": row[2],
"audience": row[3],
"created_at": row[4],
}
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
from turnstone.core.storage._schema import oidc_pending_states
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
)
conn.commit()
return result.rowcount
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+40
View File
@@ -259,6 +259,46 @@ class StorageBackend(Protocol):
"""Remove a channel user mapping. Returns True if existed."""
...
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
"""Link an OIDC subject to a turnstone user. No-op if exists."""
...
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
"""Lookup turnstone user by OIDC issuer+subject. Returns dict or None."""
...
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
"""Update last_login timestamp. Returns True if row existed."""
...
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
"""List all OIDC identities linked to a turnstone user."""
...
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
"""Remove an OIDC identity link. Returns True if existed."""
...
# -- OIDC pending state ----------------------------------------------------
def create_oidc_pending_state(
self, state: str, nonce: str, code_verifier: str, audience: str
) -> None:
"""Store OIDC authorization flow state for callback validation."""
...
def pop_oidc_pending_state(
self, state: str, max_age_seconds: int = 300
) -> dict[str, str] | None:
"""Fetch and delete pending state atomically. Returns None if expired or missing."""
...
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
"""Delete expired pending states. Returns count of deleted rows."""
...
# -- Channel routing -------------------------------------------------------
def create_channel_route(
+28
View File
@@ -469,3 +469,31 @@ mcp_servers = sa.Table(
)
sa.Index("idx_mcp_servers_enabled", mcp_servers.c.enabled)
# ---------------------------------------------------------------------------
# OIDC identity tables
# ---------------------------------------------------------------------------
oidc_identities = sa.Table(
"oidc_identities",
metadata,
sa.Column("issuer", sa.Text, nullable=False),
sa.Column("subject", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("email", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("last_login", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("issuer", "subject"),
)
sa.Index("idx_oidc_identities_user_id", oidc_identities.c.user_id)
oidc_pending_states = sa.Table(
"oidc_pending_states",
metadata,
sa.Column("state", sa.Text, primary_key=True),
sa.Column("nonce", sa.Text, nullable=False),
sa.Column("code_verifier", sa.Text, nullable=False),
sa.Column("audience", sa.Text, nullable=False),
sa.Column("created_at", sa.Text, nullable=False),
)
+174 -1
View File
@@ -589,12 +589,13 @@ class SQLiteBackend:
]
def delete_user(self, user_id: str) -> bool:
from turnstone.core.storage._schema import channel_users
from turnstone.core.storage._schema import channel_users, oidc_identities
with self._engine.connect() as conn:
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
conn.commit()
return result.rowcount > 0
@@ -2483,6 +2484,178 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
from turnstone.core.storage._schema import oidc_identities
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(oidc_identities).prefix_with("OR IGNORE"),
{
"issuer": issuer,
"subject": subject,
"user_id": user_id,
"email": email,
"created": now,
"last_login": now,
},
)
conn.commit()
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
row = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
).fetchone()
if row:
return {
"issuer": row[0],
"subject": row[1],
"user_id": row[2],
"email": row[3],
"created": row[4],
"last_login": row[5],
}
return None
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
from turnstone.core.storage._schema import oidc_identities
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
result = conn.execute(
sa.update(oidc_identities)
.where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
.values(last_login=now)
)
conn.commit()
return result.rowcount > 0
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
oidc_identities.c.issuer,
oidc_identities.c.subject,
oidc_identities.c.user_id,
oidc_identities.c.email,
oidc_identities.c.created,
oidc_identities.c.last_login,
)
.where(oidc_identities.c.user_id == user_id)
.order_by(oidc_identities.c.created.desc())
).fetchall()
return [
{
"issuer": r[0],
"subject": r[1],
"user_id": r[2],
"email": r[3],
"created": r[4],
"last_login": r[5],
}
for r in rows
]
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
from turnstone.core.storage._schema import oidc_identities
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(oidc_identities).where(
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC pending state ----------------------------------------------------
def create_oidc_pending_state(
self, state: str, nonce: str, code_verifier: str, audience: str
) -> None:
from turnstone.core.storage._schema import oidc_pending_states
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(oidc_pending_states),
{
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
"audience": audience,
"created_at": now,
},
)
conn.commit()
def pop_oidc_pending_state(
self, state: str, max_age_seconds: int = 300
) -> dict[str, str] | None:
from turnstone.core.storage._schema import oidc_pending_states
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._engine.connect() as conn:
# Acquire write lock before SELECT to prevent TOCTOU race
conn.execute(sa.text("BEGIN IMMEDIATE"))
row = conn.execute(
sa.select(
oidc_pending_states.c.state,
oidc_pending_states.c.nonce,
oidc_pending_states.c.code_verifier,
oidc_pending_states.c.audience,
oidc_pending_states.c.created_at,
).where(
(oidc_pending_states.c.state == state)
& (oidc_pending_states.c.created_at > cutoff)
)
).fetchone()
# Always delete the row (whether valid, expired, or missing is fine)
conn.execute(sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state))
conn.commit()
if not row:
return None
return {
"state": row[0],
"nonce": row[1],
"code_verifier": row[2],
"audience": row[3],
"created_at": row[4],
}
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
from turnstone.core.storage._schema import oidc_pending_states
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
)
conn.commit()
return result.rowcount
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
@@ -0,0 +1,42 @@
"""Create OIDC identity and pending state tables.
Revision ID: 018
Revises: 017
Create Date: 2026-03-15
"""
import sqlalchemy as sa
from alembic import op
revision = "018"
down_revision = "017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"oidc_identities",
sa.Column("issuer", sa.Text, nullable=False),
sa.Column("subject", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("email", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("last_login", sa.Text, nullable=False),
sa.PrimaryKeyConstraint("issuer", "subject"),
)
op.create_index("idx_oidc_identities_user_id", "oidc_identities", ["user_id"])
op.create_table(
"oidc_pending_states",
sa.Column("state", sa.Text, primary_key=True),
sa.Column("nonce", sa.Text, nullable=False),
sa.Column("code_verifier", sa.Text, nullable=False),
sa.Column("audience", sa.Text, nullable=False),
sa.Column("created_at", sa.Text, nullable=False),
)
def downgrade() -> None:
op.drop_table("oidc_pending_states")
op.drop_table("oidc_identities")
+57
View File
@@ -1582,6 +1582,27 @@ async def auth_setup(request: Request) -> Response:
return await handle_auth_setup(request, JWT_AUD_SERVER)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request)
async def oidc_authorize(request: Request) -> Response:
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
from turnstone.core.auth import handle_oidc_authorize
return await handle_oidc_authorize(request, JWT_AUD_SERVER)
async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_SERVER)
def config_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/config-reload — invalidate config cache."""
cs = getattr(request.app.state, "config_store", None)
@@ -1697,6 +1718,31 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# Start watch runner (periodic command polling)
if app.state.watch_runner:
app.state.watch_runner.start()
# OIDC discovery (if configured)
oidc_config = app.state.oidc_config
if oidc_config.enabled:
from turnstone.core.oidc import discover_oidc
try:
oidc_config = await discover_oidc(oidc_config)
app.state.oidc_config = oidc_config
except Exception:
log.warning("OIDC discovery failed — OIDC login disabled", exc_info=True)
if oidc_config.enabled and oidc_config.jwks_uri:
try:
from turnstone.core.oidc import fetch_jwks
app.state.jwks_data = await fetch_jwks(oidc_config.jwks_uri)
log.info(
"OIDC enabled: %s (%s)",
oidc_config.provider_name,
oidc_config.issuer,
)
except Exception:
log.warning(
"OIDC JWKS prefetch failed — will retry on first login",
exc_info=True,
)
yield
# Shutdown
if app.state.watch_runner:
@@ -1789,6 +1835,9 @@ def create_app(
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
Route("/api/auth/setup", auth_setup, methods=["POST"]),
Route("/api/auth/whoami", auth_whoami),
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
@@ -1825,6 +1874,14 @@ def create_app(
from turnstone.core.auth import LoginRateLimiter
app.state.login_limiter = LoginRateLimiter()
# OIDC configuration (opt-in via env vars)
from turnstone.core.oidc import load_oidc_config
oidc_config = load_oidc_config()
app.state.oidc_config = oidc_config
app.state.jwks_data = None # populated after async discovery
return app
+59
View File
@@ -47,6 +47,34 @@ function initLogin() {
overlay.innerHTML = _buildLoginHTML();
document.body.appendChild(overlay);
_bindLoginEvents();
// OIDC callback: detect success or error from URL params
var _oidcParams = new URLSearchParams(window.location.search);
var _oidcError = _oidcParams.get("oidc_error");
if (_oidcError) {
showLogin();
history.replaceState({}, "", window.location.pathname);
// Defer: showLogin() triggers async status fetch → _switchMode() → _clearError().
// Display after that settles.
var _pendingOidcError = _oidcError;
setTimeout(function () {
_showError(_pendingOidcError);
}, 300);
} else if (_oidcParams.get("oidc_success")) {
history.replaceState({}, "", window.location.pathname);
// Fetch permissions before completing login (cookie is already set)
fetch("/v1/api/auth/whoami")
.then(function (r) {
return r.ok ? r.json() : {};
})
.then(function (data) {
_storePermissions(data);
_onSuccess();
})
.catch(function () {
_onSuccess(); // Proceed even if permissions fetch fails
});
}
}
function _buildLoginHTML() {
@@ -57,6 +85,11 @@ function _buildLoginHTML() {
"</h2>" +
'<div id="login-subtitle" class="login-subtitle"></div>' +
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
// --- OIDC SSO button ---
'<div id="oidc-section" style="display:none">' +
'<button id="oidc-btn" class="oidc-btn" type="button">Continue with SSO</button>' +
'<div id="oidc-divider" class="oidc-divider"><span>or</span></div>' +
"</div>" +
// --- Setup mode fields ---
'<div id="setup-fields" style="display:none">' +
'<label for="setup-username" class="login-label">Username</label>' +
@@ -158,6 +191,31 @@ function _switchMode(mode) {
}
}
function _updateOIDCUI(data) {
var section = document.getElementById("oidc-section");
var btn = document.getElementById("oidc-btn");
var divider = document.getElementById("oidc-divider");
if (!section) return;
if (!data.oidc_enabled || _authMode === "setup") {
section.style.display = "none";
return;
}
section.style.display = "";
btn.textContent = "Continue with " + (data.oidc_provider_name || "SSO");
btn.onclick = function () {
window.location.href = "/v1/api/auth/oidc/authorize";
};
if (data.password_enabled === false) {
document.getElementById("login-fields").style.display = "none";
document.getElementById("login-toggle").style.display = "none";
document.getElementById("login-submit").style.display = "none";
divider.style.display = "none";
}
}
function _clearError() {
var errEl = document.getElementById("login-error");
if (errEl && errEl.style.display !== "none") {
@@ -194,6 +252,7 @@ function showLogin() {
} else {
_switchMode("login");
}
_updateOIDCUI(data);
})
.catch(function () {
// Fallback to login mode
+49
View File
@@ -377,6 +377,55 @@ body {
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
/* OIDC / SSO */
.oidc-btn {
width: 100%;
padding: 11px;
background: var(--bg-highlight);
color: var(--fg-bright);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
letter-spacing: 0.02em;
}
.oidc-btn:hover {
background: var(--bg-elevated);
border-color: var(--accent);
}
.oidc-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.oidc-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.oidc-divider {
display: flex;
align-items: center;
margin: 16px 0;
gap: 12px;
}
.oidc-divider::before,
.oidc-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-strong);
}
.oidc-divider span {
font-family: var(--font-display);
font-size: 10px;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ==========================================================================
Keyboard shortcuts overlay
========================================================================== */