Skip to content

Add single sign-on (OIDC) - #684

Merged
WaylandYang merged 6 commits into
deeplethe:devfrom
nafeeur:sso-oidc
Sep 13, 2026
Merged

WaylandYang merged 6 commits into
deeplethe:devfrom
nafeeur:sso-oidc

Conversation

@nafeeur

@nafeeur nafeeur commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a narrow OIDC authorization-code flow for single sign-on, off by default:

  • Backend (crates/utopia-server/src/api/oidc_routes.rs): PKCE, one-use state, nonce, issuer/audience checks, and RS256 verification against the provider's JWKS. Enabled only when all four UTOPIA_OIDC_ISSUER / UTOPIA_OIDC_CLIENT_ID / UTOPIA_OIDC_CLIENT_SECRET (optional) / UTOPIA_OIDC_REDIRECT_URI env vars are set — status reports false otherwise and the login button stays hidden.
  • No implicit account creation. A subject the provider vouches for is refused at /auth/oidc/callback unless an administrator has already bound it to an account (oidc_identities, migration 0056). Binding/unbinding is admin-only and audited (auth.oidc_link / auth.oidc_unlink / auth.oidc_login).
  • Frontend: a "Continue with SSO" button on the login page (conditional on status.enabled), and a new Administration → Single sign-on page for admins to link/unlink identities — built from the existing ui/ shell (Table, Dialog, Field, SearchSelect) per web/DESIGN.md, with full English + Chinese strings.
  • Test fixtures + two unit tests covering the HTTPS-only endpoint rule and JWT verification (signature, issuer, audience, nonce, expiry, azp).

Test plan

  • cargo check -p utopia-server / cargo test -p utopia-server oidc_routes (2/2 passing)
  • pnpm build (style guard + tsc + vite build) and pnpm test (51/51) in web/
  • Ran both servers locally end-to-end against a real Postgres+pgvector instance — migration 0056 applies cleanly, /api/v1/auth/oidc/status responds correctly when unconfigured
  • cargo fmt --all --check / cargo clippy --workspace --all-targets -- -D warnings — both clean, no errors or warnings
  • Full authorization-code round trip exercised against a real IdP (Auth0) end-to-end — see comment below for details

Changes before merging (maintainer)

A security review before the release found the protocol handling sound: PKCE S256, one-use DB-backed state bound to an HttpOnly cookie, RS256 pinned, iss/aud/azp/nonce/exp/nbf/iat all enforced. It also found one design problem and several smaller defects. All are fixed on this branch in ec126ae.

An identity is now linked by its owner, not by an administrator. The admin POST /admin/oidc/identities let an admin bind any subject to any account in the org, other admins included, without the owner taking part or seeing it. That gave admins a new way to sign in as someone else. The link survived a password change and attributed later actions to the victim (decision 0014: identity comes from the person). Now:

  • a signed-in user starts GET /auth/oidc/start?link=1 from their account page, and the flow records their id (oidc_flows.link_user_id);
  • the callback writes the link only if the browser finishing the flow still carries that same user's session;
  • GET/DELETE /auth/oidc/me let the owner see and remove their link;
  • admins keep the read-only list and unlink (with confirmation), and there is no endpoint to create a link for someone else;
  • a subject already linked elsewhere is refused (taken), and so is a second link on the same account (already_linked), instead of a 500.

Smaller fixes

  • Unauthenticated start is bounded. Discovery and JWKS are cached for 10 minutes, with a forced JWKS refresh when a kid is unknown. In-flight flows are capped at 1,000.
  • Responses from the IdP are size-limited: discovery and JWKS at 256 KB, the token response at 64 KB.
  • The browser never sees raw JSON. Every callback refusal redirects to /login or /account with ?sso_error=<code>, including cancel at the provider (error=access_denied), and shows a localized message. The reason goes to a warn log and to the audit ledger (auth.oidc_login_failed / auth.oidc_link_failed).
  • Unlink only audits and returns 200 when something was removed; otherwise 404.
  • The state cookie is __Host--prefixed (Secure, Path=/) when behind TLS.
  • RFC 6749 §2.3.1: the client id and secret are form-encoded before Basic auth.
  • A token without kid is accepted only when the JWKS holds exactly one RSA key.
  • UTOPIA_OIDC_ALLOW_LOOPBACK_HTTP=1 lets the issuer be a localhost IdP for development; public hosts still require HTTPS.
  • .env.example and hints now say three variables are required and the secret is optional.

