mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: OIDC SSO authentication with PKCE, auto-provisioning, and role … (#71)
* feat: OIDC SSO authentication with PKCE, auto-provisioning, and role mapping Add OpenID Connect as a fourth authentication method, enabling single sign-on via any OIDC provider (Okta, Azure AD, Google, Keycloak). Opt-in via env vars (TURNSTONE_OIDC_ISSUER, CLIENT_ID, CLIENT_SECRET). Security: - Authorization Code Flow with PKCE (S256) - State/nonce parameters with database-backed pending store (multi-node safe) - JWKS signature validation with async fetch + key rotation retry - Algorithm allowlist from JWKS key (not token header) prevents confusion - Identity matching exclusively by (issuer, sub) — prevents account takeover - password_enabled=false enforced server-side, not just UI - Rate limiting on both authorize and callback endpoints - OIDC users get "!oidc" password sentinel (bcrypt rejects naturally) - ID token validated for iss, aud, exp, nonce Features: - Auto-provisioning with username deduplication on first login - Claim-based role mapping with IdP demotion propagation (revokes stale roles) - "Continue with [Provider]" SSO button on login page - OIDC-only mode hides password form - Setup wizard required before OIDC login (admin bootstrap) Storage: migration 018 (oidc_identities + oidc_pending_states tables), 8 new protocol methods on both SQLite and PostgreSQL backends. 66 new tests (2273 total). * fix: address PR #71 review feedback (18 items) Bugs fixed: - OIDC success redirect now fetches permissions via new /auth/whoami endpoint before completing login (fixes permission-gating in UI) - Remove double decodeURIComponent on oidc_error (URLSearchParams already decodes; extra call throws on stray %) - Authorize rate limiter returns redirect instead of JSON 429 (endpoint reached via browser navigation, not fetch) - Lazy JWKS fetch in callback when startup discovery failed (IdP recovery without restart) - Startup exception handlers now log with exc_info=True - PostgreSQL pop_oidc_pending_state uses DELETE...RETURNING for true atomicity (eliminates TOCTOU) Behavior: - New OIDC users without role mapping get builtin-viewer by default (assigned_by="oidc-default", not revoked by role sync) Documentation fixes: - Role mapping: sync semantics (add + revoke stale), not "additive only" - PASSWORD_ENABLED=false blocks ALL password logins including admin - Algorithm: asymmetric allowlist, not per-key derivation - PlantUML diagram updated for role revocation API spec fixes: - Removed error_codes=[302] from callback (302 is success redirect) - Added /auth/whoami to both server + console specs - Regenerated TypeScript SDK OpenAPI snapshots (23 + 51 paths) * fix: address PR #71 round 2 review feedback (10 items) Rate limiting: - Authorize endpoint now calls record() after check() so the rate limiter actually counts attempts (was a no-op before) OIDC resilience: - Split startup try/except: discovery failure disables OIDC, JWKS prefetch failure leaves OIDC enabled for lazy retry on first login - JWKS unavailable message changed to "temporarily unavailable" (was misleadingly "not configured") - create_oidc_pending_state raises on collision instead of OR IGNORE (prevents silent insert drop on state collision) - SQLite pop_oidc_pending_state uses BEGIN IMMEDIATE for write lock (eliminates TOCTOU race) Frontend: - OIDC error display deferred 300ms so showLogin()'s async status fetch doesn't clear it via _switchMode → _clearError API spec: - OIDC authorize/callback endpoints now declare response_code=302 - Added AuthWhoamiResponse Pydantic model for /auth/whoami - Regenerated TypeScript SDK OpenAPI snapshots Documentation: - Diagram: JWKS "cached at startup, refreshed on-demand" (was "hourly") - Added TODO(tech-debt) comments on Host header redirect_uri sites
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — OIDC Authorization Code Flow with PKCE
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<browser>> #E8EAF6
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<idp>> #C8E6C9
|
||||
}
|
||||
|
||||
participant "Browser" as Browser <<browser>>
|
||||
participant "Turnstone\n(Server / Console)" as Server <<server>>
|
||||
database "SQLite /\nPostgreSQL" as DB <<storage>>
|
||||
participant "Identity Provider\n(IdP)" as IdP <<idp>>
|
||||
|
||||
== Page Load ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/status
|
||||
Server --> Browser : {oidc_enabled: true,\noidc_provider_name: "...",\npassword_enabled: true}
|
||||
|
||||
note right of Browser
|
||||
Login screen renders
|
||||
"Continue with {provider_name}"
|
||||
button alongside password form.
|
||||
If password_enabled=false,
|
||||
only the SSO button is shown.
|
||||
end note
|
||||
|
||||
== Authorization Request ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/authorize
|
||||
|
||||
Server -> Server : Generate state (random)\nnonce (random)\nPKCE code_verifier + code_challenge
|
||||
|
||||
Server -> DB : create_oidc_pending_state(\nstate, nonce, code_verifier, audience)
|
||||
note right of DB
|
||||
Stored with created_at timestamp.
|
||||
Expires after 5 minutes.
|
||||
end note
|
||||
|
||||
Server --> Browser : 302 Redirect to IdP\nauthorization_endpoint
|
||||
|
||||
Browser -> IdP : GET /authorize?\nresponse_type=code&\nclient_id=...&\nredirect_uri=...&\nscope=openid email profile&\nstate=...&nonce=...&\ncode_challenge=...&\ncode_challenge_method=S256
|
||||
|
||||
== User Authentication (at IdP) ==
|
||||
|
||||
IdP -> Browser : Login page (if no\nexisting IdP session)
|
||||
Browser -> IdP : User authenticates\n(username/password, MFA, etc.)
|
||||
|
||||
IdP --> Browser : 302 Redirect to callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
== Callback Processing ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
Server -> Server : Rate limit check\n(5 per 5min per IP)
|
||||
|
||||
Server -> DB : cleanup_expired_oidc_states(300)
|
||||
note right of DB
|
||||
Lazy cleanup of states
|
||||
older than 5 minutes.
|
||||
end note
|
||||
|
||||
Server -> DB : pop_oidc_pending_state(state)
|
||||
DB --> Server : {nonce, code_verifier, audience}
|
||||
note right of Server
|
||||
Atomic fetch-and-delete.
|
||||
Returns None if state is
|
||||
expired or unknown.
|
||||
end note
|
||||
|
||||
== Token Exchange ==
|
||||
|
||||
Server -> IdP : POST /token\ngrant_type=authorization_code&\ncode=AUTH_CODE&\nclient_id=...&\nclient_secret=...&\ncode_verifier=...&\nredirect_uri=...
|
||||
note right of Server
|
||||
Client secret + PKCE verifier
|
||||
sent server-side only.
|
||||
Never exposed to browser.
|
||||
end note
|
||||
|
||||
IdP --> Server : {id_token: "eyJ...",\naccess_token: "..."}
|
||||
|
||||
== ID Token Validation ==
|
||||
|
||||
Server -> IdP : Fetch JWKS public keys\n(cached at startup, refreshed\non-demand when unknown kid\nencountered — key rotation)
|
||||
|
||||
Server -> Server : Validate ID token:\n1. Verify signature (RS256/ES256)\n2. Check iss == configured issuer\n3. Check aud == client_id\n4. Check exp (not expired)\n5. Verify nonce matches
|
||||
|
||||
== User Provisioning ==
|
||||
|
||||
Server -> DB : get_oidc_identity(issuer, sub)
|
||||
|
||||
alt Existing identity found
|
||||
DB --> Server : {user_id, ...}
|
||||
Server -> DB : update_oidc_identity_login()\nupdate last_login timestamp
|
||||
Server -> DB : get_user(user_id)
|
||||
DB --> Server : user record
|
||||
else New user (first login)
|
||||
Server -> Server : Derive username from\npreferred_username / email
|
||||
Server -> DB : create_user(user_id, username,\ndisplay_name, "!oidc")
|
||||
note right of DB
|
||||
Password hash set to sentinel
|
||||
value "!oidc" — not a valid
|
||||
bcrypt hash, so password login
|
||||
is always rejected.
|
||||
end note
|
||||
Server -> DB : create_oidc_identity(\nissuer, sub, user_id, email)
|
||||
end
|
||||
|
||||
opt Role mapping configured
|
||||
Server -> Server : Read role_claim from ID token\nMap values via role_map
|
||||
Server -> DB : Sync roles: add new,\nrevoke stale OIDC-assigned,\npreserve manually assigned
|
||||
end
|
||||
|
||||
== Issue Turnstone JWT ==
|
||||
|
||||
Server -> Server : Load user permissions\nDerive scopes from permissions
|
||||
Server -> Server : Create JWT (HS256)\nsub: user_id\nscopes: read,write,...\nsrc: "oidc"\naud: turnstone-server\nexp: +24h
|
||||
|
||||
Server --> Browser : 302 Redirect to /?oidc_success=1\nSet-Cookie: session=JWT\n(HttpOnly, SameSite=Lax, Secure)
|
||||
|
||||
== Browser Success Detection ==
|
||||
|
||||
Browser -> Browser : Detect ?oidc_success=1\nStrip param from URL\n(history.replaceState)
|
||||
Browser -> Browser : Hide login overlay\nCall onLoginSuccess()
|
||||
|
||||
note right of Browser
|
||||
Browser is now authenticated.
|
||||
JWT cookie sent on all
|
||||
subsequent requests.
|
||||
end note
|
||||
|
||||
== Error Paths ==
|
||||
|
||||
note over Browser, IdP
|
||||
**Error handling:**
|
||||
- IdP returns error param → redirect to /?oidc_error=...
|
||||
- State missing/expired → redirect to /?oidc_error=Login+session+expired
|
||||
- Token exchange fails → redirect to /?oidc_error=...
|
||||
- ID token validation fails → redirect to /?oidc_error=...
|
||||
- No admin user exists → redirect to /?oidc_error=Initial+setup+required
|
||||
- Rate limit exceeded → redirect to /?oidc_error=Too+many+login+attempts
|
||||
All errors are shown as toast messages on the login screen.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259
|
||||
size 309584
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# OpenID Connect (OIDC) Single Sign-On
|
||||
|
||||
Turnstone supports OpenID Connect for federated authentication, allowing
|
||||
users to log in with their existing corporate identity provider instead of
|
||||
managing a separate password. OIDC is opt-in: when configured, the login
|
||||
screen shows a "Continue with SSO" button alongside the existing
|
||||
username/password form. When not configured, the login experience is
|
||||
unchanged.
|
||||
|
||||
Any OIDC-compliant provider works: Google, Okta, Azure AD, Keycloak,
|
||||
Auth0, OneLogin, and others that publish a
|
||||
`.well-known/openid-configuration` discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A registered **confidential** OIDC client at your identity provider
|
||||
2. The client's redirect URI must include:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. A local admin user must exist in Turnstone (complete the initial setup
|
||||
wizard before enabling OIDC)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
OIDC is configured via environment variables (preferred) or the `[oidc]`
|
||||
section of `config.toml`. Environment variables take precedence when both
|
||||
are set.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `TURNSTONE_OIDC_ISSUER` | Yes | — | Issuer URL (e.g. `https://accounts.google.com`). Must serve `/.well-known/openid-configuration`. |
|
||||
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | — | OAuth 2.0 client ID from your provider |
|
||||
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | — | OAuth 2.0 client secret (confidential client) |
|
||||
| `TURNSTONE_OIDC_SCOPES` | No | `openid email profile` | Space-separated OAuth scopes to request |
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
|
||||
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
secret) are non-empty. If any is missing, OIDC is silently disabled and
|
||||
the login screen shows only the password form.
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
issuer = "https://accounts.google.com"
|
||||
client_id = "your-client-id"
|
||||
client_secret = "your-client-secret"
|
||||
scopes = "openid email profile"
|
||||
provider_name = "Google"
|
||||
role_claim = "groups"
|
||||
password_enabled = true
|
||||
|
||||
[oidc.role_map]
|
||||
admin = "builtin-admin"
|
||||
engineering = "builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider-Specific Setup
|
||||
|
||||
### Google
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) >
|
||||
**APIs & Services** > **Credentials**
|
||||
2. Click **Create Credentials** > **OAuth 2.0 Client ID**
|
||||
3. Application type: **Web application**
|
||||
4. Add authorized redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Copy the **Client ID** and **Client secret**
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://accounts.google.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=GOCSPX-...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Google
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
1. In the Okta Admin Console, go to **Applications** > **Create App
|
||||
Integration**
|
||||
2. Sign-in method: **OIDC - OpenID Connect**
|
||||
3. Application type: **Web Application**
|
||||
4. Add sign-in redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Note the **Issuer** (your Okta domain, e.g.
|
||||
`https://dev-123456.okta.com`)
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://dev-123456.okta.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=0oaXXXXXXXXXXXXX
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Okta
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,everyone:builtin-operator"
|
||||
```
|
||||
|
||||
### Azure AD (Entra ID)
|
||||
|
||||
1. In the Azure Portal, go to **App registrations** > **New registration**
|
||||
2. Redirect URI: **Web** >
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. Under **Certificates & secrets**, create a new **Client secret** and
|
||||
copy the value immediately
|
||||
4. The issuer URL is
|
||||
`https://login.microsoftonline.com/{tenant-id}/v2.0`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0
|
||||
TURNSTONE_OIDC_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME="Azure AD"
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="Admin:builtin-admin,User:builtin-operator"
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In the Keycloak Admin Console, select your **Realm**
|
||||
2. Go to **Clients** > **Create client**
|
||||
3. Client type: **OpenID Connect**
|
||||
4. Set **Client authentication** to **On** (confidential)
|
||||
5. Add valid redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
6. The issuer URL is
|
||||
`https://keycloak.example.com/realms/your-realm`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm
|
||||
TURNSTONE_OIDC_CLIENT_ID=turnstone
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Keycloak
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=realm_access.roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,operator:builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role Mapping
|
||||
|
||||
OIDC role mapping assigns Turnstone roles to users based on claims in the
|
||||
ID token. This is optional — without it, OIDC users are provisioned with
|
||||
the `builtin-viewer` role (read-only access) by default.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `TURNSTONE_OIDC_ROLE_CLAIM` to the name of the claim in the ID token
|
||||
that contains the user's group or role memberships. Then set
|
||||
`TURNSTONE_OIDC_ROLE_MAP` to map claim values to Turnstone role IDs.
|
||||
|
||||
The role map is a comma-separated list of `claim_value:turnstone_role`
|
||||
pairs:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer:builtin-viewer"
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Synced on every login**: roles are added when new claim values appear,
|
||||
and OIDC-assigned roles are revoked when the corresponding claim value
|
||||
is no longer present. Roles assigned manually (or by other sources) are
|
||||
never touched — only roles with `assigned_by="oidc"` are subject to
|
||||
revocation.
|
||||
- **List or string**: the claim value can be a JSON array
|
||||
(`["admin", "engineering"]`) or a single string (`"admin"`). Both are
|
||||
handled correctly.
|
||||
- **Unknown values**: claim values not present in the role map are silently
|
||||
ignored.
|
||||
- **Missing roles**: if the role map references a Turnstone role ID that
|
||||
does not exist in the database, the assignment is skipped (no error).
|
||||
- **Evaluated on every login**: roles are checked and applied each time
|
||||
the user authenticates via OIDC, so new group memberships are picked
|
||||
up on the next login.
|
||||
|
||||
### Built-in Roles
|
||||
|
||||
| Role ID | Permissions |
|
||||
|---------|-------------|
|
||||
| `builtin-admin` | All permissions |
|
||||
| `builtin-operator` | read, write, workstreams.create, workstreams.close |
|
||||
| `builtin-viewer` | read |
|
||||
|
||||
---
|
||||
|
||||
## User Provisioning
|
||||
|
||||
When a user logs in via OIDC for the first time, Turnstone automatically
|
||||
creates a local user account:
|
||||
|
||||
1. The OIDC identity (`issuer` + `sub` claim) is stored in the
|
||||
`oidc_identities` table and linked to the new user
|
||||
2. The **username** is derived from the `preferred_username` claim,
|
||||
falling back to the email local part, with deduplication if needed
|
||||
3. The **display name** comes from the `name` claim, falling back to
|
||||
`preferred_username` or email
|
||||
4. The user's password hash is set to a sentinel value (`!oidc`) — OIDC
|
||||
users cannot log in with a password
|
||||
|
||||
On subsequent logins, the existing user is matched by `(issuer, sub)` and
|
||||
the `last_login` timestamp is updated. Role mapping is re-evaluated on
|
||||
every login.
|
||||
|
||||
---
|
||||
|
||||
## OIDC-Only Mode
|
||||
|
||||
To enforce OIDC for all logins and hide the password form, set:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_PASSWORD_ENABLED=false
|
||||
```
|
||||
|
||||
In this mode the login screen shows only the "Continue with SSO" button.
|
||||
The password form, token toggle, and sign-in button are all hidden.
|
||||
All username/password logins are blocked at the API level, including
|
||||
admin accounts.
|
||||
|
||||
The first admin account must be created via the setup wizard (with a
|
||||
password) before OIDC is enabled. The setup wizard always works
|
||||
regardless of this setting because it is only available when zero users
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow
|
||||
|
||||
Both the server and console support OIDC login. The flow is identical:
|
||||
|
||||
1. The browser fetches `GET /v1/api/auth/status` at page load
|
||||
2. If the response includes `oidc_enabled: true`, the login screen shows
|
||||
a "Continue with {provider_name}" button
|
||||
3. Clicking the button navigates to `GET /v1/api/auth/oidc/authorize`
|
||||
4. Turnstone generates a state token, nonce, and PKCE verifier, stores
|
||||
them in the database, and redirects the browser to the identity
|
||||
provider's authorization endpoint
|
||||
5. The user authenticates at the identity provider
|
||||
6. The IdP redirects back to
|
||||
`GET /v1/api/auth/oidc/callback?code=...&state=...`
|
||||
7. Turnstone validates the state, exchanges the authorization code for
|
||||
tokens using the PKCE verifier, validates the ID token against the
|
||||
provider's JWKS public keys, provisions or matches the user, and
|
||||
issues a Turnstone JWT
|
||||
8. The browser is redirected to `/?oidc_success=1` with the JWT set in
|
||||
an `HttpOnly` session cookie
|
||||
9. The browser JavaScript detects the `oidc_success` query parameter,
|
||||
strips it from the URL, hides the login overlay, and calls
|
||||
`onLoginSuccess()` to initialize the application
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/api/auth/oidc/authorize` | Public | Redirects to identity provider |
|
||||
| GET | `/v1/api/auth/oidc/callback` | Public | Handles IdP callback, issues JWT |
|
||||
|
||||
Both endpoints are public (no authentication required) because they are
|
||||
part of the login flow itself.
|
||||
|
||||
### Auth status response
|
||||
|
||||
When OIDC is enabled, `GET /v1/api/auth/status` includes additional
|
||||
fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_enabled": true,
|
||||
"has_users": true,
|
||||
"setup_required": false,
|
||||
"oidc_enabled": true,
|
||||
"oidc_provider_name": "Google",
|
||||
"password_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 018 creates two tables:
|
||||
|
||||
```sql
|
||||
CREATE TABLE oidc_identities (
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL,
|
||||
last_login TEXT NOT NULL,
|
||||
PRIMARY KEY (issuer, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_identities_user_id ON oidc_identities(user_id);
|
||||
|
||||
CREATE TABLE oidc_pending_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
audience TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
The `oidc_identities` table links an OIDC subject (identified by
|
||||
`issuer` + `subject`) to a Turnstone `user_id`. A single user can have
|
||||
multiple OIDC identities (e.g. from different providers).
|
||||
|
||||
The `oidc_pending_states` table stores authorization flow state for
|
||||
callback validation. Entries are automatically cleaned up after 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Authorization Code Flow with PKCE**: the recommended OAuth 2.0 flow
|
||||
for web applications. PKCE prevents authorization code interception
|
||||
attacks even without a client secret (though the client secret is still
|
||||
used for additional security).
|
||||
- **ID token validation**: all tokens are validated using the provider's
|
||||
JWKS public keys (RS256 or ES256). The signature, issuer, audience,
|
||||
and expiry are all checked.
|
||||
- **State parameter**: a cryptographically random state token prevents
|
||||
CSRF attacks on the callback endpoint. The state is stored server-side
|
||||
and verified on callback.
|
||||
- **Nonce**: a random nonce is included in the authorization request and
|
||||
verified in the ID token to prevent replay attacks.
|
||||
- **Client secret**: never leaves the server — it is only used in the
|
||||
server-to-IdP token exchange, not exposed to the browser.
|
||||
- **OIDC users cannot use password login**: the sentinel password hash
|
||||
(`!oidc`) ensures `verify_password()` always rejects password attempts
|
||||
for OIDC-provisioned users.
|
||||
- **Rate limiting**: the callback endpoint shares the login rate limiter
|
||||
(5 attempts per 5-minute window per IP).
|
||||
- **State TTL**: pending authorization states expire after 5 minutes.
|
||||
Expired states are lazily cleaned up on each callback.
|
||||
- **Setup guard**: OIDC login requires at least one local admin user to
|
||||
exist. This ensures the initial admin account is always created via the
|
||||
setup wizard with a password, not hijacked by an external identity.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OIDC not configured"
|
||||
|
||||
All three required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`, and
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`. Check that none are empty or
|
||||
whitespace-only.
|
||||
|
||||
### "Login session expired"
|
||||
|
||||
The authorization flow must complete within 5 minutes. If the user takes
|
||||
too long at the identity provider, the pending state expires. Try again.
|
||||
|
||||
### "Initial setup required"
|
||||
|
||||
OIDC login is blocked until at least one local admin user exists.
|
||||
Complete the setup wizard first (navigate to the Turnstone URL and follow
|
||||
the prompts to create an admin user with a password).
|
||||
|
||||
### Discovery fails at startup
|
||||
|
||||
Check that the issuer URL is reachable from the Turnstone server and
|
||||
serves a valid `/.well-known/openid-configuration` document. The server
|
||||
logs the discovery attempt at startup:
|
||||
|
||||
```
|
||||
OIDC discovery failed for https://your-issuer.example.com: ...
|
||||
```
|
||||
|
||||
OIDC is automatically disabled when discovery fails. Restart the server
|
||||
after fixing the connectivity issue.
|
||||
|
||||
### Redirect URI mismatch
|
||||
|
||||
The redirect URI configured at the identity provider must exactly match
|
||||
`https://your-host/v1/api/auth/oidc/callback`. Common issues:
|
||||
|
||||
- **Scheme mismatch**: the redirect uses `https://` — make sure TLS is
|
||||
configured or a reverse proxy sets the `X-Forwarded-Proto` header
|
||||
- **Port mismatch**: if running on a non-standard port, include it in
|
||||
the redirect URI
|
||||
- **Path mismatch**: the path must include the `/v1` API version prefix
|
||||
|
||||
### User not assigned expected roles
|
||||
|
||||
Check that:
|
||||
|
||||
1. `TURNSTONE_OIDC_ROLE_CLAIM` matches the exact claim name in the ID
|
||||
token (case-sensitive)
|
||||
2. `TURNSTONE_OIDC_ROLE_MAP` maps the correct claim values to valid
|
||||
Turnstone role IDs
|
||||
3. The roles referenced in the map exist in the database (check the
|
||||
admin panel > Roles tab)
|
||||
4. The identity provider is configured to include the claim in the ID
|
||||
token (some providers require explicit scope or claim configuration)
|
||||
+101
-2
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ from turnstone.api.schemas import (
|
||||
AuthSetupRequest,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
AuthWhoamiResponse,
|
||||
ErrorResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
@@ -196,6 +197,29 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=StatusResponse,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/authorize",
|
||||
"GET",
|
||||
"Redirect to OIDC provider for SSO login",
|
||||
response_code=302,
|
||||
error_codes=[404, 503],
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/oidc/callback",
|
||||
"GET",
|
||||
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
|
||||
response_code=302,
|
||||
tags=["Auth"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/whoami",
|
||||
"GET",
|
||||
"Return authenticated user info and permissions",
|
||||
response_model=AuthWhoamiResponse,
|
||||
error_codes=[401],
|
||||
tags=["Auth"],
|
||||
),
|
||||
# --- Memories ---
|
||||
EndpointSpec(
|
||||
"/v1/api/memories",
|
||||
|
||||
+18
-1
@@ -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 \
|
||||
|
||||
@@ -323,6 +323,27 @@ async def auth_setup(request: Request) -> Response:
|
||||
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
async def auth_whoami(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/whoami — return authenticated user info."""
|
||||
from turnstone.core.auth import handle_auth_whoami
|
||||
|
||||
return await handle_auth_whoami(request)
|
||||
|
||||
|
||||
async def oidc_authorize(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
|
||||
from turnstone.core.auth import handle_oidc_authorize
|
||||
|
||||
return await handle_oidc_authorize(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
|
||||
from turnstone.core.auth import handle_oidc_callback
|
||||
|
||||
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — workstream creation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -674,6 +695,31 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
if scheduler is not None:
|
||||
scheduler.start()
|
||||
# OIDC discovery (if configured)
|
||||
oidc_config = app.state.oidc_config
|
||||
if oidc_config.enabled:
|
||||
from turnstone.core.oidc import discover_oidc
|
||||
|
||||
try:
|
||||
oidc_config = await discover_oidc(oidc_config)
|
||||
app.state.oidc_config = oidc_config
|
||||
except Exception:
|
||||
log.warning("OIDC discovery failed — OIDC login disabled", exc_info=True)
|
||||
if oidc_config.enabled and oidc_config.jwks_uri:
|
||||
try:
|
||||
from turnstone.core.oidc import fetch_jwks
|
||||
|
||||
app.state.jwks_data = await fetch_jwks(oidc_config.jwks_uri)
|
||||
log.info(
|
||||
"OIDC enabled: %s (%s)",
|
||||
oidc_config.provider_name,
|
||||
oidc_config.issuer,
|
||||
)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
# Shutdown
|
||||
if scheduler is not None:
|
||||
@@ -3557,6 +3603,9 @@ def create_app(
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Route("/api/auth/status", auth_status),
|
||||
Route("/api/auth/setup", auth_setup, methods=["POST"]),
|
||||
Route("/api/auth/whoami", auth_whoami),
|
||||
Route("/api/auth/oidc/authorize", oidc_authorize),
|
||||
Route("/api/auth/oidc/callback", oidc_callback),
|
||||
Route("/api/admin/users", admin_list_users),
|
||||
Route("/api/admin/users", admin_create_user, methods=["POST"]),
|
||||
Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]),
|
||||
@@ -3747,6 +3796,13 @@ def create_app(
|
||||
|
||||
app.state.login_limiter = LoginRateLimiter()
|
||||
|
||||
# OIDC configuration (opt-in via env vars)
|
||||
from turnstone.core.oidc import load_oidc_config
|
||||
|
||||
oidc_config = load_oidc_config()
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.jwks_data = None # populated after async discovery
|
||||
|
||||
# Scheduler — start background thread if storage is available
|
||||
if auth_storage is not None:
|
||||
from turnstone.console.scheduler import TaskScheduler
|
||||
|
||||
+245
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -589,12 +589,13 @@ class SQLiteBackend:
|
||||
]
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
from turnstone.core.storage._schema import channel_users, oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
conn.execute(sa.delete(oidc_identities).where(oidc_identities.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -2483,6 +2484,178 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_identities).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"issuer": issuer,
|
||||
"subject": subject,
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"created": now,
|
||||
"last_login": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_oidc_identity(self, issuer: str, subject: str) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"issuer": row[0],
|
||||
"subject": row[1],
|
||||
"user_id": row[2],
|
||||
"email": row[3],
|
||||
"created": row[4],
|
||||
"last_login": row[5],
|
||||
}
|
||||
return None
|
||||
|
||||
def update_oidc_identity_login(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(oidc_identities)
|
||||
.where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
.values(last_login=now)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_oidc_identities_for_user(self, user_id: str) -> list[dict[str, str]]:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
oidc_identities.c.issuer,
|
||||
oidc_identities.c.subject,
|
||||
oidc_identities.c.user_id,
|
||||
oidc_identities.c.email,
|
||||
oidc_identities.c.created,
|
||||
oidc_identities.c.last_login,
|
||||
)
|
||||
.where(oidc_identities.c.user_id == user_id)
|
||||
.order_by(oidc_identities.c.created.desc())
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"issuer": r[0],
|
||||
"subject": r[1],
|
||||
"user_id": r[2],
|
||||
"email": r[3],
|
||||
"created": r[4],
|
||||
"last_login": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def delete_oidc_identity(self, issuer: str, subject: str) -> bool:
|
||||
from turnstone.core.storage._schema import oidc_identities
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_identities).where(
|
||||
(oidc_identities.c.issuer == issuer) & (oidc_identities.c.subject == subject)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC pending state ----------------------------------------------------
|
||||
|
||||
def create_oidc_pending_state(
|
||||
self, state: str, nonce: str, code_verifier: str, audience: str
|
||||
) -> None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(oidc_pending_states),
|
||||
{
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_verifier": code_verifier,
|
||||
"audience": audience,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def pop_oidc_pending_state(
|
||||
self, state: str, max_age_seconds: int = 300
|
||||
) -> dict[str, str] | None:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
# Acquire write lock before SELECT to prevent TOCTOU race
|
||||
conn.execute(sa.text("BEGIN IMMEDIATE"))
|
||||
row = conn.execute(
|
||||
sa.select(
|
||||
oidc_pending_states.c.state,
|
||||
oidc_pending_states.c.nonce,
|
||||
oidc_pending_states.c.code_verifier,
|
||||
oidc_pending_states.c.audience,
|
||||
oidc_pending_states.c.created_at,
|
||||
).where(
|
||||
(oidc_pending_states.c.state == state)
|
||||
& (oidc_pending_states.c.created_at > cutoff)
|
||||
)
|
||||
).fetchone()
|
||||
# Always delete the row (whether valid, expired, or missing is fine)
|
||||
conn.execute(sa.delete(oidc_pending_states).where(oidc_pending_states.c.state == state))
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"state": row[0],
|
||||
"nonce": row[1],
|
||||
"code_verifier": row[2],
|
||||
"audience": row[3],
|
||||
"created_at": row[4],
|
||||
}
|
||||
|
||||
def cleanup_expired_oidc_states(self, max_age_seconds: int = 300) -> int:
|
||||
from turnstone.core.storage._schema import oidc_pending_states
|
||||
|
||||
cutoff = (datetime.now(UTC) - timedelta(seconds=max_age_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(oidc_pending_states).where(oidc_pending_states.c.created_at < cutoff)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Create OIDC identity and pending state tables.
|
||||
|
||||
Revision ID: 018
|
||||
Revises: 017
|
||||
Create Date: 2026-03-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "018"
|
||||
down_revision = "017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"oidc_identities",
|
||||
sa.Column("issuer", sa.Text, nullable=False),
|
||||
sa.Column("subject", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("email", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("last_login", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("issuer", "subject"),
|
||||
)
|
||||
op.create_index("idx_oidc_identities_user_id", "oidc_identities", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"oidc_pending_states",
|
||||
sa.Column("state", sa.Text, primary_key=True),
|
||||
sa.Column("nonce", sa.Text, nullable=False),
|
||||
sa.Column("code_verifier", sa.Text, nullable=False),
|
||||
sa.Column("audience", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("oidc_pending_states")
|
||||
op.drop_table("oidc_identities")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,34 @@ function initLogin() {
|
||||
overlay.innerHTML = _buildLoginHTML();
|
||||
document.body.appendChild(overlay);
|
||||
_bindLoginEvents();
|
||||
|
||||
// OIDC callback: detect success or error from URL params
|
||||
var _oidcParams = new URLSearchParams(window.location.search);
|
||||
var _oidcError = _oidcParams.get("oidc_error");
|
||||
if (_oidcError) {
|
||||
showLogin();
|
||||
history.replaceState({}, "", window.location.pathname);
|
||||
// Defer: showLogin() triggers async status fetch → _switchMode() → _clearError().
|
||||
// Display after that settles.
|
||||
var _pendingOidcError = _oidcError;
|
||||
setTimeout(function () {
|
||||
_showError(_pendingOidcError);
|
||||
}, 300);
|
||||
} else if (_oidcParams.get("oidc_success")) {
|
||||
history.replaceState({}, "", window.location.pathname);
|
||||
// Fetch permissions before completing login (cookie is already set)
|
||||
fetch("/v1/api/auth/whoami")
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : {};
|
||||
})
|
||||
.then(function (data) {
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function () {
|
||||
_onSuccess(); // Proceed even if permissions fetch fails
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _buildLoginHTML() {
|
||||
@@ -57,6 +85,11 @@ function _buildLoginHTML() {
|
||||
"</h2>" +
|
||||
'<div id="login-subtitle" class="login-subtitle"></div>' +
|
||||
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
|
||||
// --- OIDC SSO button ---
|
||||
'<div id="oidc-section" style="display:none">' +
|
||||
'<button id="oidc-btn" class="oidc-btn" type="button">Continue with SSO</button>' +
|
||||
'<div id="oidc-divider" class="oidc-divider"><span>or</span></div>' +
|
||||
"</div>" +
|
||||
// --- Setup mode fields ---
|
||||
'<div id="setup-fields" style="display:none">' +
|
||||
'<label for="setup-username" class="login-label">Username</label>' +
|
||||
@@ -158,6 +191,31 @@ function _switchMode(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function _updateOIDCUI(data) {
|
||||
var section = document.getElementById("oidc-section");
|
||||
var btn = document.getElementById("oidc-btn");
|
||||
var divider = document.getElementById("oidc-divider");
|
||||
if (!section) return;
|
||||
|
||||
if (!data.oidc_enabled || _authMode === "setup") {
|
||||
section.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = "";
|
||||
btn.textContent = "Continue with " + (data.oidc_provider_name || "SSO");
|
||||
btn.onclick = function () {
|
||||
window.location.href = "/v1/api/auth/oidc/authorize";
|
||||
};
|
||||
|
||||
if (data.password_enabled === false) {
|
||||
document.getElementById("login-fields").style.display = "none";
|
||||
document.getElementById("login-toggle").style.display = "none";
|
||||
document.getElementById("login-submit").style.display = "none";
|
||||
divider.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function _clearError() {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl && errEl.style.display !== "none") {
|
||||
@@ -194,6 +252,7 @@ function showLogin() {
|
||||
} else {
|
||||
_switchMode("login");
|
||||
}
|
||||
_updateOIDCUI(data);
|
||||
})
|
||||
.catch(function () {
|
||||
// Fallback to login mode
|
||||
|
||||
@@ -377,6 +377,55 @@ body {
|
||||
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
|
||||
|
||||
/* OIDC / SSO */
|
||||
.oidc-btn {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.oidc-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.oidc-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.oidc-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.oidc-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.oidc-divider::before,
|
||||
.oidc-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
.oidc-divider span {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard shortcuts overlay
|
||||
========================================================================== */
|
||||
|
||||
Reference in New Issue
Block a user