OIDC Single Sign-On (SSO)
Temps lets you log in to the console with any standards-compliant OpenID Connect (OIDC) identity provider, so your team authenticates through your existing IdP instead of a separate Temps password. Use it when you want centralized identity, group-driven roles, and just-in-time account creation — for example to route all logins through Okta, Auth0, Keycloak, Google Workspace, or Microsoft Entra ID (Azure AD). It works with any compliant OIDC provider; the console also ships setup templates for the common ones plus a Generic template for everything else (Authentik, Zitadel, etc.).
OIDC SSO is configured through the admin console under Settings → Authentication. All of its routes are served under the /api prefix. The hardening that protects this flow (account-takeover prevention, SSRF defense, audit logging) is documented in Security Features → OIDC SSO Security.
How it works
When a provider is enabled, the login form renders a "Sign in with {provider name}" button per enabled provider. Clicking it runs a standard OIDC authorization-code flow:
- Discovery — Temps fetches the provider's OpenID configuration from its issuer URL.
- Authorization redirect — the browser is sent to the IdP via
GET /api/auth/oidc/login/{slug}. Temps generates a server-sidestatetoken and an ID-tokennonce, and uses PKCE with a SHA-256 code challenge. - Callback — the IdP redirects back to
GET /api/auth/oidc/callback. Temps exchanges the code, then validates thestatetoken and thenonce. - Identity from the ID token — all identity claims come exclusively from the cryptographically verified ID token. Temps does not call the IdP's userinfo endpoint.
- Provisioning & roles — on first login, just-in-time (JIT) provisioning creates the Temps account (gated per provider by
jit_provisioning). IdP groups/claims are mapped to Temps roles via role mappings ordered by priority.
The public login route is keyed by an opaque slug, not the provider's database ID. The slug is the kebab-cased provider name plus a 4-byte SHA-256 suffix, so internal integer IDs are never exposed to unauthenticated callers and can't be enumerated.
Provider configuration fields
These columns live on each oidc_providers row. Templates prefill most of them; defaults are applied on create.
| Field | Default | Purpose |
|---|---|---|
template | generic | Setup template: okta, auth0, keycloak, google, microsoft, or generic. Prefills issuer URL, scopes, and claim names. |
group_claim | groups | ID-token claim that carries the user's IdP groups, used for role mapping. |
role_claim | roles | ID-token claim that carries roles, used for role mapping. |
default_role | user | Temps role assigned when no role mapping matches. |
trust_idp_email | false | Per-provider opt-out of the email_verified ID-token gate. Leave off unless your IdP never emits the claim (see note below). |
trust_idp_email defaults to false and exists only for corporate IdPs (such as Okta's Org Authorization Server) that never assert email_verified and where an admin controls every account. Turning it on bypasses the verification gate, not the truth — the local users.email_verified column is still only set when the IdP actually asserted it, and every bypass is logged. See Account-Takeover Prevention for why this gate matters.
Routes
All routes are served under the /api prefix.
Public (login flow)
| Method | Path | Purpose |
|---|---|---|
GET | /api/auth/oidc/providers | Enabled-provider summaries for the login page. Returns the deterministic slug, name, and template — never the integer DB ID. |
GET | /api/auth/oidc/login/{slug} | Redirects the browser to the IdP authorize URL. |
GET | /api/auth/oidc/callback | Completes login, sets the session cookie, honors MFA. Re-checks provider.enabled. |
Admin (requires the SettingsWrite permission)
| Method | Path | Purpose |
|---|---|---|
POST | /api/admin/oidc/providers | Create a provider. |
GET | /api/admin/oidc/providers | List providers (full detail, including integer ID). |
PATCH | /api/admin/oidc/providers/{provider_id} | Update a provider. |
DELETE | /api/admin/oidc/providers/{provider_id} | Delete a provider. |
POST | /api/admin/oidc/providers/{provider_id}/test | Test connection (discovery against the issuer). |
GET | /api/admin/oidc/providers/{provider_id}/users | List users federated through this provider. |
GET | /api/admin/oidc/providers/{provider_id}/role-mappings | List role mappings for this provider. |
POST | /api/admin/oidc/providers/{provider_id}/role-mappings | Create a role mapping. |
DELETE | /api/admin/oidc/role-mappings/{mapping_id} | Delete a role mapping. |
All provider and role-mapping writes (create / update / delete) emit audit logs. Updates record a fields_changed list so an auditor can tell whether (for example) the client secret was rotated without comparing values.
Role mapping
Role mappings translate an IdP group into a Temps role. Each mapping has a priority (default 100); when a user logs in, mappings are evaluated in ascending priority (then by ID), and the first match wins. The idp_group value is capped at 256 characters and rejects control characters.
What happens when a user is in multiple IdP groups? The mapping with the lowest priority value that matches one of the user's groups wins — lower numbers are evaluated first. This lets you express precedence: give the admin group priority: 100, engineers priority: 200, and so on, so a user who is in both groups lands on the admin role.
Fallback chain when no mapping matches. Resolution runs in this exact order:
- Group mappings, in ascending
priority. A mapping whoseidp_groupis the literal*is a catch-all that matches every user, so a low-priority*mapping (for examplepriority: 999) is the cleanest way to say "everyone who reaches this point gets theuserrole." role_claimfallback — if no group mapping matched, Temps reads the provider'srole_claim(defaultroles) from the ID token and tries the first role value there.default_role— if neither matched, the provider'sdefault_role(defaultuser) is assigned.
When SSO assigns a role on login, it replaces any other Temps roles the user previously held, so the resolved role is authoritative on every login rather than additive.
The email_verified gate and trust_idp_email
By default Temps refuses to link an incoming SSO identity onto an existing local account — or to JIT-provision a new one — unless the IdP's ID token asserts email_verified: true. This is an anti-account-takeover measure: because the users table has a UNIQUE(email) constraint and SSO can both link to and create accounts, an attacker who can register victim@example.com at a configured IdP without verifying that mailbox could otherwise seize the victim's existing password or magic-link account on first SSO login. oidc_service::resolve_user enforces the check on both the link path and the JIT-provision path; when the claim is absent and trust_idp_email is false, the request is rejected with OidcError::EmailNotVerified and a warn is logged on the tracing target temps_auth::oidc::abuse.
trust_idp_email (per-provider, default false) exists for the one case where the gate is pure noise: corporate IdPs that never emit the claim and where an admin controls every account. Okta's Org Authorization Server is the canonical example — there is no Claims tab to add email_verified and no Token Preview to confirm it.
When to enable it: only for corporate IdPs where an admin controls provisioning (Okta Org Authorization Server, Microsoft Entra ID, internal SSO). Never enable it for public IdPs that allow self-signup (Auth0 social logins, Google consumer accounts) — that reintroduces the takeover vector the gate closes.
It bypasses the gate, not the truth: the local users.email_verified column is still only flipped to true when the IdP actually asserted the claim, so the database keeps recording what the IdP really said. Every bypass logs a warn on temps_auth::oidc::trust_bypass with the provider ID, email, and subject. In the console the flag is a switch in the OIDC provider edit form's First-login policy section, inside an amber warning block; create/update audit rows record its value (and add trust_idp_email to fields_changed on update) so an auditor can answer "who turned this on and when."
Enable on an existing provider
curl -X PATCH https://your-temps-instance.com/api/admin/oidc/providers/{provider_id} \
-H "Authorization: Bearer your-admin-api-key" \
-H "Content-Type: application/json" \
-d '{ "trust_idp_email": true }'
Omitting trust_idp_email from any create/update body leaves it false, so you can't accidentally disable the gate by reusing an older payload.
Setting up a provider in the console
- Open Settings → Authentication and add an SSO provider.
- Pick a template (Okta, Auth0, Keycloak, Google, Microsoft, or Generic). The template prefills the issuer URL, scopes, and group/role/default-role claim names.
- Fill in the issuer URL, client ID, and client secret from your IdP's OIDC application.
- Set the redirect URL to your console origin's callback path — it must match
/api/auth/oidc/callbackon the origin where the console is served. - Run Test connection to verify discovery against the issuer.
- Configure role mappings (IdP group → Temps role) and a default role.
- Enable the provider. The "Sign in with {provider name}" button then appears on the login form.
Local development with Keycloak
Temps ships a one-command local IdP for development under tools/keycloak-dev/ (a docker-compose.yml, a pre-seeded realm-temps.json, and a setup.sh). It brings up Keycloak on http://localhost:8180 with a temps realm already configured for the Temps EE console.
# From the temps repo root
tools/keycloak-dev/setup.sh
This starts Keycloak and prints the realm details:
- Issuer:
http://localhost:8180/realms/temps - Client ID:
temps-ee· Client secret:temps-ee-dev-secret - Redirect URIs:
http://localhost:9081/api/auth/oidc/callbackandhttp://localhost:9100/api/auth/oidc/callback(EE web dev) - Test users:
sso-admin / sso-admin(grouptemps-admins, roleadmin) andsso-user / sso-user(grouptemps-users, roleuser)
Then in the console (EE dev console at http://localhost:9081), go to Settings → Authentication → Add SSO Provider, choose the Keycloak template, paste the issuer/client values above, set Group claim: groups, Role claim: roles, Default role: user, and run Test connection before enabling.
Schema
OIDC SSO is added by three migrations:
| Migration | Adds |
|---|---|
m20260522_000001_oidc_sso | Creates oidc_providers and oidc_login_states; adds users.oidc_provider_id and users.oidc_subject with a unique compound index users_oidc_unique as the federation key. |
m20260522_000002_oidc_role_mappings | Creates oidc_role_mappings; adds template, group_claim, role_claim, and default_role columns to oidc_providers. |
m20260526_000002_add_trust_idp_email_to_oidc_providers | Adds oidc_providers.trust_idp_email boolean not null default false. |
The federation key is the (oidc_provider_id, oidc_subject) pair on users — the IdP's stable subject identifier, not the email. This is what lets Temps reliably re-recognize a returning SSO user even if their email changes at the IdP.
Provider compatibility notes
A few provider-specific behaviors are handled in the dependency configuration so common IdPs work out of the box:
- HTTPS to the IdP — the
openidconnect4.0.1 crate is built with therustls-tlsfeature so the OIDC HTTP client can speak HTTPS. - Auth0
updated_atclaim — Auth0 returnsupdated_atas an ISO-8601 string (rather than Unix seconds) on social/Google connections, so the crate is built withaccept-rfc3339-timestampsto parse it. - Trailing slash in the issuer — the issuer URL is never trailing-slash-stripped, preserving OIDC Core §16.13 / RFC 8414 byte-equality. Auth0 publishes its issuer with a trailing slash, so stripping it would break discovery.
Debugging a failed discovery
Discovery is the first step of every login, and Test connection (the admin-only POST /api/admin/oidc/providers/{provider_id}/test) exercises exactly that path against the issuer without needing a real user to log in — run it after every config change. The endpoint returns success: false plus the underlying error message rather than throwing, so the failure reason is visible in the response. If discovery fails:
- Check the trailing slash. The issuer URL must match what your IdP publishes byte-for-byte (see above). Copy the full issuer URL straight from your IdP's OIDC setup documentation rather than retyping it, and use Test connection to confirm.
- Confirm the well-known document exists. Temps fetches the OpenID configuration at the issuer's
/.well-known/openid-configurationpath. Open that URL (issuer + that suffix) in a browser — if it 404s or returns HTML, the issuer URL is wrong. - Watch for IdP-specific transport quirks. Some IdPs reject requests without certain headers (for example
Accept-Encoding) or only serve discovery over HTTPS; the bundled client speaks HTTPS viarustls-tlsand follows a 10s-timeout, no-redirect policy.
Security
OIDC SSO ships with hardening against account takeover, SSRF/DNS-rebinding, error leakage, and provider-ID enumeration. See Security Features → OIDC SSO Security for the full breakdown.
Next steps
- Security Features — OIDC SSO hardening and the broader auth model
- Teams — assign console access once users are provisioned
- Admin Listener — keep admin/SSO management routes on a private interface