Skip to content

Phase 2: single sign-on with OpenID Connect - #39

Merged
alikahwaji merged 2 commits into
developfrom
feat/sso-oidc
Oct 23, 2026
Merged

alikahwaji merged 2 commits into
developfrom
feat/sso-oidc

Conversation

@alikahwaji

Copy link
Copy Markdown
Contributor

Phase 2: single sign-on first, then SCIM (your choice).

What's new

  • Administration → Integrations → Single sign-on (admins):
    • enter the issuer, client ID and client secret of the organization's identity provider (Entra, Okta, Google, Keycloak, …)
    • Check and save fetches the provider's metadata and refuses inconsistent settings
    • the secret is stored encrypted and write-only
    • the card shows the redirect URI to register
  • Sign in with SSO on the login page: enter your email, go to your identity provider, and come back signed in where you were.
  • Require single sign-on for members:
    • members can no longer sign in, reset or change a password in SCIRM, and accepting an invitation creates the account without a password session
    • administrators keep password plus two-step sign-in as the way in when the identity provider is down
  • Audit: auth.sso_login, auth.sso_login_failed (with a reason code), sso.configured, sso.removed.
  • Docs: docs/single-sign-on.md.

How it's built

  • Routing by the existing account, not by email domain. /sso/start finds 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.
  • Accounts are linked to the provider's subject, with (issuer, sub) stored on the user:
    • the first sign-in links by the email in the ID token, which must be verified and belong to an active member
    • after that the subject must match, so an email reassigned at the provider can't take over an account
    • moving to a new issuer re-links by verified email
  • Protocol:
    • authorization code with PKCE S256, response_mode=query, HTTP Basic client authentication
    • state, nonce, the verifier and the next path sit in a signed HttpOnly SameSite=Lax cookie on the gateway for 10 minutes
    • ID tokens: RS/PS/ES only, from the provider's published keys (one refetch for an unknown kid); issuer, audience/azp, expiry and nonce are checked
    • multi-tenant issuers (Entra /common, /organizations, /consumers) are refused
    • discovery, key-set and token requests go through the shared outbound guard
  • Handoff: the browser receives a one-time code in the URL fragment (hashed, 60 s, single use), not tokens. It only works together with a random value the login page keeps in sessionStorage (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.
  • Migration 023:
    • sso_configs (forced RLS)
    • users.sso_issuer/sso_subject (unique)
    • sso_handoffs with scirm_sso_handoff_consume(code_hash, binding_hash) (definer, granted by db_setup.py)

Testing

  • New tests/security/test_sso.py (26 tests), against a fake identity provider with its own RSA key:
    • the full flow; linking by subject, refusing a reassigned email, keeping a renamed email; moving to a new issuer
    • the same answer for unknown accounts and other organizations
    • the code works only with the starting tab's binding (and a wrong binding doesn't use it up); start without a binding is refused
    • bad tokens: wrong aud/iss/nonce, expired, unverified email, alg: none, HS256, unknown kid, foreign key
    • wrong state, missing cookie, open redirects in next, expired handoff
    • /common//organizations and ftp issuers refused; the issuer must match metadata; a secret is needed the first time; admin-only; the secret is never shown; strict mode requires https
    • enforcement: members refused with a password, admins allowed, nothing revealed on a wrong password, invitations give no session; turning enforce on later keeps the stored secret
  • tests/db: sso_configs has forced RLS; handoff consume works with a non-superuser owner, refuses another binding, and works once. Migrations round-trip.
  • Suites: 571 in-memory; security + db + evidence on PostgreSQL: 369. Frontend: 39 (readHandoff, binding helper), tsc clean, vite build OK. Strict docs build OK.
  • Live on the gate stack with a fake OIDC provider container (browser on 127.0.0.1:9000, gateway at fakeidp:9000):
    • saved settings in the Integrations card (the metadata check passed)
    • "Sign in with SSO" as a non-admin member went to the provider and back, signed in on the page they started from; the account was linked to the provider's subject, the handoff was used once, auth.sso_login was audited, and the tab's binding was cleared
    • with "Require SSO" on, that member's password sign-in was refused with a clear message, and the admin's password still worked
  • Bug found live and fixed: saving settings without re-entering the secret failed on PostgreSQL (the upsert checked the new row's empty secret first). It now updates in place, with a regression test.

Not in this PR

  • Account creation at first sign-in (SCIM next), domain verification, ending sessions when the provider disables someone (SCIM), single logout.
  • Turning on "Require SSO" doesn't end existing password sessions; they last until their refresh token expires (up to 30 days).
  • Entra needs the optional email claim configured for first sign-ins.
  • Pre-existing: accepting an invitation never verifies the invitee controls the email (an admin could invite and accept x@othercompany.com themselves). SSO and SCIM make this matter more. Worth an email-verification step later.

Deploy

  • Set PUBLIC_API_URL on the gateway (a placeholder was added to all-services.yml) and PUBLIC_APP_URL. Without both, SSO is unavailable and the card says so.
  • Migration 023 adds a function the migrate job grants. Restart the gateway after migrating.

🤖 Generated with Claude Code

alikahwaji and others added 2 commits October 23, 2026 14:13
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>
@alikahwaji
alikahwaji merged commit 74b28bf into develop Oct 23, 2026
21 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.

1 participant