End-to-end, against a local fake IdP

A scripted IdP signs RS256 tokens with the test key, and the server is built from this branch. 29/29 checks pass:

  • unlinked subject refused, and the refusal audited;
  • the admin bind endpoint is gone (405);
  • linking without a session, finished without the starting session, or finished in another user's session: refused;
  • the owner links, and the row is written for them;
  • the linked subject signs in, and the session belongs to the owner;
  • a taken subject and a second link are both refused;
  • wrong aud, wrong nonce, an expired token and alg: none are refused;
  • a callback without the state cookie is refused; cancel at the provider returns with a code; a state can't be used twice;
  • behind TLS the state cookie is __Host-, Secure, Path=/;
  • the owner and admin views are correct; a non-admin gets 403; a deactivated linked account can't sign in;
  • owner unlink and admin unlink work, and a second unlink is 404;
  • the client secret is form-encoded, and it never appears in the server log.

cargo clippy --workspace --all-targets -D warnings is clean. utopia-server tests: 276 passed on a migrated database. Web: style guard, tsc, and Vitest 51/51. Login, account and admin pages were checked in the browser.

A narrow OIDC authorization-code flow (PKCE, one-use state, nonce,
issuer/audience and RS256 verification), off by default and enabled
only once all four UTOPIA_OIDC_* variables are set. Accounts must be
explicitly linked by an administrator on a new Administration >
Single sign-on page; a subject that has never been linked is refused
at callback rather than silently provisioned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136c4gq3BDppPhcWcRdy5MJ
Signed-off-by: Nafeeur Rahman <hello@nafeeur.nyc>
@nafeeur

nafeeur commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Closed out the two remaining test-plan items.

cargo fmt / cargo clippy

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings

Both clean — no formatting diffs, no clippy warnings.

Full authorization-code round trip against a real IdP

Registered a free Auth0 tenant as a real OIDC provider and ran both the negative and positive paths end-to-end locally (Postgres + cargo run -p utopia-server + pnpm dev):

  1. Set the four UTOPIA_OIDC_* env vars to the Auth0 app (issuer, client id/secret, http://localhost:1516/api/v1/auth/oidc/callback). /api/v1/auth/oidc/status{"enabled":true} and "Continue with SSO" appears on the login page.
  2. Unlinked subject, before any admin binding: clicked through — real PKCE state/discovery/redirect to Auth0's hosted login, real login, callback received a real code, server exchanged it for tokens and verified the RS256 signature against Auth0's real JWKS (issuer/audience/nonce all checked) — then correctly refused with "This SSO identity has not been linked by an administrator", exactly per the no-implicit-provisioning design.
  3. Admin-linked the Auth0 sub to a Utopia account via POST /api/v1/admin/oidc/identities (same effect as using the new Single sign-on admin page's "Link identity" dialog).
  4. Same login, after linking: Auth0 (with an existing session) went straight to its consent screen ("Utopia is requesting access to your ... account") → Accept → callback → landed authenticated in the app as the linked account.
1-login-sso-button 2-admin-sso-settings 3-admin-link-identity-dialog 4-post-sso-login-authenticated

nafeeur and others added 4 commits September 13, 2026 17:53
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
@WaylandYang

Copy link
Copy Markdown
Contributor

@nafeeur thanks for this. The protocol work is careful, and it held up against every token and state attack I threw at it. Before merging for the release I pushed ec126ae to your branch with one design change and a set of hardening fixes, listed in the description.

The design change: identities are now linked by their owner from the account page, not by an administrator. An admin binding any subject to any account (other admins included) gave admins a way to sign in as someone else that survives a password change, so admins now keep only the list and unlink.

🤖 Generated with Claude Code

@WaylandYang
WaylandYang merged commit 55448d3 into deeplethe:dev Sep 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants