Commit graph

2 commits

Author SHA1 Message Date
ScreenTinker e97228a502 SSO: per-organization providers, configured by the customer
Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "yes, Acme Corp SSO" would turn a guessed domain into
confirmation that Acme buys this product, and the slug would hand out a working
entry point to their tenant. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:33:32 -05:00
ScreenTinker 252854d31e SSO: one OIDC flow for every provider, and verify the token properly
The OAuth support that was here could not work and would not have been safe if
it had.

It could not work: the login page called google.accounts.oauth2 and
new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page
in this app — no script tag, no dynamic import, nothing. Both buttons threw
ReferenceError on click. Even had they loaded, the CSP allows scripts only from
'self' and cloudflareinsights, and frames only from self and YouTube, so the
libraries and their popups were blocked too.

It would not have been safe: both endpoints authenticated with an ACCESS token
and neither checked who it was issued for. POST /auth/google fell back to
tokeninfo?access_token= and read the email out of the reply; POST
/auth/microsoft handed the bearer token to Graph /me and trusted that. Graph
and tokeninfo will both describe the user behind a token minted for SOMEBODY
ELSE'S application, so any site a user signed into that requested `email` or
`User.Read` could have replayed their token here and been issued a session as
them. Both endpoints are deleted; nothing is lost, because nothing could reach
them.

Replaced by ONE generic flow — Authorization Code + PKCE (S256), run
server-side, with the provider list resolved through a single function so
per-organization SSO can extend it later without a second login path. Google
and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik,
Auth0 and anything else that speaks OIDC now work with three env vars.

Because the exchange happens server-side the browser never talks to the
provider, so there is no SDK to load, no client id in the page, and no
third-party origin needed in the CSP.

Identity comes from an ID token that must survive: signature against the
provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused
outright, the latter because the only key we hold is public), `iss` exactly as
discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this
server minted for that login. State is compared in constant time against a
value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and
survives a restart mid-login.

Account rules are the ones already in place: a verified email is required, an
SSO login never takes over an account that has a password, and a changed `sub`
for a known address is refused rather than handing the account to a recycled
mailbox.

18 new tests, every one describing something the old code would have accepted:
cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery,
wrong signing key, expired tokens, a discovery document lying about its issuer,
and a registry that never leaks a client id or secret to the browser.

Verified end to end against Google's real discovery document: the redirect
carries response_type=code, PKCE S256, state and nonce, and every callback
guard rejects as intended (no cookie, wrong state, no code, provider refusal,
unknown provider).

⚠️ TOTP is still not prompted on an SSO login, matching the documented
behaviour of the previous SSO and API-token paths. That is a product decision
and is left unchanged here rather than altered silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:19:28 -05:00