|
| 1 | +# Proposal: Single Sign-On (SSO) for organizations |
| 2 | + |
| 3 | +Status: **draft, request for comments** |
| 4 | +Tracking issue: [encryption4all/postguard#143](https://github.com/encryption4all/postguard/issues/143) |
| 5 | +Related: [postguard#74](https://github.com/encryption4all/postguard/issues/74) (org user management), [postguard#146](https://github.com/encryption4all/postguard/issues/146) (AD / SCIM sync) |
| 6 | + |
| 7 | +This document proposes the shape of SSO support in postguard-business before |
| 8 | +any schema or code lands. The goal is to agree on the model and phasing so |
| 9 | +that the implementation PRs can be small, focused, and individually |
| 10 | +reviewable. |
| 11 | + |
| 12 | +## Motivation |
| 13 | + |
| 14 | +Today `postguard-business` authenticates every portal action via Yivi/IRMA. |
| 15 | +For an organization with, say, 50 employees, that means 50 separate Yivi |
| 16 | +enrolments and a Yivi disclosure each time any of them wants to hit the |
| 17 | +portal. Enterprise customers (Zivver's Ultimate tier is the reference |
| 18 | +competitor) expect to bring their own identity provider — Azure AD, Google |
| 19 | +Workspace, Okta, Keycloak — and have their users land in the portal |
| 20 | +authenticated, without re-proving identity attributes on every action. |
| 21 | + |
| 22 | +## Non-goals (phase 1) |
| 23 | + |
| 24 | +- **Yivi removal.** Yivi stays as the default auth and as the signing/key |
| 25 | + path. SSO is _added_ as an alternative org-user entry point, not a |
| 26 | + replacement. |
| 27 | +- **SAML.** OIDC first. SAML is strictly more code (SP metadata, signed |
| 28 | + assertions, XML canonicalisation) and most target IdPs support OIDC. SAML |
| 29 | + is phase 2. |
| 30 | +- **Extensions (Outlook / Thunderbird / browser add-on).** Desktop SSO |
| 31 | + needs embedded-browser OIDC with PKCE and is a separate, much larger |
| 32 | + task. Only the portal is in scope for phase 1. |
| 33 | +- **SCIM / AD sync.** Separately tracked in #146. Out of scope here. |
| 34 | +- **Custom-branded login pages per org.** A plain `/auth/sso/[orgSlug]` |
| 35 | + bounce is enough for v1. |
| 36 | + |
| 37 | +## Prerequisite: multi-user organizations (blocks this work) |
| 38 | + |
| 39 | +The current data model has one email per organization (`organizations.email`). |
| 40 | +There is no `org_users` table — you cannot log in "as Alice from Acme", only |
| 41 | +"as Acme". SSO only makes sense once an organization can have many users, |
| 42 | +so this proposal assumes issue #74 (part A, data model) lands first or in |
| 43 | +the same PR stack. |
| 44 | + |
| 45 | +Proposed minimal `org_users` shape (owned by #74, stated here only to fix |
| 46 | +the dependency): |
| 47 | + |
| 48 | +``` |
| 49 | +org_users ( |
| 50 | + id uuid pk, |
| 51 | + org_id uuid references organizations, |
| 52 | + email varchar(256) not null, |
| 53 | + full_name varchar(256), |
| 54 | + role varchar(32) not null default 'member', -- 'owner' | 'admin' | 'member' |
| 55 | + status varchar(32) not null default 'active', -- 'invited' | 'active' | 'disabled' |
| 56 | + auth_source varchar(32) not null default 'yivi', -- 'yivi' | 'sso' |
| 57 | + sso_subject varchar(256), -- IdP 'sub' claim, for SSO users |
| 58 | + created_at timestamptz, |
| 59 | + last_login_at timestamptz, |
| 60 | + unique (org_id, email) |
| 61 | +) |
| 62 | +``` |
| 63 | + |
| 64 | +`sessions.userType` gains `'user'` as a third value (alongside `'org'` and |
| 65 | +`'admin'`) and `sessions.orgUserId` references `org_users.id`. |
| 66 | + |
| 67 | +## Data model for SSO |
| 68 | + |
| 69 | +One new table, owned by this proposal: |
| 70 | + |
| 71 | +``` |
| 72 | +org_identity_providers ( |
| 73 | + id uuid pk, |
| 74 | + org_id uuid references organizations, |
| 75 | + provider varchar(16) not null, -- 'oidc' (phase 1) | 'saml' (phase 2) |
| 76 | + display_name varchar(128) not null, -- shown on portal login page |
| 77 | + enabled boolean not null default false, |
| 78 | +
|
| 79 | + -- OIDC fields |
| 80 | + issuer varchar(512), -- discovery URL, e.g. https://login.microsoftonline.com/{tenant}/v2.0 |
| 81 | + client_id varchar(256), |
| 82 | + client_secret_encrypted bytea, -- envelope-encrypted, key id in client_secret_key_id |
| 83 | + client_secret_key_id varchar(64), |
| 84 | + scopes varchar(256) not null default 'openid email profile', |
| 85 | +
|
| 86 | + -- Attribute mapping: which claim becomes which org_user field. |
| 87 | + -- Defaults: email=email, full_name=name, sso_subject=sub |
| 88 | + claim_mapping jsonb not null default '{}', |
| 89 | +
|
| 90 | + -- Policy |
| 91 | + jit_provisioning boolean not null default false, -- auto-create org_users on first login if email domain matches org.domain |
| 92 | + require_yivi_for_signing boolean not null default true, -- SSO session alone suffices for portal; Yivi still required to sign/decrypt |
| 93 | +
|
| 94 | + created_at timestamptz, |
| 95 | + updated_at timestamptz, |
| 96 | + unique (org_id) -- one IdP per org in phase 1 |
| 97 | +) |
| 98 | +``` |
| 99 | + |
| 100 | +Encryption of `client_secret_encrypted` uses an env-provided symmetric key |
| 101 | +(`SSO_SECRET_KEY`, 32 bytes base64) so leaked DB dumps cannot be used to |
| 102 | +silently replay against an IdP. Key rotation is a follow-up. |
| 103 | + |
| 104 | +## Auth flow (OIDC) |
| 105 | + |
| 106 | +``` |
| 107 | +User → /auth/sso/[orgSlug] |
| 108 | + (SvelteKit route) |
| 109 | + | |
| 110 | + | 1. Look up org by slug, load org_identity_providers row, ensure |
| 111 | + | enabled, generate state + PKCE verifier, stash in signed cookie |
| 112 | + | 2. Redirect to provider.authorizationEndpoint?client_id=…&state=… |
| 113 | + v |
| 114 | + IdP login page → IdP → redirect back |
| 115 | + | |
| 116 | + v |
| 117 | +User → /api/auth/sso/callback?code=…&state=… |
| 118 | + | |
| 119 | + | 1. Validate state + PKCE |
| 120 | + | 2. Exchange code at token endpoint |
| 121 | + | 3. Verify ID token signature against JWKS, iss/aud/exp checks |
| 122 | + | 4. Apply claim_mapping to extract email + sub + full_name |
| 123 | + | 5. Find org_users by (org_id, sso_subject); if miss, find by |
| 124 | + | (org_id, email); if miss and jit_provisioning, insert; else 403 |
| 125 | + | 6. createSession(userType='user', orgUserId, orgId, attrs={sso:true}) |
| 126 | + | 7. Set pg_session cookie (existing helper) |
| 127 | + | 8. Redirect to ?redirect= or /portal/dashboard |
| 128 | +``` |
| 129 | + |
| 130 | +We lean on [`openid-client`](https://github.com/panva/openid-client) (actively |
| 131 | +maintained, certified against the OIDC conformance suite). No hand-rolled |
| 132 | +JWT verification. |
| 133 | + |
| 134 | +## Portal login UX |
| 135 | + |
| 136 | +- `/auth/login` gets a small additional block: "Sign in with your |
| 137 | + organization" with a text input for the org slug/domain, which POSTs to |
| 138 | + `/auth/sso/[orgSlug]`. Yivi stays as the primary option. |
| 139 | +- For orgs with SSO enabled, the portal sidebar shows the signed-in user's |
| 140 | + name + org; the `change organization` / impersonation UI is unchanged |
| 141 | + (admin path only). |
| 142 | + |
| 143 | +## Admin UX |
| 144 | + |
| 145 | +- New page `(admin)/admin/organizations/[id]/sso` — show/edit |
| 146 | + `org_identity_providers` row. Admin only. |
| 147 | +- Shows the org's **redirect URI** (copy-paste into the IdP). |
| 148 | +- "Test configuration" button (admin-only) that initiates an SSO round-trip |
| 149 | + against the saved config and reports success / which step failed. |
| 150 | +- All edits produce entries in `admin_audit_log`. |
| 151 | + |
| 152 | +## Signing and decryption (the ambiguous bit) |
| 153 | + |
| 154 | +The issue says: |
| 155 | + |
| 156 | +> SSO users should still be able to encrypt/decrypt without per-message |
| 157 | +> Yivi authentication where the organization allows it. |
| 158 | +
|
| 159 | +PostGuard's encryption is IRMA/Yivi-based at the core: a recipient proves |
| 160 | +attributes to obtain a decryption key. That cryptographic primitive cannot |
| 161 | +be bypassed by SSO — the KG still wants Yivi proofs. What an org CAN |
| 162 | +opt into is: **the portal accepts an SSO session in place of a fresh Yivi |
| 163 | +disclosure for portal actions** (API key management, audit log, etc.). |
| 164 | +Per-message encryption/decryption still goes through the standard |
| 165 | +Yivi/IRMA flow triggered from the client. |
| 166 | + |
| 167 | +`org_identity_providers.require_yivi_for_signing` defaults to true and |
| 168 | +captures this. An org may flip it to `false` only after we have a story |
| 169 | +for delegating signing attributes to an SSO-authenticated user (likely via |
| 170 | +a short-lived server-signed token the postguard-business backend issues |
| 171 | +after verifying SSO identity + org policy — out of scope here, but the |
| 172 | +flag lets us ship without painting ourselves into a corner). |
| 173 | + |
| 174 | +Flag this for explicit review in the RFC discussion. |
| 175 | + |
| 176 | +## Feature flag |
| 177 | + |
| 178 | +`FF_SSO=true` enables all SSO routes. Off by default. Matches existing |
| 179 | +`FF_*` pattern. When off: |
| 180 | + |
| 181 | +- `/auth/sso/*` returns 404 |
| 182 | +- Admin SSO page is hidden |
| 183 | +- No migration needed to "disable" — the table just stays empty |
| 184 | + |
| 185 | +## Security considerations |
| 186 | + |
| 187 | +- State + PKCE mandatory (prevents code injection / CSRF on callback). |
| 188 | +- `openid-client` does signature + iss/aud/exp/nbf verification. |
| 189 | +- Client secret encrypted at rest with a symmetric key outside the DB. |
| 190 | +- Redirect URI allowlisted per env (no open redirects). |
| 191 | +- Session cookie unchanged (httpOnly, secure, sameSite lax — same as today). |
| 192 | +- `admin_audit_log` entry on every IdP config change (create / enable / |
| 193 | + disable / rotate secret). |
| 194 | +- Login failures (bad state, bad claim, JIT-denied) logged without PII, |
| 195 | + rate-limited per IP. |
| 196 | + |
| 197 | +## Rollout plan (PR-sized chunks) |
| 198 | + |
| 199 | +Each chunk is one PR. Humans can stop the train at any step. |
| 200 | + |
| 201 | +1. **This PR** — proposal only. No code. Merge or reject the plan. |
| 202 | +2. `feat(db): org_users and sso scaffolding` — schema migration only, |
| 203 | + tables empty, no routes. Pure data. (Part of / coordinated with #74.) |
| 204 | +3. `feat(auth): OIDC SSO callback` — `openid-client` dep, `/auth/sso/[slug]`, |
| 205 | + `/api/auth/sso/callback`, session creation path. `FF_SSO` gates |
| 206 | + everything. No admin UI yet; IdP config inserted via seed / db:studio |
| 207 | + for testing. |
| 208 | +4. `feat(portal): SSO login entry on /auth/login` — the small "sign in |
| 209 | + with your organization" block. |
| 210 | +5. `feat(admin): SSO configuration page` — `(admin)/admin/organizations/[id]/sso`, |
| 211 | + CRUD, test button, audit log. |
| 212 | +6. `feat(sso): SAML phase 2` — only if SAML is still wanted. Separate |
| 213 | + proposal amendment. |
| 214 | + |
| 215 | +## Open questions (please answer in the PR thread) |
| 216 | + |
| 217 | +1. **OIDC-only for launch acceptable, or must SAML ship at the same time?** |
| 218 | + (Big scope difference.) |
| 219 | +2. **JIT provisioning default — on or off?** If on, any user with an email |
| 220 | + matching the org's `domain` gets an `org_users` row on first login. |
| 221 | + Convenient but widens who can log in. |
| 222 | +3. **`require_yivi_for_signing` — is the "SSO session is enough for |
| 223 | + _portal_ actions only, Yivi still required for encrypt/decrypt" default |
| 224 | + correct?** This is my read of the issue; please confirm. |
| 225 | +4. **Slug vs. domain for `/auth/sso/[x]`.** Using `organizations.domain` |
| 226 | + (e.g. `acme.com`) means the SSO URL is predictable for end users but |
| 227 | + couples the SSO URL to the domain-verification feature. A short slug |
| 228 | + like `acme` is cleaner but needs a migration. |
| 229 | +5. **Scope of phase 1 for extensions:** confirm Outlook / Thunderbird |
| 230 | + add-ons are explicitly out-of-scope for this issue, and file a separate |
| 231 | + issue for desktop SSO. |
| 232 | + |
| 233 | +If this plan looks right, chunks 2–5 are roughly one week of work total |
| 234 | +and can be reviewed independently. |
0 commit comments