mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
f4fd7e1f67
* fix(security): classify outbound addresses by what they reach (GHSA-wm4f-79pw-pfr9)
Five guards screened outbound URLs and each hand-rolled its own address
normalization and policy tests, so each had a different hole. An IPv6
transition address carries an IPv4 destination in its low bits and
`ipaddress` classifies the wrapper, not the destination: 64:ff9b::a9fe:a9fe
reports is_global because 64:ff9b::/96 is global unicast, while a NAT64
gateway routes it to the cloud metadata endpoint. CGNAT (100.64.0.0/10) is
neither is_private nor is_global, so a denylist built on is_private missed
it with no gateway involved at all.
Add turnstone/core/ip_classify.py as the single classifier. One function
returns exactly one policy lane — PUBLIC, PRIVATE (operator-approvable) or
NEVER — and every guard branches on the lane rather than re-deriving it.
Two overlapping booleans would make a verdict depend on which one a caller
tested first; several addresses are simultaneously globally routable and
metadata-reaching.
- Decode transition addresses per RFC 6052 §2.2 (NAT64 well-known and
local-use prefixes, 6to4, Teredo, IPv4-mapped, IPv4-compatible) and judge
them by the IPv4 they reach. The local-use prefix does not say which
layout its gateway uses, so every length it can carry is decoded and the
worst result classified.
- Share hostname resolution too. The five copies had already drifted on
which failures they caught, and getaddrinfo raises UnicodeError — not an
OSError — from the IDNA encoder.
- Resolution failure is a refusal, not a pass: the fetch resolves again, so
an authority answering the guard with SERVFAIL and the fetch with an
internal address would otherwise switch the guard off for that hop.
- Screen every redirect hop in every mode. allow_private_origin widens which
lanes are acceptable rather than turning screening off, and the permission
is revoked after any hop that is not wholly private.
- Cleartext http is allowed only for a hostname that RESOLVES to loopback.
*.localhost is ordinary DNS, and trusting the name put an OIDC token
exchange on the wire in the clear.
- Screen doctor and console-probe URLs through the classifier. Both used a
host.startswith("169.254.") string test that never resolved, so any DNS
name pointing at the metadata service passed and its body was returned to
the model.
- Add known vendor metadata prefixes the stdlib does not flag, and place
deprecated IPv6 site-local outside the public lane.
The operator's private-network opt-in still admits the whole home lab,
including IPv6 loopback, CGNAT and split-horizon hosts. Metadata,
link-local, multicast, unspecified and reserved addresses stay refused
regardless of the opt-in, including as a redirect target from an approved
private origin — the settings help and docs now say so.
Reported by @tonghuaroot.
* fix(security): close Azure/Oracle metadata gap and restore dual-stack origins
Review follow-ups on the address-classification rework.
Azure's host-agent endpoint (168.63.129.16) and Oracle Cloud's metadata
endpoint (192.0.0.192) sit in ordinary unicast space, so the stdlib reported
them as globally routable and both classified PUBLIC — reachable with no
opt-in at all, a worse position than the RFC 1918 host beside them, and
directly contradicting the "metadata stays refused even with the opt-in"
guarantee the settings help and docs now advertise. Both join the shared
vendor list.
Revoking the private-hop permission on the ORIGIN hop broke the case
`_screen_tool_url` deliberately admits: a dual-stack or split-horizon
home-lab host answering with both a LAN and a public record was approved,
then refused on its own `302 /login` — one hop was all it ever got. Track
the approved HOST instead, so redirects that stay on it remain covered while
a redirect to any other private host is still refused once the chain is no
longer wholly private.
Also:
- Try several registry candidates for the collector-scope probe instead of
abandoning it when the first is unresolvable, which also stopped a healthy
registry from logging as malformed.
- Bound the probe's name resolution with an explicit timeout matching the
2s the httpx connect deadline used to provide; it runs before the console
lifespan yields and getaddrinfo has no timeout of its own.
- Route doctor and the console probe through `web.screen_url` rather than
keeping a third and fourth copy of parse/resolve/classify/fold, which had
already diverged on default port and empty-hostname wording. An empty
hostname no longer reports as a cloud-metadata refusal.
- Give `screen_url` a scheme-aware default port.
- Stop doubling the word "hostname" in the OAuth resolution refusal.
- Correct the `_screen_tool_url` docstring: it described `private_origin` as
requiring every record to be private, which the mixed-record decision
reversed, and `private_block` as a property of a refusal when it reports
the lane on the success path too.
- Make the preview tests' screening stub opt-in rather than autouse — as a
module-wide fixture it also stubbed the tests whose subject IS the screen,
so one of them would have passed even if screening refused everything.
Verified the module now passes with all name resolution blocked.
* fix(security): refuse mixed-record private origins instead of exempting them
The previous commit let an approved private origin redirect to itself by
exempting its hostname from the chain-wide revocation. That exemption was
wrong three ways: it was captured once and never cleared, so a public hop
could steer the fetcher back into the approved host at a path of its
choosing — reopening the private -> public -> private bypass; it was
re-entrant across same-host redirects with fresh DNS each time, so a
self-redirecting host could walk arbitrary internal addresses; and it
matched on bare hostname, so it spanned every port on the approved box.
All three were reproduced against the parent commit, which refuses them.
Delete the exemption rather than repair it. The case it existed for — a
dual-stack host answering with both a LAN and a public record — is now
refused where it is actually decidable, in `_screen_tool_url`, with the
remedy in the message: point the tool at the LAN address directly. A
granted chain therefore always starts wholly private, so the fetch guard
needs no notion of an approved host and stays one unconditional rule.
That the accommodation could not be expressed safely in the guard is the
signal: the connection may land on either record, so approving such a host
never described where the fetch would go.
Also from the same review:
- Walk the whole service registry for a collector-scope probe candidate
instead of the first three, and split the outcome into three log lines,
so entries that are merely unreachable stop raising the malformed-registry
alarm and skipping the boot check cluster-wide.
- Stop the candidate walk on a resolver timeout. `asyncio.timeout` bounds
the await, not the work, so continuing left one parked thread per timed-out
candidate on the shared executor.
- Move the metadata-hostname denylist into `ip_classify` and enforce it in
`screen_url`, so doctor and the console probe inherit it instead of each
keeping a copy.
- Drop the scheme-aware default port: a numeric service does not change
which addresses resolution returns, and classification reads only those.
`parsed.port` is still touched so an out-of-range value refuses.
- Correct the vendor-metadata comment, which generalized a claim true of
Azure's and Oracle's addresses to Alibaba's CGNAT one.
- Rename a test class that was still named for the rule it no longer tests.
598 lines
24 KiB
Markdown
598 lines
24 KiB
Markdown
# 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 continue to work. |
|
|
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
|
|
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
|
|
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
|
|
|
|
All four required fields — issuer, client ID, client secret, and
|
|
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
|
|
is disabled at startup (an error is logged when only `redirect_base`
|
|
is missing) and the login screen shows only the password form.
|
|
|
|
### Redirect base (required)
|
|
|
|
`TURNSTONE_OIDC_REDIRECT_BASE` pins the redirect URI sent to the identity
|
|
provider to a known externally-visible origin. Set it to the public origin
|
|
of your Turnstone deployment:
|
|
|
|
```bash
|
|
TURNSTONE_OIDC_REDIRECT_BASE=https://app.example.com
|
|
```
|
|
|
|
The resulting callback URL will be
|
|
`https://app.example.com/v1/api/auth/oidc/callback` — register this as the
|
|
authorized redirect URI in your identity provider.
|
|
|
|
OIDC will refuse to start when this variable is unset. There is no
|
|
Host-header fallback: a permissive reverse proxy or direct backend access
|
|
would otherwise let an attacker spoof `Host` and steer the IdP redirect
|
|
to a callback origin they control.
|
|
|
|
### Cross-host endpoints
|
|
|
|
By default, every endpoint in the IdP discovery document
|
|
(`token_endpoint`, `jwks_uri`, `userinfo_endpoint`) must share the
|
|
issuer's `(scheme, host, port)`. This prevents a hostile or compromised
|
|
IdP from redirecting the token-exchange POST (which carries
|
|
`client_secret`) to an arbitrary host, and prevents JWKS fetches from
|
|
being aimed at internal services.
|
|
|
|
A few public IdPs legitimately split endpoints across hostnames. Google
|
|
and Microsoft Entra ID are the canonical examples:
|
|
|
|
| IdP | Issuer host | Cross-host endpoint(s) |
|
|
|-----|-------------|------------------------|
|
|
| Google | `accounts.google.com` | `oauth2.googleapis.com`, `www.googleapis.com`, `openidconnect.googleapis.com` |
|
|
| Microsoft Entra | `login.microsoftonline.com` | `graph.microsoft.com` (userinfo) |
|
|
|
|
Both sets are built in — operators using `https://accounts.google.com` or
|
|
`https://login.microsoftonline.com/<tenant>/v2.0` need no extra
|
|
configuration. (Entra's discovery document advertises `userinfo_endpoint`
|
|
on `graph.microsoft.com`, distinct from the issuer host.)
|
|
|
|
For other IdPs whose discovery document references a non-issuer host,
|
|
extend the allow-list explicitly:
|
|
|
|
```bash
|
|
TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS=token.example.com,keys.example.com
|
|
```
|
|
|
|
The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
|
|
this knob only relaxes the same-origin check, not the security gates.
|
|
Each entry is a hostname (no scheme, no path).
|
|
|
|
### Self-hosted and internal IdPs
|
|
|
|
By default Turnstone refuses an issuer whose hostname resolves to a
|
|
private or internal address:
|
|
|
|
```
|
|
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
|
|
```
|
|
|
|
This is SSRF hardening, not a licensing or product restriction: the OIDC
|
|
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
|
|
and refusing non-public destinations keeps a mistyped or maliciously
|
|
steered issuer from aiming those fetches at internal services. For a
|
|
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
|
|
opt in explicitly in `config.toml`:
|
|
|
|
```toml
|
|
[oidc]
|
|
allow_private_network = true
|
|
```
|
|
|
|
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
|
|
when both are set).
|
|
|
|
The opt-in admits private-range (RFC 1918), unique-local, site-local,
|
|
CGNAT (100.64/10, where overlay VPNs commonly assign hosts), and
|
|
loopback addresses. Link-local, multicast, reserved ranges and known
|
|
cloud-metadata endpoints stay refused even with the opt-in — no
|
|
legitimate IdP lives there. An address is judged by what it actually
|
|
reaches, so an IPv6 transition address (NAT64, 6to4, Teredo) wrapping
|
|
an internal IPv4 is treated exactly as that IPv4 would be. The HTTPS
|
|
requirement and the same-origin endpoint checks are unaffected.
|
|
|
|
This knob only affects the login-flow IdP configured here. OAuth
|
|
endpoints advertised by remote MCP servers are untrusted input and are
|
|
always held to the strict public-address rule.
|
|
|
|
### Model gateway credentials
|
|
|
|
The same OIDC registration can authenticate model gateways. A model definition
|
|
with `auth_mode = "entra_obo"` (Entra grant profile) or `auth_mode =
|
|
"rfc8693_obo"` (RFC 8693 token-exchange profile) redeems the driving user's
|
|
captured credential for its exact `obo_audience`; `auth_mode = "entra_app"`
|
|
uses the registration's client ID and secret with Entra client credentials.
|
|
All three bind the result through the provider SDK's native credential option
|
|
rather than injecting an override header. The grant mode is never inferred:
|
|
missing user context or a failed OBO mint cannot switch a delegated definition
|
|
to client credentials.
|
|
|
|
Each dynamic mode pairs with the grant profile whose dialect it names:
|
|
`entra_obo` and `entra_app` require `obo_grant_profile = "entra"`;
|
|
`rfc8693_obo` requires `obo_grant_profile = "rfc8693"`. The pairing is
|
|
enforced when a write chooses a `(auth_mode, obo_audience)` pair — a same-pair
|
|
edit of a row saved before the pairing rule keeps working — and at runtime a
|
|
mismatched legacy row refuses to mint with `cause=grant_profile_mismatch` and
|
|
no IdP traffic. RFC 8693 client-credentials is not implemented.
|
|
|
|
The delegated modes need the MCP encryption key, a credential captured for the
|
|
driving user, and delegated/admin-consented permission to the audience.
|
|
`rfc8693_obo` additionally carries `obo_scopes`, the space-separated scope
|
|
list its exchange leg requests: exchange-capable IdPs that gate audiences
|
|
behind optional scopes refuse the exchange without it ("Requested audience not
|
|
available"), which is why the scope-less Entra-named mode could never mint on
|
|
that profile (issue #955). Scopes are stored shape-checked only — whether a
|
|
value satisfies the IdP stays the IdP's call at mint time. Turning
|
|
`capture_user_credential` off later stops *new* captures but does not
|
|
invalidate credentials already stored, so existing users keep minting.
|
|
`entra_app` requires a confidential-client secret. Configure the permitted
|
|
resource IDs in the runtime setting `model.auth_audience_allowlist` before
|
|
saving dynamic model definitions. De-listing an audience later blocks every
|
|
write that would arm or re-aim a definition at it, but does not stop aliases
|
|
already configured from minting — disabling the row (the `admin.models` disarm
|
|
lever) is what stops minting. See
|
|
[Settings](settings.md#model-backend-authentication) for permissions, failure
|
|
policy, and lane identity rules.
|
|
|
|
An unrecognised `obo_grant_profile` is warned about at startup and **rejected
|
|
at the write choke points**: configuring an `oauth_obo` MCP server or a dynamic
|
|
model alias returns a 400 that echoes the configured value, so the typo is the
|
|
diagnosis. At runtime an unknown profile never mints — the mint legs resolve by
|
|
exact name; the full cause detail is logged once per audience, and every
|
|
affected call still logs its per-turn fallback or refusal naming the alias,
|
|
the target audience, and the last recorded cause (`cause=` — for example
|
|
`unsupported_grant_profile` or `oidc_not_enabled`) — so a pre-existing row
|
|
degrades loudly, with the reason visible mid-incident even after the
|
|
once-per-process line has rotated out of retained logs, rather than silently
|
|
swapping per-user attribution for the shared static key.
|
|
|
|
The `[security]` token encryption key is deployment-wide, not per-host: rows are
|
|
encrypted with `MultiFernet` and carry no key id, so every host that reads them
|
|
needs the same keyring. That includes the console, which mints for
|
|
coordinator-hosted sessions. A node that needs the key and lacks it refuses to
|
|
start; the console starts but withholds its coordinator subsystem and shows
|
|
the key requirement as the remediation error instead of failing silently at
|
|
call time.
|
|
|
|
### config.toml alternative
|
|
|
|
```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
|
|
redirect_base = "https://app.example.com"
|
|
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
|
|
allow_private_network = false
|
|
|
|
[oidc.role_map]
|
|
admin = "builtin-admin"
|
|
engineering = "builtin-operator"
|
|
```
|
|
|
|
---
|
|
|
|
## Provider-Specific Setup
|
|
|
|
### Google
|
|
|
|
1. Go to [Google Cloud Console](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.
|
|
|
|
### `assigned_by` markers
|
|
|
|
Role assignments record an `assigned_by` value that controls how the
|
|
sync logic treats them. OIDC-driven flows use two distinct markers:
|
|
|
|
- `oidc` — set by claim-driven role mapping; revoked automatically on
|
|
the next login when the corresponding claim value is no longer
|
|
present.
|
|
- `oidc-default` — applied to brand-new OIDC users who have no
|
|
claim-mapped roles, as a safety net so they still get
|
|
`builtin-viewer` access on first login. Survives subsequent logins
|
|
regardless of claim contents and is never revoked by `apply_role_mapping`.
|
|
|
|
### Built-in Roles
|
|
|
|
| Role ID | Permissions |
|
|
|---------|-------------|
|
|
| `builtin-admin` | All permissions |
|
|
| `builtin-operator` | read, write, workstreams.create, workstreams.close |
|
|
| `builtin-viewer` | read |
|
|
|
|
---
|
|
|
|
## User Provisioning
|
|
|
|
When a user logs in via OIDC for the first time, Turnstone automatically
|
|
creates a local user account:
|
|
|
|
1. The OIDC identity (`issuer` + `sub` claim) is stored in the
|
|
`oidc_identities` table and linked to the new user
|
|
2. The **username** is derived from the `preferred_username` claim,
|
|
falling back to the email local part, with deduplication if needed
|
|
3. The **display name** comes from the `name` claim, falling back to
|
|
`preferred_username` or email
|
|
4. The user's password hash is set to a sentinel value (`!oidc`) — OIDC
|
|
users cannot log in with a password
|
|
|
|
On subsequent logins, the existing user is matched by `(issuer, sub)` and
|
|
the `last_login` timestamp is updated. Role mapping is re-evaluated on
|
|
every login.
|
|
|
|
---
|
|
|
|
## OIDC-Only Mode
|
|
|
|
To enforce OIDC for all logins and hide the password form, set:
|
|
|
|
```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)
|
|
continues to work regardless of this setting. JWTs and API tokens are
|
|
the supported authentication methods. OIDC-only mode affects
|
|
password-based authentication only.
|
|
|
|
---
|
|
|
|
## Login Flow
|
|
|
|
Both the server and console support OIDC login. The flow is identical:
|
|
|
|
1. The browser fetches `GET /v1/api/auth/status` at page load
|
|
2. If the response includes `oidc_enabled: true`, the login screen shows
|
|
a "Continue with {provider_name}" button
|
|
3. Clicking the button navigates to `GET /v1/api/auth/oidc/authorize`
|
|
4. Turnstone generates a state token, nonce, and PKCE verifier, stores
|
|
them in the database, and redirects the browser to the identity
|
|
provider's authorization endpoint
|
|
5. The user authenticates at the identity provider
|
|
6. The IdP redirects back to
|
|
`GET /v1/api/auth/oidc/callback?code=...&state=...`
|
|
7. Turnstone validates the state, exchanges the authorization code for
|
|
tokens using the PKCE verifier, validates the ID token against the
|
|
provider's JWKS public keys, provisions or matches the user, and
|
|
issues a Turnstone JWT
|
|
8. The browser is redirected to `/?oidc_success=1` with the JWT set in
|
|
an `HttpOnly` session cookie
|
|
9. The browser JavaScript detects the `oidc_success` query parameter,
|
|
strips it from the URL, hides the login overlay, and calls
|
|
`onLoginSuccess()` to initialize the application
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
| Method | Path | Auth | Description |
|
|
|--------|------|------|-------------|
|
|
| GET | `/v1/api/auth/oidc/authorize` | Public | Redirects to identity provider |
|
|
| GET | `/v1/api/auth/oidc/callback` | Public | Handles IdP callback, issues JWT |
|
|
|
|
Both endpoints are public (no authentication required) because they are
|
|
part of the login flow itself.
|
|
|
|
### Auth status response
|
|
|
|
When OIDC is enabled, `GET /v1/api/auth/status` includes additional
|
|
fields:
|
|
|
|
```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 four required environment variables must be set:
|
|
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`,
|
|
`TURNSTONE_OIDC_CLIENT_SECRET`, and `TURNSTONE_OIDC_REDIRECT_BASE`.
|
|
Check that none are empty or whitespace-only.
|
|
|
|
### "OIDC enabled but TURNSTONE_OIDC_REDIRECT_BASE is unset"
|
|
|
|
This error is logged when the three credential variables are set but
|
|
`TURNSTONE_OIDC_REDIRECT_BASE` is missing. OIDC is disabled at startup
|
|
to prevent Host-header-derived redirect URI spoofing. Set the variable
|
|
to your service's externally-visible origin (e.g.
|
|
`https://app.example.com`) and restart the server. See
|
|
[Redirect base](#redirect-base-required) for the rationale.
|
|
|
|
### Discovery silently disables OIDC with "host does not match issuer"
|
|
|
|
The IdP discovery document points `token_endpoint`, `jwks_uri`, or
|
|
`userinfo_endpoint` at a hostname that doesn't share the issuer's
|
|
origin. If the IdP is legitimate, add the additional hostname(s) to
|
|
`TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS`. Google is allow-listed
|
|
automatically; see [Cross-host endpoints](#cross-host-endpoints).
|
|
|
|
### "Login session expired"
|
|
|
|
The authorization flow must complete within 5 minutes. If the user takes
|
|
too long at the identity provider, the pending state expires. Try again.
|
|
|
|
### "Initial setup required"
|
|
|
|
OIDC login is blocked until at least one local admin user exists.
|
|
Complete the setup wizard first (navigate to the Turnstone URL and follow
|
|
the prompts to create an admin user with a password).
|
|
|
|
### Discovery fails at startup
|
|
|
|
Check that the issuer URL is reachable from the Turnstone server and
|
|
serves a valid `/.well-known/openid-configuration` document. The server
|
|
logs the discovery attempt at startup:
|
|
|
|
```
|
|
OIDC discovery failed for https://your-issuer.example.com: ...
|
|
```
|
|
|
|
OIDC is automatically disabled when discovery fails. Restart the server
|
|
after fixing the connectivity issue.
|
|
|
|
### Redirect URI mismatch
|
|
|
|
The redirect URI configured at the identity provider must exactly match
|
|
`https://your-host/v1/api/auth/oidc/callback`. Common issues:
|
|
|
|
- **Scheme mismatch**: the redirect uses `https://` — make sure TLS is
|
|
configured or a reverse proxy sets the `X-Forwarded-Proto` header
|
|
- **Port mismatch**: if running on a non-standard port, include it in
|
|
the redirect URI
|
|
- **Path mismatch**: the path must include the `/v1` API version prefix
|
|
|
|
### User not assigned expected roles
|
|
|
|
Check that:
|
|
|
|
1. `TURNSTONE_OIDC_ROLE_CLAIM` matches the exact claim name in the ID
|
|
token (case-sensitive)
|
|
2. `TURNSTONE_OIDC_ROLE_MAP` maps the correct claim values to valid
|
|
Turnstone role IDs
|
|
3. The roles referenced in the map exist in the database (check the
|
|
admin panel > Roles tab)
|
|
4. The identity provider is configured to include the claim in the ID
|
|
token (some providers require explicit scope or claim configuration)
|