Skip to content

Commit e4370dc

Browse files
docs(sso): RFC proposal for org SSO (#143)
Proposes data model (org_identity_providers, org_users dependency on #74), OIDC-first flow, feature flag FF_SSO, and a 5-PR rollout plan. Opens with a request-for-comments; no code changes. Implementation PRs follow once the shape is agreed. Relates to encryption4all/postguard#143 Related to encryption4all/postguard#74, encryption4all/postguard#146 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f4676e0 commit e4370dc

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

docs/proposals/sso.md

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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

Comments
 (0)