diff --git a/README.md b/README.md index db6ec718..38f24f62 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/diagrams/25-oidc-architecture.puml b/docs/diagrams/25-oidc-architecture.puml new file mode 100644 index 00000000..be550933 --- /dev/null +++ b/docs/diagrams/25-oidc-architecture.puml @@ -0,0 +1,147 @@ +@startuml +!theme plain +title Turnstone — OIDC Authorization Code Flow with PKCE + +skinparam participant { + BackgroundColor<> #E8EAF6 + BackgroundColor<> #FFE0B2 + BackgroundColor<> #B3E5FC + BackgroundColor<> #C8E6C9 +} + +participant "Browser" as Browser <> +participant "Turnstone\n(Server / Console)" as Server <> +database "SQLite /\nPostgreSQL" as DB <> +participant "Identity Provider\n(IdP)" as 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 diff --git a/docs/diagrams/png/25-oidc-architecture.png b/docs/diagrams/png/25-oidc-architecture.png new file mode 100644 index 00000000..dd0be53c --- /dev/null +++ b/docs/diagrams/png/25-oidc-architecture.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259 +size 309584 diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 00000000..b9e18e9a --- /dev/null +++ b/docs/oidc.md @@ -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) diff --git a/docs/security.md b/docs/security.md index 6a81afab..479e4440 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index 70fd614c..c8dd448f 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -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": [ diff --git a/sdk/typescript/openapi-server.json b/sdk/typescript/openapi-server.json index e612782e..2f9d3b31 100644 --- a/sdk/typescript/openapi-server.json +++ b/sdk/typescript/openapi-server.json @@ -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": [ diff --git a/tests/test_auth.py b/tests/test_auth.py index 1747bb8f..6b0338d7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_oidc.py b/tests/test_oidc.py new file mode 100644 index 00000000..63830d96 --- /dev/null +++ b/tests/test_oidc.py @@ -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 diff --git a/tests/test_oidc_storage.py b/tests/test_oidc_storage.py new file mode 100644 index 00000000..de97752d --- /dev/null +++ b/tests/test_oidc_storage.py @@ -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 diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 39c38cf1..38343273 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -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", diff --git a/turnstone/api/schemas.py b/turnstone/api/schemas.py index 136b8694..7aec9255 100644 --- a/turnstone/api/schemas.py +++ b/turnstone/api/schemas.py @@ -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 = "" # --------------------------------------------------------------------------- diff --git a/turnstone/api/server_spec.py b/turnstone/api/server_spec.py index 37b60921..754fee8b 100644 --- a/turnstone/api/server_spec.py +++ b/turnstone/api/server_spec.py @@ -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", diff --git a/turnstone/bootstrap.py b/turnstone/bootstrap.py index 87e19550..00eb40df 100644 --- a/turnstone/bootstrap.py +++ b/turnstone/bootstrap.py @@ -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 \ diff --git a/turnstone/console/server.py b/turnstone/console/server.py index a17ff75e..7eb6fcb5 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -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 diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 3bd01853..2677ce52 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -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 diff --git a/turnstone/core/oidc.py b/turnstone/core/oidc.py new file mode 100644 index 00000000..a256ab23 --- /dev/null +++ b/turnstone/core/oidc.py @@ -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 + ) diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 18a0cd29..d01dd3ce 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -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: diff --git a/turnstone/core/storage/_protocol.py b/turnstone/core/storage/_protocol.py index b1adb9c6..d13302fa 100644 --- a/turnstone/core/storage/_protocol.py +++ b/turnstone/core/storage/_protocol.py @@ -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( diff --git a/turnstone/core/storage/_schema.py b/turnstone/core/storage/_schema.py index 2a8a2406..e5f0ecd6 100644 --- a/turnstone/core/storage/_schema.py +++ b/turnstone/core/storage/_schema.py @@ -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), +) diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 364c02b3..d4d89b69 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -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: diff --git a/turnstone/core/storage/migrations/versions/018_oidc_identities.py b/turnstone/core/storage/migrations/versions/018_oidc_identities.py new file mode 100644 index 00000000..9d682c0f --- /dev/null +++ b/turnstone/core/storage/migrations/versions/018_oidc_identities.py @@ -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") diff --git a/turnstone/server.py b/turnstone/server.py index 929e0953..35c39508 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -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 diff --git a/turnstone/shared_static/auth.js b/turnstone/shared_static/auth.js index 41809c41..13c97d8d 100644 --- a/turnstone/shared_static/auth.js +++ b/turnstone/shared_static/auth.js @@ -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() { "" + '' + '' + + // --- OIDC SSO button --- + '" + // --- Setup mode fields --- '