Repository navigation
Phase 2: single sign-on with OpenID Connect - #39
Merged
Merged
Conversation
Phase 2: SSO first, then SCIM (the user's choice). - Migration 023: sso_configs (issuer, client id, client secret encrypted and bound to the organization, enforce; forced RLS), users.sso_issuer/sso_subject (unique), sso_handoffs (hash, 60 s, single use) with scirm_sso_handoff_consume (definer, granted by db_setup). - libs/common/oidc.py: discovery (issuer must match exactly), key set and token endpoint through the shared outbound guard; authorization code with PKCE S256, response_mode=query, HTTP Basic client auth; ID tokens only with RS/PS/ES algorithms from the published keys (one refetch for an unknown kid), issuer, audience/azp, expiry and nonce checked; multi-tenant issuers (/common, /organizations, /consumers) refused. - Flow: start looks up the account by email (no domain claims); a signed HttpOnly SameSite=Lax cookie holds state, nonce, verifier, org and the next path; the callback links (issuer, subject) at the first sign-in by verified email and requires it afterwards; the app gets a one-time code in the URL fragment and exchanges it for tokens. No account creation. - Enforce: members cannot sign in, reset or change a password; admins keep password + two-step sign-in as the way in. - Admin card on Integrations (check and save, write-only secret, redirect URI to register); "Sign in with SSO" on the login page; /sso/complete. - Audit: auth.sso_login, auth.sso_login_failed (reason), sso.configured, sso.removed. PUBLIC_API_URL in staging. docs/single-sign-on.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uer changes
- Login CSRF: the one-time handoff code worked in any browser within 60 s,
so a captured code, or a sign-in an attacker started in a victim's
browser, could sign the victim into the attacker's account. The login
page now keeps a random value in sessionStorage and sends its SHA-256 to
/sso/start (required); it rides in the state cookie onto the handoff row
(sso_handoffs.binding_hash), and /sso/exchange must present the value.
A wrong value leaves the code unused.
- Accepting an invitation in an organization that requires SSO creates the
account but returns no password session ("account ready, sign in with
SSO").
- Moving to another identity provider no longer locks out linked members:
a different issuer is re-linked by verified email; within one issuer the
subject still never changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2: single sign-on first, then SCIM (your choice).
What's new
auth.sso_login,auth.sso_login_failed(with a reason code),sso.configured,sso.removed.docs/single-sign-on.md.How it's built
/sso/startfinds the account, and only its organization's identity provider is used. There are no domain claims and no account creation at sign-in; SCIM, next, provisions accounts. An unknown email and an organization without SSO get the same answer.(issuer, sub)stored on the user:response_mode=query, HTTP Basic client authenticationnextpath sit in a signed HttpOnly SameSite=Lax cookie on the gateway for 10 minuteskid); issuer, audience/azp, expiry and nonce are checked/common,/organizations,/consumers) are refusedsessionStorage(its SHA-256 goes to/sso/start), so a captured code or a sign-in started by someone else can't sign a victim's browser into another account (login CSRF). A wrong value leaves the code unused.sso_configs(forced RLS)users.sso_issuer/sso_subject(unique)sso_handoffswithscirm_sso_handoff_consume(code_hash, binding_hash)(definer, granted bydb_setup.py)Testing
tests/security/test_sso.py(26 tests), against a fake identity provider with its own RSA key:aud/iss/nonce, expired, unverified email,alg: none, HS256, unknownkid, foreign keynext, expired handoff/common//organizationsandftpissuers refused; the issuer must match metadata; a secret is needed the first time; admin-only; the secret is never shown; strict mode requires httpstests/db:sso_configshas forced RLS; handoff consume works with a non-superuser owner, refuses another binding, and works once. Migrations round-trip.readHandoff, binding helper),tscclean,vite buildOK. Strict docs build OK.127.0.0.1:9000, gateway atfakeidp:9000):auth.sso_loginwas audited, and the tab's binding was clearedNot in this PR
emailclaim configured for first sign-ins.x@othercompany.comthemselves). SSO and SCIM make this matter more. Worth an email-verification step later.Deploy
PUBLIC_API_URLon the gateway (a placeholder was added toall-services.yml) andPUBLIC_APP_URL. Without both, SSO is unavailable and the card says so.🤖 Generated with Claude Code