From fc756da032a309d431d0c27c507aecb9a5f24ace Mon Sep 17 00:00:00 2001
From: alikahwaji
Date: Fri, 23 Oct 2026 14:13:44 +1300
Subject: [PATCH 1/2] feat(sso): single sign-on with OpenID Connect, per
organization
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
---
alembic/versions/023_sso.py | 88 +++++++
apps/api-gateway/main.py | 224 +++++++++++++++-
apps/frontend/src/App.tsx | 2 +
apps/frontend/src/components/SsoSettings.tsx | 118 +++++++++
apps/frontend/src/contexts/AuthContext.tsx | 9 +-
apps/frontend/src/pages/AuditLogPage.tsx | 9 +
apps/frontend/src/pages/IntegrationsPage.tsx | 2 +
apps/frontend/src/pages/LoginPage.tsx | 31 +++
.../src/pages/SsoCompletePage.test.ts | 21 ++
apps/frontend/src/pages/SsoCompletePage.tsx | 52 ++++
apps/frontend/src/services/api.ts | 31 +++
docs/single-sign-on.md | 85 ++++++
infra/k8s/staging/all-services.yml | 3 +
libs/common/oidc.py | 205 +++++++++++++++
libs/common/store.py | 139 ++++++++++
mkdocs.yml | 1 +
scripts/db_setup.py | 3 +-
tests/db/test_database.py | 21 +-
tests/security/test_sso.py | 246 ++++++++++++++++++
19 files changed, 1281 insertions(+), 9 deletions(-)
create mode 100644 alembic/versions/023_sso.py
create mode 100644 apps/frontend/src/components/SsoSettings.tsx
create mode 100644 apps/frontend/src/pages/SsoCompletePage.test.ts
create mode 100644 apps/frontend/src/pages/SsoCompletePage.tsx
create mode 100644 docs/single-sign-on.md
create mode 100644 libs/common/oidc.py
create mode 100644 tests/security/test_sso.py
diff --git a/alembic/versions/023_sso.py b/alembic/versions/023_sso.py
new file mode 100644
index 0000000..d225376
--- /dev/null
+++ b/alembic/versions/023_sso.py
@@ -0,0 +1,88 @@
+"""Single sign-on with OpenID Connect, per organization.
+
+- sso_configs: one identity provider per organization (issuer, client id, client secret
+ encrypted with secret_box and bound to "sso:", enforce flag). Forced RLS: every
+ read happens with the organization known (from the account at start, from the signed
+ state cookie at the callback).
+- users.sso_issuer / sso_subject: the identity provider's stable user id, linked at the
+ first SSO sign-in and required to match afterwards (emails can be reassigned at an
+ identity provider; the subject cannot). Unique per issuer.
+- sso_handoffs: after the callback the browser gets a one-time code (in the URL fragment),
+ not tokens. Only its hash is stored; it lasts 60 seconds and works once, consumed by
+ scirm_sso_handoff_consume (SECURITY DEFINER: the exchange happens before sign-in).
+
+Revision ID: 023
+Revises: 022
+Create Date: 2026-10-23
+"""
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "023"
+down_revision: Union[str, None] = "022"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+FUNCTIONS = ("scirm_sso_handoff_consume(text)",)
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE sso_configs (
+ organization_id uuid PRIMARY KEY REFERENCES organizations(id),
+ issuer text NOT NULL CHECK (length(issuer) <= 300),
+ client_id text NOT NULL CHECK (length(client_id) BETWEEN 1 AND 300),
+ client_secret_enc text NOT NULL CHECK (client_secret_enc LIKE 'enc:v1:%'),
+ enforce boolean NOT NULL DEFAULT false,
+ created_by text NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ """)
+ op.execute("ALTER TABLE sso_configs ENABLE ROW LEVEL SECURITY")
+ op.execute("ALTER TABLE sso_configs FORCE ROW LEVEL SECURITY")
+ op.execute("""
+ CREATE POLICY sso_configs_org_isolation ON sso_configs
+ USING (organization_id = scirm_current_org())
+ WITH CHECK (organization_id = scirm_current_org())
+ """)
+ op.execute("ALTER TABLE users ADD COLUMN sso_issuer text NULL, ADD COLUMN sso_subject text NULL")
+ op.execute("CREATE UNIQUE INDEX ux_users_sso ON users (sso_issuer, sso_subject) WHERE sso_subject IS NOT NULL")
+ op.execute("""
+ CREATE TABLE sso_handoffs (
+ code_hash text PRIMARY KEY CHECK (code_hash ~ '^[0-9a-f]{64}$'),
+ organization_id uuid NOT NULL REFERENCES organizations(id),
+ user_id uuid NOT NULL REFERENCES users(id),
+ expires_at timestamptz NOT NULL,
+ used_at timestamptz NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+ )
+ """)
+ # Enabled, not forced (like users): inserts happen with the organization set; the
+ # consume function (the owner) works before sign-in, also with a non-superuser owner.
+ op.execute("ALTER TABLE sso_handoffs ENABLE ROW LEVEL SECURITY")
+ op.execute("""
+ CREATE POLICY sso_handoffs_org_isolation ON sso_handoffs
+ USING (organization_id = scirm_current_org())
+ WITH CHECK (organization_id = scirm_current_org())
+ """)
+ op.execute("""
+ CREATE FUNCTION scirm_sso_handoff_consume(p_code_hash text)
+ RETURNS TABLE (user_id uuid, organization_id uuid)
+ LANGUAGE sql SECURITY DEFINER SET search_path = public, pg_temp AS $$
+ UPDATE sso_handoffs SET used_at = now()
+ WHERE code_hash = p_code_hash AND used_at IS NULL AND expires_at > now()
+ RETURNING sso_handoffs.user_id, sso_handoffs.organization_id
+ $$
+ """)
+ for fn in FUNCTIONS:
+ op.execute(f"REVOKE ALL ON FUNCTION {fn} FROM PUBLIC")
+
+
+def downgrade() -> None:
+ for fn in FUNCTIONS:
+ op.execute(f"DROP FUNCTION IF EXISTS {fn}")
+ op.execute("DROP TABLE IF EXISTS sso_handoffs")
+ op.execute("DROP INDEX IF EXISTS ux_users_sso")
+ op.execute("ALTER TABLE users DROP COLUMN IF EXISTS sso_subject, DROP COLUMN IF EXISTS sso_issuer")
+ op.execute("DROP TABLE IF EXISTS sso_configs")
diff --git a/apps/api-gateway/main.py b/apps/api-gateway/main.py
index 18cc665..7f67607 100644
--- a/apps/api-gateway/main.py
+++ b/apps/api-gateway/main.py
@@ -32,7 +32,7 @@
from libs.common.security import setup_cors, setup_rate_limiting
from libs.common.store import (API_KEY_PREFIX, AUDIT_CATEGORY_NAMES, FEEDBACK_VERDICTS, ROLES, EmailExists, LastAdmin,
audit_category, audit_cursor, create_store, is_api_key)
-from libs.common import secret_box
+from libs.common import oidc, secret_box
from libs.integrations import reports, webhooks
# MFA seeds are encrypted at rest; outside development the key must be configured.
@@ -82,7 +82,8 @@ async def lifespan(app: FastAPI):
_AUDITED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# Recorded by the handlers themselves, with the account email.
-_SELF_AUDITED_PATHS = {"/api/v1/auth/login", "/api/v1/invitations/accept", "/api/v1/invitations/lookup"}
+_SELF_AUDITED_PATHS = {"/api/v1/auth/login", "/api/v1/invitations/accept", "/api/v1/invitations/lookup",
+ "/api/v1/auth/sso/exchange"}
async def _account_owner(email: str) -> Optional[Dict[str, Any]]:
@@ -400,6 +401,10 @@ async def login(request: LoginRequest, http_request: Request):
ip=http_request.client.host if http_request.client else None)
if not user_info:
raise HTTPException(status_code=401, detail="Invalid email or password")
+ # Checked only after a correct password, so it reveals nothing to someone guessing.
+ if user_info.get("stored") and await _sso_enforced_for(user_info["org_id"], user_info["roles"]):
+ raise HTTPException(status_code=403, detail="Your organization signs in with single sign-on. "
+ "Use 'Sign in with SSO'.")
# Check if MFA is required
if MFA_ENABLED and _mfa_on(user_info):
@@ -1806,6 +1811,215 @@ async def export_full_report(token: str = Depends(security)):
# --- Password Reset ---
+# ---------------------------------------------------------------------------
+# Single sign-on (OpenID Connect, per organization) - libs/common/oidc.py
+# ---------------------------------------------------------------------------
+
+# The gateway's own public address: the redirect URI registered at identity providers.
+# Configured, never taken from the request's Host header.
+PUBLIC_API_URL = os.getenv("PUBLIC_API_URL", "http://localhost:8000" if IS_DEV else "").rstrip("/")
+SSO_COOKIE, SSO_COOKIE_PATH, SSO_STATE_TYPE = "scirm_sso", "/api/v1/auth/sso", "sso_state"
+SSO_HANDOFF_SECONDS = 60
+
+
+def _sso_redirect_uri() -> str:
+ return f"{PUBLIC_API_URL}/api/v1/auth/sso/callback"
+
+
+def _safe_next(path: Optional[str]) -> str:
+ """Where to land after sign-in: a path in this app only (no //host, no scheme)."""
+ path = (path or "").strip()
+ if not path.startswith("/") or path.startswith("//") or "\\" in path or ":" in path.split("?")[0]:
+ return "/dashboard"
+ return path[:300]
+
+
+async def _sso_enforced_for(org: str, roles: List[str]) -> bool:
+ """Members of an organization that enforces SSO sign in through it; administrators keep
+ their password (and two-step sign-in) as a way in when the identity provider is down."""
+ if "admin" in roles:
+ return False
+ try:
+ config = await audit_store.get_sso_config(org)
+ except Exception:
+ return False
+ return bool(config and config.get("enforce"))
+
+
+def _to_login(reason: str):
+ from fastapi.responses import RedirectResponse
+ response = RedirectResponse(f"{PUBLIC_APP_URL}/login?sso={reason}", status_code=302)
+ response.delete_cookie(SSO_COOKIE, path=SSO_COOKIE_PATH)
+ return response
+
+
+async def _audit_sso_failure(org: Optional[str], actor: Optional[str], reason: str) -> None:
+ try:
+ await audit_store.record_audit(org, {"actor_type": "user", "actor": actor, "action": "auth.sso_login_failed",
+ "resource_type": "auth", "status_code": 401, "details": {"reason": reason}})
+ except Exception as exc:
+ logger.error("Audit write failed", error=type(exc).__name__, action="auth.sso_login_failed")
+
+
+@app.get("/api/v1/auth/sso/start")
+async def sso_start(email: str, next: Optional[str] = None):
+ """Begin SSO for this email's account: off to its organization's identity provider."""
+ from fastapi.responses import RedirectResponse
+ if not PUBLIC_APP_URL or not PUBLIC_API_URL:
+ raise HTTPException(status_code=503, detail="Single sign-on is not set up on this deployment.")
+ account = await audit_store.find_login_user((email or "").strip().lower())
+ if not account or not account["is_active"]:
+ return _to_login("unavailable") # the same answer as an organization without SSO
+ org = str(account["organization_id"])
+ config = await audit_store.get_sso_config(org)
+ if not config:
+ return _to_login("unavailable")
+ try:
+ doc = await oidc.discover(config["issuer"])
+ except oidc.OidcError as exc:
+ await _audit_sso_failure(org, account["email"], exc.reason)
+ return _to_login("provider_error")
+ verifier, challenge = oidc.pkce_pair()
+ state, nonce = secrets.token_urlsafe(24), secrets.token_urlsafe(24)
+ cookie = create_access_token(data={"sub": "sso", "type": SSO_STATE_TYPE, "org": org, "state": state,
+ "nonce": nonce, "verifier": verifier, "next": _safe_next(next)},
+ expires_delta=timedelta(minutes=10))
+ response = RedirectResponse(oidc.authorization_url(doc, config["client_id"], _sso_redirect_uri(), state, nonce,
+ challenge, login_hint=account["email"]), status_code=302)
+ response.set_cookie(SSO_COOKIE, cookie, max_age=600, httponly=True, secure=not IS_DEV, samesite="lax",
+ path=SSO_COOKIE_PATH)
+ return response
+
+
+@app.get("/api/v1/auth/sso/callback")
+async def sso_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None,
+ error: Optional[str] = None):
+ """The identity provider's answer. Sign-in completes in the app with a one-time code."""
+ from fastapi.responses import RedirectResponse
+ from urllib.parse import quote
+ raw = request.cookies.get(SSO_COOKIE)
+ if not raw:
+ await _audit_sso_failure(None, None, "no_session")
+ return _to_login("expired")
+ try:
+ payload = decode_token(raw, SSO_STATE_TYPE)
+ except HTTPException:
+ await _audit_sso_failure(None, None, "expired")
+ return _to_login("expired")
+ org = str(payload["org"]) # from the signed cookie, never from the query
+ if error:
+ await _audit_sso_failure(org, None, f"provider_{str(error)[:40]}")
+ return _to_login("denied")
+ if not state or not code or not secrets.compare_digest(str(state), str(payload.get("state", ""))):
+ await _audit_sso_failure(org, None, "bad_state")
+ return _to_login("failed")
+ config = await audit_store.get_sso_config(org, with_secret=True)
+ if not config:
+ await _audit_sso_failure(org, None, "not_configured")
+ return _to_login("unavailable")
+ try:
+ doc = await oidc.discover(config["issuer"])
+ tokens = await oidc.exchange_code(doc, config["client_id"], config["client_secret"], code,
+ payload["verifier"], _sso_redirect_uri())
+ if not isinstance(tokens.get("id_token"), str):
+ raise oidc.OidcError("no_id_token", "The identity provider sent no ID token.")
+ claims = await oidc.verify_id_token(tokens["id_token"], doc, config["client_id"], payload["nonce"])
+ except oidc.OidcError as exc:
+ await _audit_sso_failure(org, None, exc.reason)
+ return _to_login("failed")
+ issuer, subject = doc["issuer"], str(claims["sub"])
+ member = await audit_store.sso_member(org, issuer=issuer, subject=subject)
+ if member is None: # first SSO sign-in: link by the verified email, once
+ email = oidc.email_from(claims)
+ member = await audit_store.sso_member(org, email=email) if email else None
+ if member is None:
+ await _audit_sso_failure(org, email, "no_account" if email else "no_verified_email")
+ return _to_login("no_account")
+ if not await audit_store.link_sso(org, member["id"], issuer, subject):
+ await _audit_sso_failure(org, member["email"], "subject_mismatch")
+ return _to_login("failed")
+ if not member["is_active"]:
+ await _audit_sso_failure(org, member["email"], "inactive")
+ return _to_login("no_account")
+ import hashlib
+ handoff = secrets.token_urlsafe(32)
+ expires = (datetime.now(timezone.utc) + timedelta(seconds=SSO_HANDOFF_SECONDS)).isoformat()
+ await audit_store.create_sso_handoff(org, str(member["id"]), hashlib.sha256(handoff.encode()).hexdigest(), expires)
+ await _record_login({"stored": True, "org_id": org, "id": str(member["id"])})
+ await _audit_event(org, str(member["id"]), member["email"], "auth.sso_login")
+ response = RedirectResponse(f"{PUBLIC_APP_URL}/sso/complete#code={handoff}&next={quote(payload['next'], safe='/')}",
+ status_code=302)
+ response.delete_cookie(SSO_COOKIE, path=SSO_COOKIE_PATH)
+ return response
+
+
+class SsoExchangeRequest(BaseModel):
+ code: str = Field(..., min_length=20, max_length=200)
+
+
+@app.post("/api/v1/auth/sso/exchange")
+async def sso_exchange(body: SsoExchangeRequest):
+ """The app trades the one-time code (60 s, single use) for tokens. The identity provider
+ did the authentication, including its own two-step sign-in."""
+ import hashlib
+ consumed = await audit_store.consume_sso_handoff(hashlib.sha256(body.code.encode()).hexdigest())
+ if not consumed:
+ raise HTTPException(status_code=400, detail="This sign-in link has expired. Sign in again.")
+ found = await _find_user_by_id(str(consumed["user_id"]))
+ if not found:
+ raise HTTPException(status_code=401, detail="Account not found")
+ return _issue_tokens(*found)
+
+
+class SsoConfigRequest(BaseModel):
+ issuer: str = Field(..., min_length=8, max_length=300)
+ client_id: str = Field(..., min_length=1, max_length=300)
+ client_secret: Optional[str] = Field(None, max_length=500)
+ enforce: bool = False
+
+
+def _sso_view(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ return {"config": config, "redirect_uri": _sso_redirect_uri() if PUBLIC_API_URL else None,
+ "available": bool(PUBLIC_APP_URL and PUBLIC_API_URL)}
+
+
+@app.get("/api/v1/org/sso")
+async def get_sso(token: str = Depends(security)):
+ user = await verify_token(token.credentials)
+ _require_admin(user, "manage single sign-on")
+ return _sso_view(await audit_store.get_sso_config(user.organization_id))
+
+
+@app.put("/api/v1/org/sso")
+async def put_sso(body: SsoConfigRequest, token: str = Depends(security)):
+ """Save the identity provider after checking its metadata is reachable and consistent.
+ The client secret is write-only (leave it out to keep the stored one)."""
+ user = await verify_token(token.credentials)
+ _require_admin(user, "manage single sign-on")
+ try:
+ issuer = oidc.validate_issuer(body.issuer)
+ await oidc.discover(issuer)
+ except oidc.OidcError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ try:
+ config = await audit_store.set_sso_config(user.organization_id, issuer, body.client_id.strip(),
+ (body.client_secret or "").strip() or None, body.enforce, user.email)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Enter the client secret.")
+ await _audit_event(user.organization_id, user.id, user.email, "sso.configured")
+ return _sso_view(config)
+
+
+@app.delete("/api/v1/org/sso")
+async def delete_sso(token: str = Depends(security)):
+ user = await verify_token(token.credentials)
+ _require_admin(user, "manage single sign-on")
+ if not await audit_store.delete_sso_config(user.organization_id):
+ raise HTTPException(status_code=404, detail="Single sign-on is not configured")
+ await _audit_event(user.organization_id, user.id, user.email, "sso.removed")
+ return {"deleted": True}
+
+
PASSWORD_RESET_MINUTES = int(os.getenv("PASSWORD_RESET_MINUTES", "30"))
_RESET_SENT = "If an account exists for this email, a reset link has been sent. It works once, for 30 minutes."
@@ -1846,6 +2060,9 @@ async def forgot_password(body: ForgotPasswordRequest):
raise HTTPException(status_code=503, detail="Password reset by email is not set up on this deployment. "
"Ask an administrator of your organization.")
import hashlib
+ existing = await audit_store.find_login_user(body.email.strip().lower())
+ if existing and await _sso_enforced_for(str(existing["organization_id"]), list(existing["roles"] or [])):
+ return {"message": _RESET_SENT} # their password is not used; the same answer as always
token_value = secrets.token_urlsafe(32)
expires = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_MINUTES)).isoformat()
account = await audit_store.request_password_reset(body.email.strip().lower(),
@@ -1876,6 +2093,9 @@ async def change_password(body: ChangePasswordRequest, token: str = Depends(secu
user = await verify_token(token.credentials)
if DEV_USERS.get(user.email.lower()):
raise HTTPException(status_code=400, detail="Demo accounts use DEV_PASSWORD and cannot change it.")
+ if await _sso_enforced_for(user.organization_id, user.roles):
+ raise HTTPException(status_code=400, detail="Your organization signs in with single sign-on; "
+ "change your password with your identity provider.")
_password_ok(body.new_password)
if body.new_password == body.current_password:
raise HTTPException(status_code=400, detail="Choose a password different from the current one.")
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index 0093439..642850c 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import ResetPasswordPage from './pages/ResetPasswordPage';
+import SsoCompletePage from './pages/SsoCompletePage';
import NotFoundPage from './pages/NotFoundPage';
import SupplierScanPage from './pages/SupplierScanPage';
import SapImportPage from './pages/SapImportPage';
@@ -70,6 +71,7 @@ export default function App() {
{/* Invitation links: public, whether or not someone is signed in on this browser */}
} />
} />
+ } />
{/* 404 catch-all */}
} />
diff --git a/apps/frontend/src/components/SsoSettings.tsx b/apps/frontend/src/components/SsoSettings.tsx
new file mode 100644
index 0000000..7c210d5
--- /dev/null
+++ b/apps/frontend/src/components/SsoSettings.tsx
@@ -0,0 +1,118 @@
+import { FormEvent, useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { api, SsoSettings as Settings } from '../services/api';
+
+function errorText(err: any, fallback: string): string {
+ return err?.response?.data?.detail || fallback;
+}
+
+// Single sign-on with the organization's identity provider (OpenID Connect), for admins.
+export default function SsoSettings() {
+ const { t } = useTranslation();
+ const [settings, setSettings] = useState(null);
+ const [issuer, setIssuer] = useState('');
+ const [clientId, setClientId] = useState('');
+ const [secret, setSecret] = useState('');
+ const [enforce, setEnforce] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [notice, setNotice] = useState('');
+
+ const apply = (s: Settings) => {
+ setSettings(s);
+ setIssuer(s.config?.issuer || '');
+ setClientId(s.config?.client_id || '');
+ setEnforce(Boolean(s.config?.enforce));
+ setSecret('');
+ };
+ const load = useCallback(() => api.getSso().then(apply)
+ .catch((err) => setError(errorText(err, t('sso.loadFailed', 'Could not load the single sign-on settings.')))), [t]);
+ useEffect(() => { load(); }, [load]);
+
+ const save = async (e: FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ setError('');
+ setNotice('');
+ try {
+ apply(await api.saveSso({ issuer: issuer.trim(), client_id: clientId.trim(), client_secret: secret || undefined, enforce }));
+ setNotice(t('sso.saved', 'Saved. The identity provider answered and its settings match.'));
+ } catch (err) {
+ setError(errorText(err, t('sso.saveFailed', 'Could not save the settings.')));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async () => {
+ setError('');
+ try {
+ await api.deleteSso();
+ setNotice(t('sso.removed', 'Single sign-on is off. Members sign in with their passwords.'));
+ await load();
+ } catch (err) {
+ setError(errorText(err, t('sso.removeFailed', 'Could not remove single sign-on.')));
+ }
+ };
+
+ const field = 'block w-full mt-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-white';
+ return (
+
+
+
{t('sso.title', 'Single sign-on')}
+ {settings?.config && (
+
+ {settings.config.enforce ? t('sso.onEnforced', 'On · required') : t('sso.on', 'On')}
+
+ )}
+
+
+ {t('sso.hint', 'Members sign in through your identity provider (OpenID Connect: Microsoft Entra, Okta, Google, Keycloak and others). Their accounts must already exist in SCIRM (invite them); the first sign-in links the account to the provider.')}
+
+ {settings && !settings.available && (
+
{t('sso.unavailable', 'This deployment is missing PUBLIC_APP_URL or PUBLIC_API_URL, so single sign-on cannot be used yet.')}
+ )}
+ {settings?.redirect_uri && (
+
+ {t('sso.redirect', 'Register this redirect URI at your identity provider:')}{' '}
+ {settings.redirect_uri}
+
+ )}
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+
+ );
+}
diff --git a/apps/frontend/src/contexts/AuthContext.tsx b/apps/frontend/src/contexts/AuthContext.tsx
index 78b6b73..1b8bb2b 100644
--- a/apps/frontend/src/contexts/AuthContext.tsx
+++ b/apps/frontend/src/contexts/AuthContext.tsx
@@ -17,6 +17,8 @@ interface AuthContextType {
acceptInvitation: (token: string, name: string, password: string) => Promise;
/** Changes the password; other sessions end, this one continues with new tokens. */
changePassword: (current: string, next: string) => Promise;
+ /** Finishes single sign-on with the one-time code from the redirect. */
+ completeSso: (code: string) => Promise;
logout: () => void;
}
@@ -29,6 +31,7 @@ const AuthContext = createContext({
signup: async () => {},
acceptInvitation: async () => {},
changePassword: async () => {},
+ completeSso: async () => {},
logout: () => {},
});
@@ -99,8 +102,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
begin(await api.changePassword(current, next));
};
+ const completeSso = async (code: string) => {
+ begin(await api.ssoExchange(code));
+ };
+
return (
-
+
{children}
);
diff --git a/apps/frontend/src/pages/AuditLogPage.tsx b/apps/frontend/src/pages/AuditLogPage.tsx
index bedfc40..3e16f91 100644
--- a/apps/frontend/src/pages/AuditLogPage.tsx
+++ b/apps/frontend/src/pages/AuditLogPage.tsx
@@ -7,6 +7,12 @@ import { utcDate } from '../services/time';
const NAMED: Record = {
'auth.login': 'Signed in',
'auth.login_failed': 'Failed sign-in',
+ 'auth.sso_login': 'Signed in with single sign-on',
+ 'auth.sso_login_failed': 'Failed single sign-on',
+ 'auth.password_reset_requested': 'Asked for a password reset link',
+ 'auth.password_reset': 'Reset their password with a link',
+ 'sso.configured': 'Set up single sign-on',
+ 'sso.removed': 'Turned off single sign-on',
'mfa.enabled': 'Turned on two-step sign-in',
'mfa.disabled': 'Turned off two-step sign-in',
'mfa.reset_by_admin': "Reset a teammate's two-step sign-in",
@@ -47,6 +53,9 @@ const ROUTES: Record = {
'POST /api/v1/auth/change-password': 'Changed their password',
'POST /api/v1/feedback': 'Rated a record or answer',
'POST /api/v1/auth/reset-password': 'Reset their password',
+ 'POST /api/v1/auth/forgot-password': 'Asked for a password reset link',
+ 'PUT /api/v1/org/sso': 'Saved single sign-on settings',
+ 'DELETE /api/v1/org/sso': 'Turned off single sign-on',
'POST /api/v1/suppliers': 'Added a supplier',
'PUT /api/v1/suppliers/{supplier_id}': 'Edited a supplier',
'DELETE /api/v1/suppliers/{supplier_id}': 'Deleted a supplier',
diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx
index 3b66aeb..c22f091 100644
--- a/apps/frontend/src/pages/IntegrationsPage.tsx
+++ b/apps/frontend/src/pages/IntegrationsPage.tsx
@@ -7,6 +7,7 @@ import { utcDate } from '../services/time';
import ApiKeysSettings from '../components/ApiKeysSettings';
import WebhooksSettings from '../components/WebhooksSettings';
import ReportsSettings from '../components/ReportsSettings';
+import SsoSettings from '../components/SsoSettings';
type Tone = 'ok' | 'warn' | 'bad' | 'off';
const TONES: Record = {
@@ -127,6 +128,7 @@ export default function IntegrationsPage() {
+
diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx
index 29347bf..ed8b638 100644
--- a/apps/frontend/src/pages/LoginPage.tsx
+++ b/apps/frontend/src/pages/LoginPage.tsx
@@ -2,8 +2,19 @@ import { useState, useRef } from 'react';
import { useNavigate, Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
+import { ssoStartUrl } from '../services/api';
const MAX_ATTEMPTS = 5;
+
+// Why single sign-on sent the person back here (the gateway's ?sso= reason).
+const SSO_MESSAGES: Record = {
+ unavailable: 'Single sign-on is not set up for this email. Sign in with your password, or ask your administrator.',
+ no_account: 'Your identity provider signed you in, but there is no active SCIRM account for you in that organization. Ask your administrator.',
+ denied: 'The sign-in was cancelled or refused at your identity provider.',
+ expired: 'The sign-in took too long or was started in another browser. Try again.',
+ failed: 'Single sign-on failed. Try again, or ask your administrator to check the identity provider settings.',
+ provider_error: 'Your identity provider could not be reached. Try again later.',
+};
const LOCKOUT_SECONDS = 60;
export default function LoginPage() {
@@ -22,6 +33,7 @@ export default function LoginPage() {
const [mfaToken, setMfaToken] = useState(null);
const [code, setCode] = useState('');
const expired = Boolean((location.state as any)?.expired);
+ const ssoReason = new URLSearchParams(location.search).get('sso');
// Where to redirect after login (saved by ProtectedRoute)
const redirectTo = (location.state as any)?.from || '/dashboard';
@@ -77,6 +89,12 @@ export default function LoginPage() {
Supply Chain Risk Management
+ {ssoReason && SSO_MESSAGES[ssoReason] && !error && (
+
+ {t(`auth.sso.${ssoReason}`, SSO_MESSAGES[ssoReason])}
+
+ )}
+
{expired && !error && (
{t('auth.sessionExpired', 'Your session has ended. Sign in again to return to where you were.')}
@@ -156,6 +174,19 @@ export default function LoginPage() {
+ {!mfaToken && (
+ {
+ if (!email.includes('@')) { setError(t('auth.ssoNeedsEmail', 'Enter your email first.')); return; }
+ window.location.assign(ssoStartUrl(email.trim(), redirectTo));
+ }}
+ className="w-full mt-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
+ >
+ {t('auth.ssoSignIn', 'Sign in with SSO')}
+
+ )}
+
{t('auth.forgotPassword', 'Forgot password?')}
diff --git a/apps/frontend/src/pages/SsoCompletePage.test.ts b/apps/frontend/src/pages/SsoCompletePage.test.ts
new file mode 100644
index 0000000..7114634
--- /dev/null
+++ b/apps/frontend/src/pages/SsoCompletePage.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../contexts/AuthContext', () => ({ useAuth: () => ({}) }));
+
+import { readHandoff } from './SsoCompletePage';
+
+describe('readHandoff', () => {
+ it('reads the code and destination from the fragment', () => {
+ expect(readHandoff('#code=abc123&next=/suppliers%3Fx%3D1')).toEqual({ code: 'abc123', next: '/suppliers?x=1' });
+ });
+
+ it('never leaves the app', () => {
+ for (const next of ['https://evil.example.com', '//evil.example.com', '/\\evil', 'javascript:alert(1)']) {
+ expect(readHandoff(`#code=c&next=${encodeURIComponent(next)}`).next).toBe('/dashboard');
+ }
+ });
+
+ it('copes with a missing code', () => {
+ expect(readHandoff('')).toEqual({ code: '', next: '/dashboard' });
+ });
+});
diff --git a/apps/frontend/src/pages/SsoCompletePage.tsx b/apps/frontend/src/pages/SsoCompletePage.tsx
new file mode 100644
index 0000000..c7c7430
--- /dev/null
+++ b/apps/frontend/src/pages/SsoCompletePage.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useRef, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { useAuth } from '../contexts/AuthContext';
+
+/** The one-time code and destination from /sso/complete#code=...&next=... */
+export function readHandoff(hash: string): { code: string; next: string } {
+ const params = new URLSearchParams(hash.replace(/^#/, ''));
+ const next = params.get('next') || '/dashboard';
+ // Only paths in this app (the gateway checks this too).
+ const safe = next.startsWith('/') && !next.startsWith('//') && !next.includes('\\') ? next : '/dashboard';
+ return { code: params.get('code') || '', next: safe };
+}
+
+// Where the identity provider's sign-in lands: trade the one-time code for a session. The
+// code is in the fragment, so it never reaches a server log or a Referer header.
+export default function SsoCompletePage() {
+ const { t } = useTranslation();
+ const { completeSso } = useAuth();
+ const navigate = useNavigate();
+ const [error, setError] = useState('');
+ const started = useRef(false);
+
+ useEffect(() => {
+ if (started.current) return; // the code works once; React may run effects twice in development
+ started.current = true;
+ const { code, next } = readHandoff(window.location.hash);
+ window.history.replaceState(null, '', window.location.pathname);
+ if (!code) {
+ setError(t('auth.ssoNoCode', 'This page needs the sign-in from your identity provider.'));
+ return;
+ }
+ completeSso(code)
+ .then(() => navigate(next, { replace: true }))
+ .catch((err) => setError(err?.response?.data?.detail || t('auth.ssoExchangeFailed', 'Sign-in failed. Try again.')));
+ }, [completeSso, navigate, t]);
+
+ return (
+
+
+ {error ? (
+ <>
+
{error}
+
{t('auth.backToSignIn', 'Back to Sign In')}
+ >
+ ) : (
+
{t('auth.ssoSigningIn', 'Signing you in...')}
+ )}
+
+
+ );
+}
diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts
index 8bffd1a..b3bc348 100644
--- a/apps/frontend/src/services/api.ts
+++ b/apps/frontend/src/services/api.ts
@@ -3,6 +3,11 @@ import { isAuthRequest, Session } from './session';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
+/** Where the browser goes to start single sign-on (a full-page navigation to the gateway). */
+export function ssoStartUrl(email: string, next: string): string {
+ return `${API_BASE_URL}/api/v1/auth/sso/start?${new URLSearchParams({ email, next })}`;
+}
+
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
@@ -318,6 +323,15 @@ export interface ApiKey {
revoked_at: string | null;
}
+export interface SsoSettings {
+ config: { issuer: string; client_id: string; enforce: boolean; has_secret: boolean; created_by: string | null;
+ updated_at: string } | null;
+ /** Register this at the identity provider. */
+ redirect_uri: string | null;
+ /** False when the deployment lacks PUBLIC_APP_URL / PUBLIC_API_URL. */
+ available: boolean;
+}
+
export type WebhookEvent = 'record.matched' | 'exposure.detected';
export interface Webhook {
@@ -539,6 +553,23 @@ export const api = {
return (await apiClient.post('/api/v1/auth/reset-password', { token, new_password: newPassword })).data;
},
+ /** Trades the one-time code from the SSO redirect for tokens. */
+ async ssoExchange(code: string): Promise
{
+ return (await apiClient.post('/api/v1/auth/sso/exchange', { code })).data;
+ },
+
+ async getSso(): Promise {
+ return (await apiClient.get('/api/v1/org/sso')).data;
+ },
+
+ async saveSso(body: { issuer: string; client_id: string; client_secret?: string; enforce: boolean }): Promise {
+ return (await apiClient.put('/api/v1/org/sso', body)).data;
+ },
+
+ async deleteSso(): Promise {
+ await apiClient.delete('/api/v1/org/sso');
+ },
+
async verifyMfa(mfaToken: string, totpCode: string): Promise {
const response = await apiClient.post('/api/v1/auth/mfa/verify', { mfa_token: mfaToken, totp_code: totpCode });
return response.data;
diff --git a/docs/single-sign-on.md b/docs/single-sign-on.md
new file mode 100644
index 0000000..72435ad
--- /dev/null
+++ b/docs/single-sign-on.md
@@ -0,0 +1,85 @@
+# Single sign-on
+
+An organization can sign its members in through its own identity provider with
+**OpenID Connect**: Microsoft Entra ID, Okta, Google Workspace, Keycloak, Auth0 and
+others. The identity provider does the authentication, including its own two-step
+sign-in; SCIRM's two-step sign-in is not asked for after single sign-on.
+
+## Setting up
+
+1. At the identity provider, create an application (a web app, confidential client) with
+ the redirect URI shown in **Administration → Integrations → Single sign-on**. It is
+ `/api/v1/auth/sso/callback`.
+2. In SCIRM, enter:
+ - the **issuer**: your tenant-specific one, exactly as the provider states it (for
+ Entra: `https://login.microsoftonline.com//v2.0`)
+ - the **client ID**
+ - the **client secret**
+
+ **Check and save** fetches the provider's metadata and refuses settings that do not
+ match. The secret is stored encrypted and never shown again; leave it empty later to
+ keep it.
+3. Invite your members as usual. **SSO does not create accounts.** Someone without an
+ active SCIRM account in your organization is refused after signing in at the provider.
+ Automatic provisioning comes with SCIM.
+
+Members click **Sign in with SSO** on the sign-in page and enter their email. SCIRM finds
+their account, sends them to their organization's identity provider, and back.
+
+## Require single sign-on
+
+With **Require single sign-on for members** on, members can no longer sign in, reset or
+change a password in SCIRM. **Administrators keep their password** (with two-step sign-in)
+so the organization is not locked out when the identity provider is down; give that role
+to few people.
+
+## How accounts are matched
+
+- **The first SSO sign-in** links the account to the provider's subject (its stable user id)
+ using the email in the ID token. That email must be verified (`email_verified` is not
+ false) and belong to an active member of this organization.
+- **After that** the subject must match. A different person who later gets the same email
+ at the provider is refused, and a member whose email changes at the provider still gets
+ in.
+
+## Security
+
+- **Flow:** authorization code with PKCE (S256), `response_mode=query`, and a confidential
+ client (HTTP Basic). State, nonce and the PKCE verifier sit in a signed, HttpOnly,
+ SameSite=Lax cookie on the gateway for 10 minutes.
+- **ID token checks:**
+ - an asymmetric algorithm (RS, PS or ES), from a key in the provider's key set (fetched
+ again once for an unknown key id)
+ - issuer, audience (and `azp`), expiry within 60 s of leeway, and the nonce
+- **Refused issuers:** multi-tenant issuers (Entra `/common`, `/organizations`,
+ `/consumers`), because anyone can create a tenant there with any email.
+- **Requests to the provider:** discovery, key set and token go through the same outbound
+ guard as SAP and webhooks. Outside development that means https only, public addresses
+ only, no redirects, short timeouts and small responses.
+- **After the callback:** the browser gets a **one-time code in the URL fragment**, not
+ tokens. It is valid for 60 seconds and works once; the app exchanges it at
+ `/api/v1/auth/sso/exchange`.
+- **Audit log:** `auth.sso_login`, `auth.sso_login_failed` (with a reason code such as
+ `bad_state`, `invalid_token`, `no_account`, `subject_mismatch`), `sso.configured` and
+ `sso.removed`.
+
+Asking to sign in with an email that has no account, or whose organization has no single
+sign-on, gives the same answer. For an organization with single sign-on, the redirect to
+its identity provider shows that the account exists, as with any SSO.
+
+## Deployment
+
+| Variable | |
+|---|---|
+| `PUBLIC_API_URL` | The gateway's public address (development default `http://localhost:8000`). The redirect URI is built from it, never from the request. |
+| `PUBLIC_APP_URL` | The web app's address: where sign-in finishes. |
+
+Without both, single sign-on is unavailable and the page says so.
+
+## Not built yet
+
+- Creating accounts at first sign-in (SCIM will provision them).
+- Domain verification.
+- Ending SCIRM sessions when the identity provider disables someone (SCIM will deactivate
+ them).
+- Single logout.
diff --git a/infra/k8s/staging/all-services.yml b/infra/k8s/staging/all-services.yml
index 133f27e..6108a72 100644
--- a/infra/k8s/staging/all-services.yml
+++ b/infra/k8s/staging/all-services.yml
@@ -388,6 +388,9 @@ spec:
# Base URL for invitation links (scripts/create_tenant.py, Settings > Team).
- name: PUBLIC_APP_URL
value: "REPLACE_WITH_STAGING_APP_URL"
+ # The gateway's own public address: the SSO redirect URI is /api/v1/auth/sso/callback.
+ - name: PUBLIC_API_URL
+ value: "REPLACE_WITH_STAGING_API_URL"
- name: JWT_SECRET_KEY
valueFrom:
secretKeyRef:
diff --git a/libs/common/oidc.py b/libs/common/oidc.py
new file mode 100644
index 0000000..73ac8c8
--- /dev/null
+++ b/libs/common/oidc.py
@@ -0,0 +1,205 @@
+"""
+OpenID Connect (authorization code flow with PKCE) against a tenant's identity provider.
+
+The issuer, client id and client secret are entered by a tenant admin, so every server-side
+request (discovery document, key set, token endpoint) goes through the outbound guard
+(libs/common/outbound.py): outside development https only, public addresses only, pinned
+to the checked address, no redirects followed, small responses, short timeouts.
+
+ID tokens are accepted only when signed with an asymmetric algorithm (RS/PS/ES) by a key
+from the provider's key set, issued by the configured issuer for this client, unexpired,
+and carrying the nonce sent with the request. Multi-tenant issuers (Microsoft Entra
+/common, /organizations, /consumers) are refused: anyone can create a tenant there and
+put any email in it.
+"""
+
+import base64
+import hashlib
+import json
+import os
+import secrets
+import time
+from typing import Any, Callable, Dict, List, Optional, Tuple
+from urllib.parse import urlencode, urlsplit
+
+import httpx
+import jwt
+
+from libs.common import outbound
+
+_ENVIRONMENT = os.getenv("ENVIRONMENT", "development").lower()
+STRICT = _ENVIRONMENT not in {"development", "dev", "local", "test"}
+ALGORITHMS = ("RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512")
+TIMEOUT = httpx.Timeout(10.0, connect=5.0)
+MAX_BYTES = 1024 * 1024
+CACHE_SECONDS = 3600
+LEEWAY = 60
+_MULTI_TENANT = ("/common", "/organizations", "/consumers")
+
+# Code-only hooks for tests: an httpx transport, and the DNS resolver.
+transport_factory: Optional[Callable[[], httpx.AsyncBaseTransport]] = None
+resolve: Callable[[str, int], List[str]] = outbound.system_resolve
+_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
+
+
+class OidcError(Exception):
+ """A sign-in or configuration failure; .reason is a short code for the audit log."""
+
+ def __init__(self, reason: str, message: str):
+ super().__init__(message)
+ self.reason = reason
+
+
+def validate_issuer(issuer: str, strict: Optional[bool] = None) -> str:
+ strict = STRICT if strict is None else strict
+ issuer = (issuer or "").strip()
+ parts = urlsplit(issuer)
+ if parts.scheme not in ("https", "http") or not parts.hostname:
+ raise OidcError("bad_issuer", "The issuer must be a URL, e.g. https://login.example.com/realms/acme.")
+ if strict and parts.scheme != "https":
+ raise OidcError("bad_issuer", "The issuer must use https://.")
+ if parts.query or parts.fragment or parts.username or parts.password:
+ raise OidcError("bad_issuer", "The issuer must not contain credentials, a query or a fragment.")
+ path = parts.path.rstrip("/").lower()
+ if any(path == m or path.startswith(m + "/") for m in _MULTI_TENANT):
+ raise OidcError("multi_tenant_issuer", "Use your organization's own (tenant-specific) issuer, not a "
+ "multi-tenant one such as /common or /organizations.")
+ return issuer
+
+
+async def _request(method: str, url: str, strict: bool, **kwargs: Any) -> Dict[str, Any]:
+ parts = urlsplit(url)
+ if strict and parts.scheme != "https":
+ raise OidcError("insecure_endpoint", "The identity provider must use https://.")
+ try:
+ import asyncio
+ target, sni = await asyncio.to_thread(outbound.pin, url, strict, resolve)
+ except outbound.OutboundError as exc:
+ raise OidcError("unreachable", f"The identity provider host {exc.host} "
+ f"{'could not be found' if exc.kind == 'dns' else 'is not a public address'}.")
+ headers = {"Accept": "application/json", **kwargs.pop("headers", {})}
+ if sni:
+ headers["Host"] = parts.netloc
+ options: Dict[str, Any] = {"timeout": TIMEOUT, "follow_redirects": False}
+ if transport_factory is not None:
+ options["transport"] = transport_factory()
+ try:
+ async with httpx.AsyncClient(**options) as client:
+ async with client.stream(method, target, headers=headers, extensions={"sni_hostname": sni} if sni else {},
+ **kwargs) as response:
+ body = bytearray()
+ async for chunk in response.aiter_bytes():
+ body += chunk
+ if len(body) > MAX_BYTES:
+ raise OidcError("too_large", "The identity provider sent too large a response.")
+ status = response.status_code
+ except httpx.HTTPError as exc:
+ raise OidcError("unreachable", f"Could not reach the identity provider ({type(exc).__name__}).")
+ try:
+ data = json.loads(bytes(body))
+ except ValueError:
+ data = None
+ if status != 200 or not isinstance(data, dict):
+ error = data.get("error") if isinstance(data, dict) else None
+ raise OidcError("provider_error", f"The identity provider answered HTTP {status}"
+ + (f" ({error})" if error else "") + ".")
+ return data
+
+
+async def discover(issuer: str, strict: Optional[bool] = None) -> Dict[str, Any]:
+ """The provider's metadata. Its issuer must equal the configured one exactly."""
+ strict = STRICT if strict is None else strict
+ cached = _cache.get(f"disc:{issuer}")
+ if cached and cached[0] > time.time():
+ return cached[1]
+ doc = await _request("GET", issuer.rstrip("/") + "/.well-known/openid-configuration", strict)
+ if doc.get("issuer") != issuer:
+ raise OidcError("issuer_mismatch", f"The provider says its issuer is {doc.get('issuer')!r}, "
+ f"not {issuer!r}. Enter the issuer exactly as it states it.")
+ for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
+ if not isinstance(doc.get(key), str):
+ raise OidcError("bad_discovery", f"The provider's metadata has no {key}.")
+ if strict and not doc[key].startswith("https://"):
+ raise OidcError("insecure_endpoint", f"The provider's {key} must use https://.")
+ _cache[f"disc:{issuer}"] = (time.time() + CACHE_SECONDS, doc)
+ return doc
+
+
+async def _keys(jwks_uri: str, strict: bool, refresh: bool = False) -> List[Dict[str, Any]]:
+ cached = _cache.get(f"jwks:{jwks_uri}")
+ if cached and cached[0] > time.time() and not refresh:
+ return cached[1]["keys"]
+ doc = await _request("GET", jwks_uri, strict)
+ if not isinstance(doc.get("keys"), list):
+ raise OidcError("bad_jwks", "The provider's key set is not valid.")
+ _cache[f"jwks:{jwks_uri}"] = (time.time() + CACHE_SECONDS, doc)
+ return doc["keys"]
+
+
+def pkce_pair() -> Tuple[str, str]:
+ verifier = secrets.token_urlsafe(48)
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
+ return verifier, challenge
+
+
+def authorization_url(doc: Dict[str, Any], client_id: str, redirect_uri: str, state: str, nonce: str,
+ code_challenge: str, login_hint: Optional[str] = None) -> str:
+ params = {"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
+ "scope": "openid email profile", "state": state, "nonce": nonce,
+ "code_challenge": code_challenge, "code_challenge_method": "S256",
+ # query, not form_post: a cross-site POST would not carry the SameSite=Lax cookie.
+ "response_mode": "query"}
+ if login_hint:
+ params["login_hint"] = login_hint
+ endpoint = doc["authorization_endpoint"]
+ return endpoint + ("&" if "?" in endpoint else "?") + urlencode(params)
+
+
+async def exchange_code(doc: Dict[str, Any], client_id: str, client_secret: str, code: str, verifier: str,
+ redirect_uri: str, strict: Optional[bool] = None) -> Dict[str, Any]:
+ strict = STRICT if strict is None else strict
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
+ return await _request("POST", doc["token_endpoint"], strict, headers={"Authorization": f"Basic {basic}"},
+ data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
+ "code_verifier": verifier})
+
+
+async def verify_id_token(id_token: str, doc: Dict[str, Any], client_id: str, nonce: str,
+ strict: Optional[bool] = None) -> Dict[str, Any]:
+ """The verified claims, or OidcError."""
+ strict = STRICT if strict is None else strict
+ try:
+ header = jwt.get_unverified_header(id_token)
+ except jwt.PyJWTError:
+ raise OidcError("bad_token", "The identity provider sent an unreadable ID token.")
+ alg, kid = header.get("alg"), header.get("kid")
+ if alg not in ALGORITHMS:
+ raise OidcError("bad_algorithm", f"ID tokens signed with {alg!r} are not accepted.")
+ key = None
+ for refresh in (False, True): # an unknown key id: fetch the key set once more (rotation)
+ keys = await _keys(doc["jwks_uri"], strict, refresh=refresh)
+ match = [k for k in keys if (kid is None or k.get("kid") == kid) and k.get("use", "sig") == "sig"]
+ if match:
+ key = match[0]
+ break
+ if key is None:
+ raise OidcError("unknown_key", "The ID token was signed with a key the provider does not publish.")
+ try:
+ claims = jwt.decode(id_token, jwt.PyJWK(key, algorithm=alg).key, algorithms=[alg], audience=client_id,
+ issuer=doc["issuer"], leeway=LEEWAY, options={"require": ["exp", "iat", "sub", "iss", "aud"]})
+ except jwt.PyJWTError as exc:
+ raise OidcError("invalid_token", f"The ID token was rejected ({type(exc).__name__}).")
+ audiences = claims["aud"] if isinstance(claims["aud"], list) else [claims["aud"]]
+ if len(audiences) > 1 and claims.get("azp") != client_id:
+ raise OidcError("invalid_token", "The ID token was issued to another client.")
+ if not secrets.compare_digest(str(claims.get("nonce") or ""), nonce):
+ raise OidcError("bad_nonce", "The ID token does not belong to this sign-in.")
+ return claims
+
+
+def email_from(claims: Dict[str, Any]) -> Optional[str]:
+ """The verified email to link an account the first time, or None."""
+ if claims.get("email_verified") is False:
+ return None
+ email = claims.get("email")
+ return email.strip().lower() if isinstance(email, str) and "@" in email else None
diff --git a/libs/common/store.py b/libs/common/store.py
index c226d61..11f5156 100644
--- a/libs/common/store.py
+++ b/libs/common/store.py
@@ -119,6 +119,15 @@ def api_key_hash(key: str) -> str:
_REPORT_EDITABLE = ("name", "frequency", "weekday", "hour_utc", "recipients", "skip_empty", "active", "next_run_at")
+_SSO_PUBLIC = ("issuer", "client_id", "enforce", "created_by", "updated_at")
+_SSO_MEMBER = ("id", "email", "name", "roles", "is_active", "sso_issuer", "sso_subject")
+
+
+def _sso_bound(org: str) -> str:
+ """What an organization's encrypted SSO client secret is bound to."""
+ return f"sso:{org}"
+
+
def _webhook_bound(org: str, webhook_id: str) -> str:
"""What a webhook's encrypted secret is bound to: it opens only for this webhook."""
return f"webhook:{org}:{webhook_id}"
@@ -253,6 +262,8 @@ def __init__(self, seed_demo: bool = True):
self.notification_state: Dict[str, str] = {}
self.report_schedules: Dict[str, Dict[str, Any]] = {}
self.password_resets: Dict[str, Dict[str, Any]] = {}
+ self.sso_configs: Dict[str, Dict[str, Any]] = {}
+ self.sso_handoffs: Dict[str, Dict[str, Any]] = {}
async def close(self) -> None:
return None
@@ -669,6 +680,66 @@ async def set_password(self, org: str, user_id: str, hashed_password: str) -> bo
user.update(hashed_password=hashed_password, password_changed_at=await self.db_now())
return True
+ # --- single sign-on -----------------------------------------------------
+ async def get_sso_config(self, org: str, with_secret: bool = False) -> Optional[Dict[str, Any]]:
+ row = self.sso_configs.get(org)
+ if not row:
+ return None
+ out = {k: row[k] for k in _SSO_PUBLIC}
+ out["has_secret"] = True
+ if with_secret:
+ out["client_secret"] = secret_box.decrypt(row["client_secret_enc"], _sso_bound(org))[0]
+ return out
+
+ async def set_sso_config(self, org: str, issuer: str, client_id: str, client_secret: Optional[str], enforce: bool,
+ updated_by: Optional[str]) -> Dict[str, Any]:
+ row = self.sso_configs.get(org) or {}
+ secret_enc = secret_box.encrypt(client_secret, _sso_bound(org)) if client_secret else row.get("client_secret_enc")
+ if not secret_enc:
+ raise ValueError("client secret required")
+ self.sso_configs[org] = {"issuer": issuer, "client_id": client_id, "client_secret_enc": secret_enc,
+ "enforce": bool(enforce), "created_by": updated_by, "updated_at": await self.db_now()}
+ return await self.get_sso_config(org)
+
+ async def delete_sso_config(self, org: str) -> bool:
+ return self.sso_configs.pop(org, None) is not None
+
+ async def sso_member(self, org: str, *, email: Optional[str] = None, issuer: Optional[str] = None,
+ subject: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """A member of this organization by linked (issuer, subject), or by email."""
+ for u in self.users.values():
+ if u["organization_id"] != org:
+ continue
+ if subject is not None and u.get("sso_issuer") == issuer and u.get("sso_subject") == subject:
+ return {k: u.get(k) for k in _SSO_MEMBER}
+ if email is not None and u["email"] == email.lower():
+ return {k: u.get(k) for k in _SSO_MEMBER}
+ return None
+
+ async def link_sso(self, org: str, user_id: str, issuer: str, subject: str) -> bool:
+ """Record the provider's subject for this account, unless it is linked to another."""
+ user = self.users.get(str(user_id))
+ if not user or user["organization_id"] != org:
+ return False
+ if any(u is not user and u.get("sso_issuer") == issuer and u.get("sso_subject") == subject
+ for u in self.users.values()):
+ return False
+ if user.get("sso_subject") and (user.get("sso_issuer"), user["sso_subject"]) != (issuer, subject):
+ return False
+ user.update(sso_issuer=issuer, sso_subject=subject)
+ return True
+
+ async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, expires_at: str) -> None:
+ self.sso_handoffs[code_hash] = {"organization_id": org, "user_id": str(user_id), "expires_at": expires_at,
+ "used_at": None}
+
+ async def consume_sso_handoff(self, code_hash: str) -> Optional[Dict[str, Any]]:
+ h = self.sso_handoffs.get(code_hash)
+ if not h or h["used_at"] or datetime.fromisoformat(h["expires_at"]) <= datetime.now(timezone.utc):
+ return None
+ h["used_at"] = _now()
+ return {"user_id": h["user_id"], "organization_id": h["organization_id"]}
+
async def request_password_reset(self, email: str, token_hash: str, expires_at: str) -> Optional[Dict[str, Any]]:
"""Before sign-in: a reset token for an active account (earlier ones stop working),
or None when there is no such account."""
@@ -1913,6 +1984,74 @@ async def set_password(self, org: str, user_id: str, hashed_password: str) -> bo
"WHERE id = CAST(:i AS uuid) RETURNING id"), {"h": hashed_password, "i": user_id})
return bool(rows)
+ # --- single sign-on -----------------------------------------------------
+ async def get_sso_config(self, org: str, with_secret: bool = False) -> Optional[Dict[str, Any]]:
+ rows = await self._all(org, "SELECT * FROM sso_configs")
+ if not rows:
+ return None
+ out = {k: v for k, v in _jsonable(rows[0]).items() if k in _SSO_PUBLIC}
+ out["has_secret"] = True
+ if with_secret:
+ out["client_secret"] = secret_box.decrypt(rows[0]["client_secret_enc"], _sso_bound(org))[0]
+ return out
+
+ async def set_sso_config(self, org: str, issuer: str, client_id: str, client_secret: Optional[str], enforce: bool,
+ updated_by: Optional[str]) -> Dict[str, Any]:
+ params = {"org": org, "iss": issuer, "cid": client_id, "enf": bool(enforce), "by": updated_by}
+ if client_secret:
+ rows = await self._all(org, (
+ "INSERT INTO sso_configs (organization_id, issuer, client_id, client_secret_enc, enforce, created_by) "
+ "VALUES (CAST(:org AS uuid), :iss, :cid, :sec, :enf, :by) "
+ "ON CONFLICT (organization_id) DO UPDATE SET issuer = EXCLUDED.issuer, client_id = EXCLUDED.client_id, "
+ "client_secret_enc = EXCLUDED.client_secret_enc, enforce = EXCLUDED.enforce, "
+ "created_by = EXCLUDED.created_by, updated_at = now() RETURNING organization_id"),
+ {**params, "sec": secret_box.encrypt(client_secret, _sso_bound(org))})
+ else: # keep the stored secret (an upsert would check the new row's empty secret first)
+ rows = await self._all(org, (
+ "UPDATE sso_configs SET issuer = :iss, client_id = :cid, enforce = :enf, created_by = :by, "
+ "updated_at = now() WHERE organization_id = CAST(:org AS uuid) RETURNING organization_id"), params)
+ if not rows:
+ raise ValueError("client secret required")
+ return await self.get_sso_config(org)
+
+ async def delete_sso_config(self, org: str) -> bool:
+ return bool(await self._all(org, "DELETE FROM sso_configs RETURNING organization_id"))
+
+ async def sso_member(self, org: str, *, email: Optional[str] = None, issuer: Optional[str] = None,
+ subject: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """A member of this organization by linked (issuer, subject), or by email."""
+ cols = "id, email, name, roles::text[] AS roles, is_active, sso_issuer, sso_subject"
+ if subject is not None:
+ rows = await self._all(org, f"SELECT {cols} FROM users WHERE sso_issuer = :i AND sso_subject = :s",
+ {"i": issuer, "s": subject})
+ else:
+ rows = await self._all(org, f"SELECT {cols} FROM users WHERE lower(email) = lower(:e)", {"e": email or ""})
+ return self._account(rows[0]) if rows else None
+
+ async def link_sso(self, org: str, user_id: str, issuer: str, subject: str) -> bool:
+ """Record the provider's subject for this account, unless it is linked to another."""
+ from sqlalchemy.exc import IntegrityError
+ if not _is_uuid(user_id):
+ return False
+ try:
+ rows = await self._all(org, (
+ "UPDATE users SET sso_issuer = :i, sso_subject = :s, updated_at = now() WHERE id = CAST(:u AS uuid) "
+ "AND (sso_subject IS NULL OR (sso_issuer = :i AND sso_subject = :s)) RETURNING id"),
+ {"i": issuer, "s": subject, "u": user_id})
+ except IntegrityError: # that subject is already linked to another account
+ return False
+ return bool(rows)
+
+ async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, expires_at: str) -> None:
+ await self._exec(org, [(
+ "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, expires_at) "
+ "VALUES (:h, CAST(:org AS uuid), CAST(:u AS uuid), :x)",
+ {"h": code_hash, "org": org, "u": user_id, "x": datetime.fromisoformat(expires_at)})])
+
+ async def consume_sso_handoff(self, code_hash: str) -> Optional[Dict[str, Any]]:
+ rows = await self._all(None, "SELECT * FROM scirm_sso_handoff_consume(:h)", {"h": code_hash})
+ return _jsonable(rows[0]) if rows else None
+
async def request_password_reset(self, email: str, token_hash: str, expires_at: str) -> Optional[Dict[str, Any]]:
"""Before sign-in: a reset token for an active account (earlier ones stop working),
or None when there is no such account."""
diff --git a/mkdocs.yml b/mkdocs.yml
index cfa1a45..c0c5745 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -20,6 +20,7 @@ nav:
- Disaster exposure by site: site-exposure.md
- Two-step sign-in: two-step-sign-in.md
- Passwords: passwords.md
+ - Single sign-on: single-sign-on.md
- Audit log: audit-log.md
- Public API: public-api.md
- Webhooks: webhooks.md
diff --git a/scripts/db_setup.py b/scripts/db_setup.py
index 24ed542..ee7c2d9 100644
--- a/scripts/db_setup.py
+++ b/scripts/db_setup.py
@@ -64,7 +64,8 @@ def ensure_app_role(owner_url: str, role: str, password: str) -> None:
# 015; API keys: migration 019; password resets: migration 022).
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
- "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)"):
+ "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
+ "scirm_sso_handoff_consume(text)"):
cur.execute(sql.SQL("GRANT EXECUTE ON FUNCTION " + fn + " TO {}").format(ident))
conn.close()
diff --git a/tests/db/test_database.py b/tests/db/test_database.py
index 3a9dd87..6546ff5 100644
--- a/tests/db/test_database.py
+++ b/tests/db/test_database.py
@@ -222,11 +222,13 @@ def test_lookups_work_when_the_owner_is_not_a_superuser(self, db):
with db["owner"].cursor() as cur:
cur.execute("DROP ROLE IF EXISTS scirm_plain_owner")
cur.execute("CREATE ROLE scirm_plain_owner NOSUPERUSER NOBYPASSRLS")
- for obj in ("TABLE users", "TABLE invitations", "TABLE organizations", "TABLE api_keys", "TABLE password_resets"):
+ for obj in ("TABLE users", "TABLE invitations", "TABLE organizations", "TABLE api_keys", "TABLE password_resets",
+ "TABLE sso_handoffs"):
cur.execute(f"ALTER {obj} OWNER TO scirm_plain_owner")
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
- "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)"):
+ "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
+ "scirm_sso_handoff_consume(text)"):
cur.execute(f"ALTER FUNCTION {fn} OWNER TO scirm_plain_owner")
try:
rows = as_org(db["app"], "", "SELECT email FROM scirm_login_lookup('managed@example.com')")
@@ -239,15 +241,23 @@ def test_lookups_work_when_the_owner_is_not_a_superuser(self, db):
consume = "SELECT email FROM scirm_password_reset_consume(%s, 'new-hash')"
assert as_org(db["app"], "", consume, ("8" * 64,)) == [("managed@example.com",)]
assert as_org(db["app"], "", consume, ("8" * 64,)) == [] # once
+ uid = as_org(db["app"], db["a"], "SELECT id::text FROM users WHERE email = %s", ("managed@example.com",))[0][0]
+ as_org(db["app"], db["a"], "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, expires_at) "
+ "VALUES (%s, %s, %s, now() + interval '1 minute')", ("7" * 64, db["a"], uid))
+ handoff = "SELECT user_id::text FROM scirm_sso_handoff_consume(%s)"
+ assert as_org(db["app"], "", handoff, ("7" * 64,)) == [(uid,)]
+ assert as_org(db["app"], "", handoff, ("7" * 64,)) == [] # once
finally:
with db["owner"].cursor() as cur:
cur.execute("SELECT current_user")
me = cur.fetchone()[0]
- for obj in ("TABLE users", "TABLE invitations", "TABLE organizations", "TABLE api_keys", "TABLE password_resets"):
+ for obj in ("TABLE users", "TABLE invitations", "TABLE organizations", "TABLE api_keys", "TABLE password_resets",
+ "TABLE sso_handoffs"):
cur.execute(f"ALTER {obj} OWNER TO {me}")
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
- "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)"):
+ "scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
+ "scirm_sso_handoff_consume(text)"):
cur.execute(f"ALTER FUNCTION {fn} OWNER TO {me}")
def test_an_invitation_can_be_accepted_only_once_even_concurrently(self, db):
@@ -328,7 +338,8 @@ def test_only_read_scope_and_hashes_are_accepted(self, db):
def test_webhook_and_report_tables_are_tenant_scoped_with_forced_rls(db):
"""Migrations 020 and 021: these tables force row-level security; another org sees nothing."""
- tables = ("webhooks", "webhook_events", "webhook_deliveries", "notification_state", "report_schedules")
+ tables = ("webhooks", "webhook_events", "webhook_deliveries", "notification_state", "report_schedules",
+ "sso_configs")
with db["owner"].cursor() as cur:
cur.execute("SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = ANY(%s)",
(list(tables),))
diff --git a/tests/security/test_sso.py b/tests/security/test_sso.py
new file mode 100644
index 0000000..455565e
--- /dev/null
+++ b/tests/security/test_sso.py
@@ -0,0 +1,246 @@
+"""
+Single sign-on (OpenID Connect) against a fake identity provider with its own RSA key:
+the full flow, account linking by (issuer, subject), the one-time handoff, enforcement, and
+the token checks that keep it safe. Runs on both backends, like the rest of the security suite.
+"""
+
+import base64
+import hashlib
+import json
+import time
+import uuid
+from urllib.parse import parse_qs, urlsplit
+
+import httpx
+import jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from libs.common import oidc
+
+ISSUER = "https://idp.example.com/realms/acme"
+CLIENT_ID, CLIENT_SECRET = "scirm-app", "client-secret-123"
+
+
+def bearer(token: str) -> dict:
+ return {"Authorization": f"Bearer {token}"}
+
+
+class FakeIdp:
+ """Discovery, key set and token endpoint. The test plays the browser and the login page."""
+
+ def __init__(self):
+ self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self.key.public_key()))
+ self.jwks = {"keys": [{**jwk, "kid": "k1", "use": "sig", "alg": "RS256"}]}
+ self.codes = {}
+ self.token_requests = []
+ self.issuer_in_discovery = ISSUER
+
+ def sign(self, claims, alg="RS256", kid="k1", key=None):
+ if alg == "HS256":
+ return jwt.encode(claims, "shared", algorithm="HS256", headers={"kid": kid})
+ return jwt.encode(claims, key or self.key, algorithm=alg, headers={"kid": kid})
+
+ def claims(self, sub, email, nonce, **extra):
+ now = int(time.time())
+ return {"iss": ISSUER, "aud": CLIENT_ID, "sub": sub, "email": email, "email_verified": True,
+ "nonce": nonce, "iat": now, "exp": now + 300, **extra}
+
+ def transport(self):
+ def handle(request: httpx.Request) -> httpx.Response:
+ path = request.url.path
+ if path.endswith("/.well-known/openid-configuration"):
+ return httpx.Response(200, json={"issuer": self.issuer_in_discovery,
+ "authorization_endpoint": "https://idp.example.com/auth",
+ "token_endpoint": "https://idp.example.com/token",
+ "jwks_uri": "https://idp.example.com/jwks"})
+ if path == "/jwks":
+ return httpx.Response(200, json=self.jwks)
+ if path == "/token":
+ form = parse_qs(request.content.decode())
+ self.token_requests.append({"auth": request.headers.get("authorization"), **form})
+ issued = self.codes.pop(form["code"][0], None)
+ expected = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
+ if not issued or request.headers.get("authorization") != f"Basic {expected}":
+ return httpx.Response(400, json={"error": "invalid_grant"})
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(form["code_verifier"][0].encode()).digest())
+ if challenge.decode().rstrip("=") != issued["challenge"]:
+ return httpx.Response(400, json={"error": "invalid_grant"})
+ return httpx.Response(200, json={"id_token": issued["id_token"], "token_type": "Bearer"})
+ return httpx.Response(404)
+ return httpx.MockTransport(handle)
+
+
+@pytest.fixture
+def idp(monkeypatch, client):
+ fake = FakeIdp()
+ monkeypatch.setattr(oidc, "transport_factory", fake.transport)
+ oidc._cache.clear()
+ client.cookies.clear()
+ yield fake
+ oidc._cache.clear()
+ client.cookies.clear()
+
+
+@pytest.fixture
+def configured(client, sarah, idp):
+ saved = client.put("/api/v1/org/sso", json={"issuer": ISSUER, "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET}, headers=bearer(sarah["access_token"]))
+ assert saved.status_code == 200, saved.text
+ yield saved.json()
+ client.delete("/api/v1/org/sso", headers=bearer(sarah["access_token"]))
+
+
+@pytest.fixture
+def member(client, sarah):
+ email = f"sso-{uuid.uuid4().hex[:8]}@example.com"
+ invited = client.post("/api/v1/org/invitations", json={"email": email, "roles": ["analyst"]},
+ headers=bearer(sarah["access_token"]))
+ token = invited.json()["invite_url"].split("#", 1)[1]
+ client.post("/api/v1/invitations/accept", json={"token": token, "name": "S", "password": "a-good-password"})
+ return email
+
+
+def start(client, email, next_path="/suppliers"):
+ response = client.get("/api/v1/auth/sso/start", params={"email": email, "next": next_path}, follow_redirects=False)
+ assert response.status_code == 302, response.text
+ return response
+
+
+def sign_in(client, idp, email, *, sub=None, claims_extra=None, tamper=None, state_override=None):
+ """Start, 'log in' at the fake provider, and return the callback response."""
+ location = urlsplit(start(client, email).headers["location"])
+ q = {k: v[0] for k, v in parse_qs(location.query).items()}
+ claims = {**idp.claims(sub or f"idp-{email}", email, q["nonce"]), **(claims_extra or {})}
+ id_token = tamper(claims) if tamper else idp.sign(claims)
+ idp.codes["code-1"] = {"challenge": q["code_challenge"], "id_token": id_token}
+ return client.get("/api/v1/auth/sso/callback", params={"code": "code-1", "state": state_override or q["state"]},
+ follow_redirects=False), q
+
+
+def handoff(callback):
+ location = callback.headers["location"]
+ assert "/sso/complete#code=" in location, location
+ return parse_qs(location.split("#", 1)[1])["code"][0]
+
+
+class TestFlow:
+ def test_full_sign_in(self, client, idp, configured, member):
+ callback, q = sign_in(client, idp, member)
+ assert q["code_challenge_method"] == "S256" and q["response_mode"] == "query" and q["login_hint"] == member
+ assert q["redirect_uri"] == "http://localhost:8000/api/v1/auth/sso/callback"
+ code = handoff(callback)
+ assert "next=/suppliers" in callback.headers["location"]
+ tokens = client.post("/api/v1/auth/sso/exchange", json={"code": code})
+ assert tokens.status_code == 200, tokens.text
+ me = jwt.decode(tokens.json()["access_token"], options={"verify_signature": False})
+ assert me["email"] == member
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": code}).status_code == 400 # once
+ assert idp.token_requests[-1]["grant_type"] == ["authorization_code"]
+
+ def test_the_account_is_linked_to_the_subject(self, client, idp, configured, member):
+ handoff(sign_in(client, idp, member, sub="subject-A")[0])
+ client.cookies.clear()
+ # Same email, another subject (a reassigned email at the provider): refused.
+ assert "sso=failed" in sign_in(client, idp, member, sub="subject-B")[0].headers["location"]
+ client.cookies.clear()
+ # The linked subject works even if the provider now reports another email.
+ callback, _ = sign_in(client, idp, member, sub="subject-A", claims_extra={"email": "renamed@example.com"})
+ assert "/sso/complete#code=" in callback.headers["location"]
+
+ def test_unknown_accounts_and_other_organizations_get_the_same_answer(self, client, idp, configured, lisa):
+ for email in (f"nobody-{uuid.uuid4().hex[:6]}@example.com", "lisa.park@medtech.com"):
+ response = client.get("/api/v1/auth/sso/start", params={"email": email}, follow_redirects=False)
+ assert response.status_code == 302 and response.headers["location"].endswith("/login?sso=unavailable")
+
+
+class TestRefusals:
+ @pytest.mark.parametrize("case", ["wrong_aud", "wrong_iss", "expired", "wrong_nonce", "unverified_email",
+ "alg_none", "hs256", "unknown_kid", "foreign_key"])
+ def test_bad_tokens(self, client, idp, configured, member, case):
+ other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ tamper = {
+ "wrong_aud": lambda c: idp.sign({**c, "aud": "someone-else"}),
+ "wrong_iss": lambda c: idp.sign({**c, "iss": "https://evil.example.com"}),
+ "expired": lambda c: idp.sign({**c, "exp": int(time.time()) - 3600, "iat": int(time.time()) - 7200}),
+ "wrong_nonce": lambda c: idp.sign({**c, "nonce": "replayed"}),
+ "unverified_email": lambda c: idp.sign({**c, "email_verified": False}),
+ "alg_none": lambda c: jwt.encode(c, None, algorithm="none"),
+ "hs256": lambda c: idp.sign(c, alg="HS256"),
+ "unknown_kid": lambda c: idp.sign(c, kid="nope"),
+ "foreign_key": lambda c: idp.sign(c, key=other),
+ }[case]
+ callback, _ = sign_in(client, idp, member, tamper=tamper)
+ assert "/sso/complete" not in callback.headers["location"], case
+
+ def test_wrong_state_and_missing_cookie(self, client, idp, configured, member):
+ assert "sso=failed" in sign_in(client, idp, member, state_override="forged")[0].headers["location"]
+ client.cookies.clear()
+ no_cookie = client.get("/api/v1/auth/sso/callback", params={"code": "x", "state": "y"}, follow_redirects=False)
+ assert no_cookie.headers["location"].endswith("sso=expired")
+
+ def test_open_redirects_are_ignored(self, client, idp, configured, member):
+ for bad in ("https://evil.example.com", "//evil.example.com", "/\\evil", "javascript:alert(1)"):
+ client.cookies.clear()
+ location = urlsplit(start(client, member, next_path=bad).headers["location"])
+ q = {k: v[0] for k, v in parse_qs(location.query).items()}
+ idp.codes["code-1"] = {"challenge": q["code_challenge"],
+ "id_token": idp.sign(idp.claims(f"idp-{member}", member, q["nonce"]))}
+ callback = client.get("/api/v1/auth/sso/callback", params={"code": "code-1", "state": q["state"]},
+ follow_redirects=False)
+ assert callback.headers["location"].endswith("next=/dashboard"), bad
+
+ def test_an_expired_handoff_is_refused(self, client, apps, idp, configured, member):
+ import asyncio
+ from datetime import datetime, timedelta, timezone
+ store = apps["gateway"].audit_store
+ account = asyncio.run(store.find_login_user(member))
+ past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
+ asyncio.run(store.create_sso_handoff(str(account["organization_id"]), str(account["id"]),
+ hashlib.sha256(b"old-handoff-code-123456").hexdigest(), past))
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": "old-handoff-code-123456"}).status_code == 400
+
+
+class TestConfiguration:
+ @pytest.mark.parametrize("issuer", ["https://login.microsoftonline.com/common/v2.0",
+ "https://login.microsoftonline.com/organizations/v2.0",
+ "ftp://idp.example.com"])
+ def test_bad_issuers_are_refused(self, client, sarah, idp, issuer):
+ assert client.put("/api/v1/org/sso", json={"issuer": issuer, "client_id": "c", "client_secret": "s"},
+ headers=bearer(sarah["access_token"])).status_code == 400
+
+ def test_the_issuer_must_match_the_providers_metadata(self, client, sarah, idp):
+ idp.issuer_in_discovery = "https://idp.example.com/realms/other"
+ assert client.put("/api/v1/org/sso", json={"issuer": ISSUER, "client_id": "c", "client_secret": "s"},
+ headers=bearer(sarah["access_token"])).status_code == 400
+
+ def test_the_first_configuration_needs_a_secret(self, client, sarah, idp):
+ assert client.put("/api/v1/org/sso", json={"issuer": ISSUER, "client_id": CLIENT_ID},
+ headers=bearer(sarah["access_token"])).status_code == 400
+
+ def test_only_admins_and_the_secret_is_never_shown(self, client, sarah, auditor, configured):
+ assert client.get("/api/v1/org/sso", headers=bearer(auditor["access_token"])).status_code == 403
+ shown = client.get("/api/v1/org/sso", headers=bearer(sarah["access_token"])).json()
+ assert shown["config"]["client_id"] == CLIENT_ID and CLIENT_SECRET not in json.dumps(shown)
+
+ def test_strict_mode_requires_https(self):
+ with pytest.raises(oidc.OidcError):
+ oidc.validate_issuer("http://idp.example.com/realms/acme", strict=True)
+
+
+class TestEnforcement:
+ def test_members_must_use_sso_admins_keep_their_password(self, client, login, sarah, idp, configured, member):
+ # Turned on later, without re-entering the secret (the stored one is kept).
+ on = client.put("/api/v1/org/sso", json={"issuer": ISSUER, "client_id": CLIENT_ID, "enforce": True},
+ headers=bearer(sarah["access_token"]))
+ assert on.status_code == 200, on.text
+ assert on.json()["config"]["enforce"] is True
+ try:
+ refused = login(member, "a-good-password")
+ assert refused.status_code == 403 and "single sign-on" in refused.json()["detail"]
+ assert login("sarah.chen@pharmacorp.com").status_code == 200 # demo admin
+ assert login(member, "wrong-password").status_code == 401 # nothing revealed without the password
+ finally:
+ client.delete("/api/v1/org/sso", headers=bearer(sarah["access_token"]))
+ assert login(member, "a-good-password").status_code == 200
From ba7859053ec030fc780d670a341040885d8a637d Mon Sep 17 00:00:00 2001
From: alikahwaji
Date: Fri, 23 Oct 2026 14:35:18 +1300
Subject: [PATCH 2/2] fix(sso): tie the sign-in to the tab that started it;
invites and issuer 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
---
alembic/versions/023_sso.py | 14 ++--
apps/api-gateway/main.py | 30 ++++++--
apps/frontend/src/contexts/AuthContext.tsx | 16 +++--
apps/frontend/src/pages/AcceptInvitePage.tsx | 4 +-
apps/frontend/src/pages/LoginPage.tsx | 6 +-
apps/frontend/src/pages/SsoCompletePage.tsx | 9 ++-
apps/frontend/src/services/api.ts | 14 ++--
apps/frontend/src/services/ssoBinding.test.ts | 22 ++++++
apps/frontend/src/services/ssoBinding.ts | 26 +++++++
docs/single-sign-on.md | 20 +++++-
libs/common/store.py | 39 +++++++----
scripts/db_setup.py | 2 +-
tests/db/test_database.py | 15 ++--
tests/security/test_sso.py | 68 ++++++++++++++++---
14 files changed, 223 insertions(+), 62 deletions(-)
create mode 100644 apps/frontend/src/services/ssoBinding.test.ts
create mode 100644 apps/frontend/src/services/ssoBinding.ts
diff --git a/alembic/versions/023_sso.py b/alembic/versions/023_sso.py
index d225376..6237d81 100644
--- a/alembic/versions/023_sso.py
+++ b/alembic/versions/023_sso.py
@@ -8,7 +8,8 @@
first SSO sign-in and required to match afterwards (emails can be reassigned at an
identity provider; the subject cannot). Unique per issuer.
- sso_handoffs: after the callback the browser gets a one-time code (in the URL fragment),
- not tokens. Only its hash is stored; it lasts 60 seconds and works once, consumed by
+ not tokens. Only its hash is stored; it lasts 60 seconds, works once, and only together
+ with the value the starting login page kept (binding_hash), consumed by
scirm_sso_handoff_consume (SECURITY DEFINER: the exchange happens before sign-in).
Revision ID: 023
@@ -24,7 +25,7 @@
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
-FUNCTIONS = ("scirm_sso_handoff_consume(text)",)
+FUNCTIONS = ("scirm_sso_handoff_consume(text, text)",)
def upgrade() -> None:
@@ -53,6 +54,9 @@ def upgrade() -> None:
code_hash text PRIMARY KEY CHECK (code_hash ~ '^[0-9a-f]{64}$'),
organization_id uuid NOT NULL REFERENCES organizations(id),
user_id uuid NOT NULL REFERENCES users(id),
+ -- SHA-256 of a random value kept by the login page that started the sign-in: the
+ -- code only works in that browser (no login CSRF with a captured code).
+ binding_hash text NOT NULL CHECK (binding_hash ~ '^[0-9a-f]{64}$'),
expires_at timestamptz NOT NULL,
used_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now()
@@ -67,11 +71,13 @@ def upgrade() -> None:
WITH CHECK (organization_id = scirm_current_org())
""")
op.execute("""
- CREATE FUNCTION scirm_sso_handoff_consume(p_code_hash text)
+ CREATE FUNCTION scirm_sso_handoff_consume(p_code_hash text, p_binding_hash text)
RETURNS TABLE (user_id uuid, organization_id uuid)
LANGUAGE sql SECURITY DEFINER SET search_path = public, pg_temp AS $$
+ -- A wrong binding leaves the code unused, so it cannot be burnt by someone else.
UPDATE sso_handoffs SET used_at = now()
- WHERE code_hash = p_code_hash AND used_at IS NULL AND expires_at > now()
+ WHERE code_hash = p_code_hash AND binding_hash = p_binding_hash
+ AND used_at IS NULL AND expires_at > now()
RETURNING sso_handoffs.user_id, sso_handoffs.organization_id
$$
""")
diff --git a/apps/api-gateway/main.py b/apps/api-gateway/main.py
index 7f67607..36c9067 100644
--- a/apps/api-gateway/main.py
+++ b/apps/api-gateway/main.py
@@ -7,6 +7,7 @@
import hmac
import json
import os
+import re
import secrets
import uuid
from contextlib import asynccontextmanager
@@ -961,13 +962,20 @@ async def accept_invitation(body: AcceptInvitationRequest):
raise HTTPException(status_code=404, detail="This invitation is not valid. Ask for a new one.")
user_info = {"id": str(accepted["user_id"]), "name": body.name.strip(), "roles": list(accepted["roles"]),
"org_id": str(accepted["organization_id"]), "stored": True}
- await _record_login(user_info) # accepting signs them in
+ # In an organization that requires single sign-on, members get no password session here.
+ sso_required = await _sso_enforced_for(user_info["org_id"], user_info["roles"])
+ if not sso_required:
+ await _record_login(user_info) # accepting signs them in
try:
await audit_store.record_audit(user_info["org_id"], {
"user_id": user_info["id"], "actor_type": "user", "actor": accepted["email"],
"action": "invitation.accepted", "resource_type": "auth", "status_code": 200})
except Exception as exc:
logger.error("Audit write failed", error=type(exc).__name__, action="invitation.accepted")
+ if sso_required:
+ return {"sso_required": True, "email": accepted["email"],
+ "message": "Your account is ready. Your organization signs in with single sign-on: use "
+ "'Sign in with SSO' on the sign-in page."}
return _issue_tokens(accepted["email"], user_info)
@@ -1862,11 +1870,16 @@ async def _audit_sso_failure(org: Optional[str], actor: Optional[str], reason: s
@app.get("/api/v1/auth/sso/start")
-async def sso_start(email: str, next: Optional[str] = None):
- """Begin SSO for this email's account: off to its organization's identity provider."""
+async def sso_start(email: str, binding: str = "", next: Optional[str] = None):
+ """Begin SSO for this email's account: off to its organization's identity provider.
+ `binding` is the SHA-256 (hex) of a random value the login page keeps in the browser; the
+ one-time code at the end only works with that value, so a sign-in started or captured
+ elsewhere cannot be completed in someone else's browser (login CSRF)."""
from fastapi.responses import RedirectResponse
if not PUBLIC_APP_URL or not PUBLIC_API_URL:
raise HTTPException(status_code=503, detail="Single sign-on is not set up on this deployment.")
+ if not re.fullmatch(r"[0-9a-f]{64}", binding or ""):
+ raise HTTPException(status_code=400, detail="Start single sign-on from the SCIRM sign-in page.")
account = await audit_store.find_login_user((email or "").strip().lower())
if not account or not account["is_active"]:
return _to_login("unavailable") # the same answer as an organization without SSO
@@ -1882,7 +1895,8 @@ async def sso_start(email: str, next: Optional[str] = None):
verifier, challenge = oidc.pkce_pair()
state, nonce = secrets.token_urlsafe(24), secrets.token_urlsafe(24)
cookie = create_access_token(data={"sub": "sso", "type": SSO_STATE_TYPE, "org": org, "state": state,
- "nonce": nonce, "verifier": verifier, "next": _safe_next(next)},
+ "nonce": nonce, "verifier": verifier, "bind": binding,
+ "next": _safe_next(next)},
expires_delta=timedelta(minutes=10))
response = RedirectResponse(oidc.authorization_url(doc, config["client_id"], _sso_redirect_uri(), state, nonce,
challenge, login_hint=account["email"]), status_code=302)
@@ -1944,7 +1958,8 @@ async def sso_callback(request: Request, code: Optional[str] = None, state: Opti
import hashlib
handoff = secrets.token_urlsafe(32)
expires = (datetime.now(timezone.utc) + timedelta(seconds=SSO_HANDOFF_SECONDS)).isoformat()
- await audit_store.create_sso_handoff(org, str(member["id"]), hashlib.sha256(handoff.encode()).hexdigest(), expires)
+ await audit_store.create_sso_handoff(org, str(member["id"]), hashlib.sha256(handoff.encode()).hexdigest(),
+ str(payload["bind"]), expires)
await _record_login({"stored": True, "org_id": org, "id": str(member["id"])})
await _audit_event(org, str(member["id"]), member["email"], "auth.sso_login")
response = RedirectResponse(f"{PUBLIC_APP_URL}/sso/complete#code={handoff}&next={quote(payload['next'], safe='/')}",
@@ -1955,6 +1970,8 @@ async def sso_callback(request: Request, code: Optional[str] = None, state: Opti
class SsoExchangeRequest(BaseModel):
code: str = Field(..., min_length=20, max_length=200)
+ # The random value whose hash started the sign-in (kept by the login page in this browser).
+ binding: str = Field(..., min_length=20, max_length=200)
@app.post("/api/v1/auth/sso/exchange")
@@ -1962,7 +1979,8 @@ async def sso_exchange(body: SsoExchangeRequest):
"""The app trades the one-time code (60 s, single use) for tokens. The identity provider
did the authentication, including its own two-step sign-in."""
import hashlib
- consumed = await audit_store.consume_sso_handoff(hashlib.sha256(body.code.encode()).hexdigest())
+ consumed = await audit_store.consume_sso_handoff(hashlib.sha256(body.code.encode()).hexdigest(),
+ hashlib.sha256(body.binding.encode()).hexdigest())
if not consumed:
raise HTTPException(status_code=400, detail="This sign-in link has expired. Sign in again.")
found = await _find_user_by_id(str(consumed["user_id"]))
diff --git a/apps/frontend/src/contexts/AuthContext.tsx b/apps/frontend/src/contexts/AuthContext.tsx
index 1b8bb2b..b021517 100644
--- a/apps/frontend/src/contexts/AuthContext.tsx
+++ b/apps/frontend/src/contexts/AuthContext.tsx
@@ -14,11 +14,12 @@ interface AuthContextType {
login: (email: string, password: string) => Promise;
verifyMfa: (mfaToken: string, code: string) => Promise;
signup: (email: string, password: string, name: string) => Promise;
- acceptInvitation: (token: string, name: string, password: string) => Promise;
+ /** 'sso_required': the account exists, but the organization signs in with SSO (no session). */
+ acceptInvitation: (token: string, name: string, password: string) => Promise<'signed_in' | 'sso_required'>;
/** Changes the password; other sessions end, this one continues with new tokens. */
changePassword: (current: string, next: string) => Promise;
/** Finishes single sign-on with the one-time code from the redirect. */
- completeSso: (code: string) => Promise;
+ completeSso: (code: string, binding: string) => Promise;
logout: () => void;
}
@@ -29,7 +30,7 @@ const AuthContext = createContext({
login: async () => ({ status: 'signed_in' }),
verifyMfa: async () => {},
signup: async () => {},
- acceptInvitation: async () => {},
+ acceptInvitation: async () => 'signed_in',
changePassword: async () => {},
completeSso: async () => {},
logout: () => {},
@@ -95,15 +96,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
const acceptInvitation = async (token: string, name: string, password: string) => {
- begin(await api.acceptInvitation(token, name, password));
+ const response = await api.acceptInvitation(token, name, password);
+ if (response.sso_required) return 'sso_required' as const;
+ begin(response);
+ return 'signed_in' as const;
};
const changePassword = async (current: string, next: string) => {
begin(await api.changePassword(current, next));
};
- const completeSso = async (code: string) => {
- begin(await api.ssoExchange(code));
+ const completeSso = async (code: string, binding: string) => {
+ begin(await api.ssoExchange(code, binding));
};
return (
diff --git a/apps/frontend/src/pages/AcceptInvitePage.tsx b/apps/frontend/src/pages/AcceptInvitePage.tsx
index 85ad3af..b2d1858 100644
--- a/apps/frontend/src/pages/AcceptInvitePage.tsx
+++ b/apps/frontend/src/pages/AcceptInvitePage.tsx
@@ -39,8 +39,8 @@ export default function AcceptInvitePage() {
setSubmitting(true);
try {
if (user) logout(); // joining as the invited person, not whoever was signed in on this browser
- await acceptInvitation(tokenRef.current!, name.trim(), password);
- navigate('/dashboard', { replace: true });
+ const result = await acceptInvitation(tokenRef.current!, name.trim(), password);
+ navigate(result === 'sso_required' ? '/login?sso=account_ready' : '/dashboard', { replace: true });
} catch (err: any) {
setError(inviteErrorMessage(err?.response?.status));
} finally {
diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx
index ed8b638..0c5c68b 100644
--- a/apps/frontend/src/pages/LoginPage.tsx
+++ b/apps/frontend/src/pages/LoginPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate, Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { ssoStartUrl } from '../services/api';
+import { newSsoBinding } from '../services/ssoBinding';
const MAX_ATTEMPTS = 5;
@@ -14,6 +15,7 @@ const SSO_MESSAGES: Record = {
expired: 'The sign-in took too long or was started in another browser. Try again.',
failed: 'Single sign-on failed. Try again, or ask your administrator to check the identity provider settings.',
provider_error: 'Your identity provider could not be reached. Try again later.',
+ account_ready: 'Your account is ready. Your organization signs in with single sign-on: enter your email and choose "Sign in with SSO".',
};
const LOCKOUT_SECONDS = 60;
@@ -177,9 +179,9 @@ export default function LoginPage() {
{!mfaToken && (
{
+ onClick={async () => {
if (!email.includes('@')) { setError(t('auth.ssoNeedsEmail', 'Enter your email first.')); return; }
- window.location.assign(ssoStartUrl(email.trim(), redirectTo));
+ window.location.assign(ssoStartUrl(email.trim(), redirectTo, await newSsoBinding()));
}}
className="w-full mt-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
diff --git a/apps/frontend/src/pages/SsoCompletePage.tsx b/apps/frontend/src/pages/SsoCompletePage.tsx
index c7c7430..8a627e1 100644
--- a/apps/frontend/src/pages/SsoCompletePage.tsx
+++ b/apps/frontend/src/pages/SsoCompletePage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
+import { takeSsoBinding } from '../services/ssoBinding';
/** The one-time code and destination from /sso/complete#code=...&next=... */
export function readHandoff(hash: string): { code: string; next: string } {
@@ -25,12 +26,14 @@ export default function SsoCompletePage() {
if (started.current) return; // the code works once; React may run effects twice in development
started.current = true;
const { code, next } = readHandoff(window.location.hash);
+ const binding = takeSsoBinding();
window.history.replaceState(null, '', window.location.pathname);
- if (!code) {
- setError(t('auth.ssoNoCode', 'This page needs the sign-in from your identity provider.'));
+ if (!code || !binding) {
+ // No binding: the sign-in was not started from this tab's login page.
+ setError(t('auth.ssoNoCode', 'Start single sign-on from the SCIRM sign-in page in this browser tab.'));
return;
}
- completeSso(code)
+ completeSso(code, binding)
.then(() => navigate(next, { replace: true }))
.catch((err) => setError(err?.response?.data?.detail || t('auth.ssoExchangeFailed', 'Sign-in failed. Try again.')));
}, [completeSso, navigate, t]);
diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts
index b3bc348..11b3f86 100644
--- a/apps/frontend/src/services/api.ts
+++ b/apps/frontend/src/services/api.ts
@@ -3,9 +3,10 @@ import { isAuthRequest, Session } from './session';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
-/** Where the browser goes to start single sign-on (a full-page navigation to the gateway). */
-export function ssoStartUrl(email: string, next: string): string {
- return `${API_BASE_URL}/api/v1/auth/sso/start?${new URLSearchParams({ email, next })}`;
+/** Where the browser goes to start single sign-on (a full-page navigation to the gateway).
+ * bindingHash is the SHA-256 of a value kept in this tab (see services/ssoBinding.ts). */
+export function ssoStartUrl(email: string, next: string, bindingHash: string): string {
+ return `${API_BASE_URL}/api/v1/auth/sso/start?${new URLSearchParams({ email, next, binding: bindingHash })}`;
}
const apiClient = axios.create({
@@ -554,8 +555,8 @@ export const api = {
},
/** Trades the one-time code from the SSO redirect for tokens. */
- async ssoExchange(code: string): Promise {
- return (await apiClient.post('/api/v1/auth/sso/exchange', { code })).data;
+ async ssoExchange(code: string, binding: string): Promise {
+ return (await apiClient.post('/api/v1/auth/sso/exchange', { code, binding })).data;
},
async getSso(): Promise {
@@ -744,7 +745,8 @@ export const api = {
return (await apiClient.post('/api/v1/invitations/lookup', { token })).data;
},
- async acceptInvitation(token: string, name: string, password: string): Promise {
+ /** Tokens, or sso_required when the organization signs in with single sign-on. */
+ async acceptInvitation(token: string, name: string, password: string): Promise {
return (await apiClient.post('/api/v1/invitations/accept', { token, name, password })).data;
},
diff --git a/apps/frontend/src/services/ssoBinding.test.ts b/apps/frontend/src/services/ssoBinding.test.ts
new file mode 100644
index 0000000..b46a791
--- /dev/null
+++ b/apps/frontend/src/services/ssoBinding.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { newSsoBinding, takeSsoBinding } from './ssoBinding';
+
+class MemoryStorage {
+ private items = new Map();
+ getItem(k: string) { return this.items.get(k) ?? null; }
+ setItem(k: string, v: string) { this.items.set(k, v); }
+ removeItem(k: string) { this.items.delete(k); }
+}
+
+describe('ssoBinding', () => {
+ it('keeps a value, sends its SHA-256, and gives it back once', async () => {
+ const storage = new MemoryStorage() as unknown as Storage;
+ const hash = await newSsoBinding(storage);
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
+ const value = takeSsoBinding(storage);
+ const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))))
+ .map((b) => b.toString(16).padStart(2, '0')).join('');
+ expect(digest).toBe(hash);
+ expect(takeSsoBinding(storage)).toBe('');
+ });
+});
diff --git a/apps/frontend/src/services/ssoBinding.ts b/apps/frontend/src/services/ssoBinding.ts
new file mode 100644
index 0000000..d8e2ad1
--- /dev/null
+++ b/apps/frontend/src/services/ssoBinding.ts
@@ -0,0 +1,26 @@
+// Ties a single sign-on to the tab that started it. The login page keeps a random value in
+// sessionStorage and sends only its SHA-256 to the gateway; the one-time code at the end is
+// exchanged together with the value, so a sign-in started or captured elsewhere cannot be
+// completed in someone else's browser (login CSRF).
+
+const KEY = 'scirm_sso_binding';
+
+function toHex(bytes: ArrayBuffer): string {
+ return Array.from(new Uint8Array(bytes)).map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+/** A fresh value for this tab; returns the hash to send to /sso/start. */
+export async function newSsoBinding(storage: Storage = sessionStorage): Promise {
+ const raw = new Uint8Array(32);
+ crypto.getRandomValues(raw);
+ const value = toHex(raw.buffer);
+ storage.setItem(KEY, value);
+ return toHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
+}
+
+/** The value for the exchange, removed so it is used once. */
+export function takeSsoBinding(storage: Storage = sessionStorage): string {
+ const value = storage.getItem(KEY) || '';
+ storage.removeItem(KEY);
+ return value;
+}
diff --git a/docs/single-sign-on.md b/docs/single-sign-on.md
index 72435ad..68b614a 100644
--- a/docs/single-sign-on.md
+++ b/docs/single-sign-on.md
@@ -21,7 +21,9 @@ sign-in; SCIRM's two-step sign-in is not asked for after single sign-on.
keep it.
3. Invite your members as usual. **SSO does not create accounts.** Someone without an
active SCIRM account in your organization is refused after signing in at the provider.
- Automatic provisioning comes with SCIM.
+ Automatic provisioning comes with SCIM. In an organization that requires single sign-on,
+ accepting an invitation creates the account but does not sign the person in with the
+ password; they continue with **Sign in with SSO**.
Members click **Sign in with SSO** on the sign-in page and enter their email. SCIRM finds
their account, sends them to their organization's identity provider, and back.
@@ -41,6 +43,10 @@ to few people.
- **After that** the subject must match. A different person who later gets the same email
at the provider is refused, and a member whose email changes at the provider still gets
in.
+- **Moving to another identity provider** (a new issuer): members are linked again by
+ verified email at their next sign-in. Within one issuer the subject never changes. The
+ organization's administrators already decide which identity provider signs members in,
+ so this gives them no new power.
## Security
@@ -59,6 +65,11 @@ to few people.
- **After the callback:** the browser gets a **one-time code in the URL fragment**, not
tokens. It is valid for 60 seconds and works once; the app exchanges it at
`/api/v1/auth/sso/exchange`.
+- **The code only works in the tab that started the sign-in.** The login page keeps a random
+ value in `sessionStorage` and sends only its SHA-256 to `/sso/start`. The exchange must
+ present the value itself, and a wrong value leaves the code unused. So a code captured
+ from someone's sign-in, or a sign-in an attacker starts in a victim's browser, cannot sign
+ that browser into the attacker's account (login CSRF).
- **Audit log:** `auth.sso_login`, `auth.sso_login_failed` (with a reason code such as
`bad_state`, `invalid_token`, `no_account`, `subject_mismatch`), `sso.configured` and
`sso.removed`.
@@ -76,6 +87,13 @@ its identity provider shows that the account exists, as with any SSO.
Without both, single sign-on is unavailable and the page says so.
+Turning on **Require single sign-on** does not end members' existing sessions: a session
+signed in with a password before it was turned on lasts until its refresh token expires (up
+to 30 days).
+
+For Microsoft Entra, add the optional `email` claim to the ID token (Token configuration).
+Without it, first sign-ins are refused (`no_verified_email`).
+
## Not built yet
- Creating accounts at first sign-in (SCIM will provision them).
diff --git a/libs/common/store.py b/libs/common/store.py
index 11f5156..b3407cd 100644
--- a/libs/common/store.py
+++ b/libs/common/store.py
@@ -724,18 +724,24 @@ async def link_sso(self, org: str, user_id: str, issuer: str, subject: str) -> b
if any(u is not user and u.get("sso_issuer") == issuer and u.get("sso_subject") == subject
for u in self.users.values()):
return False
- if user.get("sso_subject") and (user.get("sso_issuer"), user["sso_subject"]) != (issuer, subject):
+ # Within one issuer the subject never changes; a different issuer (the organization
+ # moved to another identity provider) is re-linked.
+ if user.get("sso_subject") and user.get("sso_issuer") == issuer and user["sso_subject"] != subject:
return False
user.update(sso_issuer=issuer, sso_subject=subject)
return True
- async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, expires_at: str) -> None:
- self.sso_handoffs[code_hash] = {"organization_id": org, "user_id": str(user_id), "expires_at": expires_at,
- "used_at": None}
+ async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, binding_hash: str,
+ expires_at: str) -> None:
+ self.sso_handoffs[code_hash] = {"organization_id": org, "user_id": str(user_id), "binding_hash": binding_hash,
+ "expires_at": expires_at, "used_at": None}
- async def consume_sso_handoff(self, code_hash: str) -> Optional[Dict[str, Any]]:
+ async def consume_sso_handoff(self, code_hash: str, binding_hash: str) -> Optional[Dict[str, Any]]:
+ """Use the code once, only with the binding of the browser that started; a wrong
+ binding leaves it unused."""
h = self.sso_handoffs.get(code_hash)
- if not h or h["used_at"] or datetime.fromisoformat(h["expires_at"]) <= datetime.now(timezone.utc):
+ if (not h or h["used_at"] or h["binding_hash"] != binding_hash
+ or datetime.fromisoformat(h["expires_at"]) <= datetime.now(timezone.utc)):
return None
h["used_at"] = _now()
return {"user_id": h["user_id"], "organization_id": h["organization_id"]}
@@ -2035,21 +2041,26 @@ async def link_sso(self, org: str, user_id: str, issuer: str, subject: str) -> b
return False
try:
rows = await self._all(org, (
+ # Within one issuer the subject never changes; another issuer is re-linked.
"UPDATE users SET sso_issuer = :i, sso_subject = :s, updated_at = now() WHERE id = CAST(:u AS uuid) "
- "AND (sso_subject IS NULL OR (sso_issuer = :i AND sso_subject = :s)) RETURNING id"),
+ "AND (sso_subject IS NULL OR sso_issuer IS DISTINCT FROM :i OR sso_subject = :s) RETURNING id"),
{"i": issuer, "s": subject, "u": user_id})
except IntegrityError: # that subject is already linked to another account
return False
return bool(rows)
- async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, expires_at: str) -> None:
+ async def create_sso_handoff(self, org: str, user_id: str, code_hash: str, binding_hash: str,
+ expires_at: str) -> None:
await self._exec(org, [(
- "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, expires_at) "
- "VALUES (:h, CAST(:org AS uuid), CAST(:u AS uuid), :x)",
- {"h": code_hash, "org": org, "u": user_id, "x": datetime.fromisoformat(expires_at)})])
-
- async def consume_sso_handoff(self, code_hash: str) -> Optional[Dict[str, Any]]:
- rows = await self._all(None, "SELECT * FROM scirm_sso_handoff_consume(:h)", {"h": code_hash})
+ "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, binding_hash, expires_at) "
+ "VALUES (:h, CAST(:org AS uuid), CAST(:u AS uuid), :b, :x)",
+ {"h": code_hash, "org": org, "u": user_id, "b": binding_hash, "x": datetime.fromisoformat(expires_at)})])
+
+ async def consume_sso_handoff(self, code_hash: str, binding_hash: str) -> Optional[Dict[str, Any]]:
+ """Use the code once, only with the binding of the browser that started; a wrong
+ binding leaves it unused."""
+ rows = await self._all(None, "SELECT * FROM scirm_sso_handoff_consume(:h, :b)",
+ {"h": code_hash, "b": binding_hash})
return _jsonable(rows[0]) if rows else None
async def request_password_reset(self, email: str, token_hash: str, expires_at: str) -> Optional[Dict[str, Any]]:
diff --git a/scripts/db_setup.py b/scripts/db_setup.py
index ee7c2d9..ad952b0 100644
--- a/scripts/db_setup.py
+++ b/scripts/db_setup.py
@@ -65,7 +65,7 @@ def ensure_app_role(owner_url: str, role: str, password: str) -> None:
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
"scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
- "scirm_sso_handoff_consume(text)"):
+ "scirm_sso_handoff_consume(text, text)"):
cur.execute(sql.SQL("GRANT EXECUTE ON FUNCTION " + fn + " TO {}").format(ident))
conn.close()
diff --git a/tests/db/test_database.py b/tests/db/test_database.py
index 6546ff5..66391bc 100644
--- a/tests/db/test_database.py
+++ b/tests/db/test_database.py
@@ -228,7 +228,7 @@ def test_lookups_work_when_the_owner_is_not_a_superuser(self, db):
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
"scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
- "scirm_sso_handoff_consume(text)"):
+ "scirm_sso_handoff_consume(text, text)"):
cur.execute(f"ALTER FUNCTION {fn} OWNER TO scirm_plain_owner")
try:
rows = as_org(db["app"], "", "SELECT email FROM scirm_login_lookup('managed@example.com')")
@@ -242,11 +242,12 @@ def test_lookups_work_when_the_owner_is_not_a_superuser(self, db):
assert as_org(db["app"], "", consume, ("8" * 64,)) == [("managed@example.com",)]
assert as_org(db["app"], "", consume, ("8" * 64,)) == [] # once
uid = as_org(db["app"], db["a"], "SELECT id::text FROM users WHERE email = %s", ("managed@example.com",))[0][0]
- as_org(db["app"], db["a"], "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, expires_at) "
- "VALUES (%s, %s, %s, now() + interval '1 minute')", ("7" * 64, db["a"], uid))
- handoff = "SELECT user_id::text FROM scirm_sso_handoff_consume(%s)"
- assert as_org(db["app"], "", handoff, ("7" * 64,)) == [(uid,)]
- assert as_org(db["app"], "", handoff, ("7" * 64,)) == [] # once
+ as_org(db["app"], db["a"], "INSERT INTO sso_handoffs (code_hash, organization_id, user_id, binding_hash, "
+ "expires_at) VALUES (%s, %s, %s, %s, now() + interval '1 minute')", ("7" * 64, db["a"], uid, "6" * 64))
+ handoff = "SELECT user_id::text FROM scirm_sso_handoff_consume(%s, %s)"
+ assert as_org(db["app"], "", handoff, ("7" * 64, "5" * 64)) == [] # another browser's binding
+ assert as_org(db["app"], "", handoff, ("7" * 64, "6" * 64)) == [(uid,)]
+ assert as_org(db["app"], "", handoff, ("7" * 64, "6" * 64)) == [] # once
finally:
with db["owner"].cursor() as cur:
cur.execute("SELECT current_user")
@@ -257,7 +258,7 @@ def test_lookups_work_when_the_owner_is_not_a_superuser(self, db):
for fn in ("scirm_login_lookup(text)", "scirm_user_by_id(uuid)", "scirm_invitation_lookup(text)",
"scirm_accept_invitation(text, text, text)", "scirm_api_key_lookup(text)",
"scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)",
- "scirm_sso_handoff_consume(text)"):
+ "scirm_sso_handoff_consume(text, text)"):
cur.execute(f"ALTER FUNCTION {fn} OWNER TO {me}")
def test_an_invitation_can_be_accepted_only_once_even_concurrently(self, db):
diff --git a/tests/security/test_sso.py b/tests/security/test_sso.py
index 455565e..2631565 100644
--- a/tests/security/test_sso.py
+++ b/tests/security/test_sso.py
@@ -102,15 +102,24 @@ def member(client, sarah):
return email
-def start(client, email, next_path="/suppliers"):
- response = client.get("/api/v1/auth/sso/start", params={"email": email, "next": next_path}, follow_redirects=False)
+def binding_pair():
+ """(value the login page keeps in the browser, its SHA-256 sent to /sso/start)"""
+ value = "bind-" + uuid.uuid4().hex + uuid.uuid4().hex
+ return value, hashlib.sha256(value.encode()).hexdigest()
+
+
+def start(client, email, next_path="/suppliers", binding_hash=None):
+ binding_hash = binding_hash or binding_pair()[1]
+ response = client.get("/api/v1/auth/sso/start", params={"email": email, "next": next_path, "binding": binding_hash},
+ follow_redirects=False)
assert response.status_code == 302, response.text
return response
-def sign_in(client, idp, email, *, sub=None, claims_extra=None, tamper=None, state_override=None):
+def sign_in(client, idp, email, *, sub=None, claims_extra=None, tamper=None, state_override=None, binding=None):
"""Start, 'log in' at the fake provider, and return the callback response."""
- location = urlsplit(start(client, email).headers["location"])
+ value, digest = binding or binding_pair()
+ location = urlsplit(start(client, email, binding_hash=digest).headers["location"])
q = {k: v[0] for k, v in parse_qs(location.query).items()}
claims = {**idp.claims(sub or f"idp-{email}", email, q["nonce"]), **(claims_extra or {})}
id_token = tamper(claims) if tamper else idp.sign(claims)
@@ -127,16 +136,45 @@ def handoff(callback):
class TestFlow:
def test_full_sign_in(self, client, idp, configured, member):
- callback, q = sign_in(client, idp, member)
+ binding = binding_pair()
+ callback, q = sign_in(client, idp, member, binding=binding)
assert q["code_challenge_method"] == "S256" and q["response_mode"] == "query" and q["login_hint"] == member
assert q["redirect_uri"] == "http://localhost:8000/api/v1/auth/sso/callback"
code = handoff(callback)
assert "next=/suppliers" in callback.headers["location"]
- tokens = client.post("/api/v1/auth/sso/exchange", json={"code": code})
+ tokens = client.post("/api/v1/auth/sso/exchange", json={"code": code, "binding": binding[0]})
assert tokens.status_code == 200, tokens.text
me = jwt.decode(tokens.json()["access_token"], options={"verify_signature": False})
assert me["email"] == member
- assert client.post("/api/v1/auth/sso/exchange", json={"code": code}).status_code == 400 # once
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": code, "binding": binding[0]}).status_code == 400
+
+ def test_the_code_only_works_in_the_browser_that_started(self, client, idp, configured, member):
+ """Login CSRF: a captured code, or one from another start, is useless elsewhere."""
+ mine, theirs = binding_pair(), binding_pair()
+ code = handoff(sign_in(client, idp, member, binding=mine)[0])
+ for wrong in (theirs[0], "x" * 40):
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": code, "binding": wrong}).status_code == 400
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": code}).status_code == 422
+ # The failed attempts did not use it up.
+ assert client.post("/api/v1/auth/sso/exchange", json={"code": code, "binding": mine[0]}).status_code == 200
+
+ def test_start_needs_the_login_pages_binding(self, client, idp, configured, member):
+ for params in ({"email": member}, {"email": member, "binding": "not-a-hash"}):
+ assert client.get("/api/v1/auth/sso/start", params=params, follow_redirects=False).status_code == 400
+
+ def test_moving_to_another_identity_provider_relinks(self, client, sarah, idp, configured, member):
+ handoff(sign_in(client, idp, member, sub="old-provider-subject")[0])
+ client.cookies.clear()
+ global ISSUER
+ old_issuer, ISSUER = ISSUER, "https://idp.example.com/realms/new"
+ idp.issuer_in_discovery = ISSUER
+ try:
+ assert client.put("/api/v1/org/sso", json={"issuer": ISSUER, "client_id": CLIENT_ID},
+ headers=bearer(sarah["access_token"])).status_code == 200
+ callback, _ = sign_in(client, idp, member, sub="new-provider-subject")
+ assert "/sso/complete#code=" in callback.headers["location"]
+ finally:
+ ISSUER = old_issuer
assert idp.token_requests[-1]["grant_type"] == ["authorization_code"]
def test_the_account_is_linked_to_the_subject(self, client, idp, configured, member):
@@ -151,7 +189,8 @@ def test_the_account_is_linked_to_the_subject(self, client, idp, configured, mem
def test_unknown_accounts_and_other_organizations_get_the_same_answer(self, client, idp, configured, lisa):
for email in (f"nobody-{uuid.uuid4().hex[:6]}@example.com", "lisa.park@medtech.com"):
- response = client.get("/api/v1/auth/sso/start", params={"email": email}, follow_redirects=False)
+ response = client.get("/api/v1/auth/sso/start", params={"email": email, "binding": binding_pair()[1]},
+ follow_redirects=False)
assert response.status_code == 302 and response.headers["location"].endswith("/login?sso=unavailable")
@@ -197,9 +236,11 @@ def test_an_expired_handoff_is_refused(self, client, apps, idp, configured, memb
store = apps["gateway"].audit_store
account = asyncio.run(store.find_login_user(member))
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
+ value, digest = binding_pair()
asyncio.run(store.create_sso_handoff(str(account["organization_id"]), str(account["id"]),
- hashlib.sha256(b"old-handoff-code-123456").hexdigest(), past))
- assert client.post("/api/v1/auth/sso/exchange", json={"code": "old-handoff-code-123456"}).status_code == 400
+ hashlib.sha256(b"old-handoff-code-123456").hexdigest(), digest, past))
+ assert client.post("/api/v1/auth/sso/exchange",
+ json={"code": "old-handoff-code-123456", "binding": value}).status_code == 400
class TestConfiguration:
@@ -241,6 +282,13 @@ def test_members_must_use_sso_admins_keep_their_password(self, client, login, sa
assert refused.status_code == 403 and "single sign-on" in refused.json()["detail"]
assert login("sarah.chen@pharmacorp.com").status_code == 200 # demo admin
assert login(member, "wrong-password").status_code == 401 # nothing revealed without the password
+ # A new member accepting an invitation gets an account, but no password session.
+ email = f"sso-new-{uuid.uuid4().hex[:6]}@example.com"
+ invite = client.post("/api/v1/org/invitations", json={"email": email, "roles": ["viewer"]},
+ headers=bearer(sarah["access_token"])).json()["invite_url"].split("#", 1)[1]
+ accepted = client.post("/api/v1/invitations/accept",
+ json={"token": invite, "name": "N", "password": "a-good-password"}).json()
+ assert accepted.get("sso_required") is True and "access_token" not in accepted
finally:
client.delete("/api/v1/org/sso", headers=bearer(sarah["access_token"]))
assert login(member, "a-good-password").status_code == 200