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}

} +
+ +
+ + +
+ +
+ + {settings?.config && ( + + )} +
+
+
+ ); +} 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 && ( + + )} +

{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 && (