# OrgState — OIDC SSO Setup Enterprise sign-on via OIDC. Stage 97. Works with any compliant IdP — Okta, Google Workspace, Microsoft Entra (Azure AD), Auth0, Authentik, etc. SAML is NOT supported in V1.1; document a customer request if you hit one. ## TL;DR for the customer success engineer ```bash # 1. install the optional SSO dep (once per deployment) pip install -r requirements-sso.txt # 2. register the IdP (operator side) infra sso provider create acme \ --name "Okta Prod" \ --issuer-url https://acme.okta.com \ --client-id 0oa...id \ --client-secret "iKx...secret" \ --allowed-email-domains acme.com # 3. point the customer at https://api.orgstate.example/sso/acme/login # → 302 to Okta → Okta login → 302 to callback → session set ``` ## How the flow works 1. User clicks "Sign in with SSO" in the dashboard → `GET /sso/{tid}/login`. 2. We mint state + nonce + PKCE verifier, stash them in a short-lived signed cookie, redirect to the IdP's `authorize_endpoint`. 3. IdP authenticates the user, redirects to `GET /sso/{tid}/callback?code=...&state=...`. 4. We verify the state matches the cookie, exchange the code for tokens at the IdP's `token_endpoint`, validate the ID token (signature via JWKS + iss + aud + exp + nonce). 5. Verified email → domain gate check (`allowed_email_domains`) → mint a session row in `sso_sessions`, set as cookie (`orgstate_sso_session`), redirect to `/`. 6. Subsequent requests carry the session cookie (browser) or `Authorization: Bearer ` (SDK). ## Env vars | Var | Required? | Default | Why | |---|---|---|---| | `ORGSTATE_SSO_COOKIE_SECRET` | yes (multi-replica) | per-process random | HMAC key for the flow cookie. **Must be the same across replicas** or callbacks land on a node that didn't mint the flow → 400 sso_flow_expired. | | `ORGSTATE_REQUIRE_HTTPS_WEBHOOKS` | recommended | unset | When true, blocks `http://` IdP issuers (Stage 88). | ## Per-IdP setup ### Okta 1. Okta admin → Applications → Create App Integration → OIDC → Web Application. 2. Sign-in redirect URI: `https://api.orgstate.example/sso/{tid}/callback`. Add one per tenant. 3. Sign-out redirect URI: `https://dashboard.example/` (whatever post-logout landing you want). 4. Grant type: Authorization Code + PKCE. 5. Copy the Client ID + Client Secret + the Okta org URL (`https://acme.okta.com`). 6. Run: ```bash infra sso provider create acme \ --name "Okta Prod" \ --issuer-url https://acme.okta.com \ --client-id \ --client-secret \ --allowed-email-domains acme.com ``` ### Google Workspace 1. Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client ID. 2. Application type: Web application. 3. Authorized redirect URI: `https://api.orgstate.example/sso/{tid}/callback`. 4. Copy Client ID + Client Secret. 5. Issuer URL: `https://accounts.google.com`. 6. Run: ```bash infra sso provider create acme \ --name "Google Workspace" \ --issuer-url https://accounts.google.com \ --client-id .apps.googleusercontent.com \ --client-secret \ --allowed-email-domains acme.com ``` ### Microsoft Entra (Azure AD) 1. Entra portal → App registrations → New registration. 2. Redirect URI (Web): `https://api.orgstate.example/sso/{tid}/callback`. 3. Certificates & secrets → New client secret → copy value. 4. Issuer URL: `https://login.microsoftonline.com//v2.0`. The tenant_id here is Entra's tenant ID, NOT your OrgState tenant_id. 5. API permissions → grant `openid`, `email`, `profile`. 6. Run: ```bash infra sso provider create acme \ --name "Microsoft Entra" \ --issuer-url https://login.microsoftonline.com//v2.0 \ --client-id \ --client-secret \ --allowed-email-domains acme.com ``` ### Auth0 1. Auth0 dashboard → Applications → Create → Regular Web App. 2. Allowed Callback URLs: `https://api.orgstate.example/sso/{tid}/callback`. 3. Issuer URL: `https://.auth0.com`. 4. Run `infra sso provider create` with the Client ID + Secret. ## Multiple IdPs per tenant A tenant can register multiple SSO providers (e.g. one Okta + one Google for partners). When that happens, the login URL must specify which one: ``` https://api.orgstate.example/sso/acme/login?provider_id=sso_abc123 ``` Without `?provider_id=` and with multiple providers, we return 400 `sso_provider_required` rather than guessing. ## Domain gate (`allowed_email_domains`) CSV list of email-domain suffixes the verified email must match. Empty string = allow any verified email (useful for personal Google accounts in dev, dangerous in prod). Examples: - `acme.com` → only @acme.com - `acme.com,partner.io` → either - `""` (empty) → any verified email (NOT recommended for prod) The check is case-insensitive and anchored at the `@` — `acme.com` does NOT match `evilacme.com`. ## Session lifetime 12 hours (`DEFAULT_SESSION_TTL_HOURS` in `infra/sso/sessions.py`). Aligns with a typical workday. To change, edit the constant and redeploy. ## Session operations (Stage 98) ```bash # who's logged in right now? infra sso session list acme infra sso session list acme --user-email alice@acme.com # forensics — include past sessions infra sso session list acme --include-revoked --include-expired # revoke ONE session (operator pastes token from list) infra sso session revoke --token # OFFBOARDING: kill EVERY session for a user across all devices infra sso session revoke --tenant acme --user-email alice@acme.com # writes audit row `sso_revoke_user` with the count # housekeeping: drop sessions past their expires_at # (audit row only when count > 0 — cron-friendly) infra sso session purge # typical nightly cron entry: # 0 3 * * * python -m infra sso session purge --actor cron_nightly ``` Audit trail — every operation lands in `audit_logs`: | Action | When | |---|---| | `sso_login` | session created via /sso/.../callback | | `sso_logout` | single-token revoke (`infra sso session revoke --token`) | | `sso_revoke_user` | bulk revoke (`--tenant + --user-email`) | | `sso_purge_expired` | purge with count > 0 | ## Troubleshooting | Symptom | Likely cause | |---|---| | 503 `sso_unavailable` | `pip install -r requirements-sso.txt` not run on the deployed instance. | | 404 `sso_not_configured` | No providers registered for this tenant. Run `infra sso provider list `. | | 400 `sso_state_mismatch` on callback | Browser dropped the flow cookie OR you're behind a TLS terminator that strips cookies. Check SameSite / Secure flags + `ORGSTATE_REQUIRE_HTTPS=true`. | | 400 `sso_flow_expired` after multi-replica deploy | `ORGSTATE_SSO_COOKIE_SECRET` not set or differs between replicas. Set it to the same value on every node. | | 401 `sso_email_unverified` | IdP says the email isn't verified. User confirms email at the IdP and retries. | | 403 `sso_domain_not_allowed` | User's email domain isn't in `allowed_email_domains`. Either add the domain or have the user use a permitted account. | | 502 `sso_discovery_failed` | IdP's `/.well-known/openid-configuration` unreachable from the OrgState process. Network ACL? | Full troubleshooting log: every successful login writes `sso_login`, every revocation writes `sso_logout`. Failed callbacks land in regular API error logs with the request_id (Stage 84) — `grep request_id=<...>` to see the chain. ## See also - [`ENCRYPTION.md`](ENCRYPTION.md) — encryption-at-rest posture, including client_secret handling roadmap - [`RUNBOOK.md`](RUNBOOK.md) — operator runbook (incident triage, key rotation) - Stage 88 (`infra/api/tls.py`) — TLS enforcement that pairs with SSO (you want both on in prod) - Stage 82 — GDPR erasure also wipes sso_providers + sso_sessions