Commit graph

4 commits

Author SHA1 Message Date
ScreenTinker d4b8d7dad4 SSO: prove domain ownership by DNS, and fix what the second review found
A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.

DOMAIN OWNERSHIP (the root cause, not the symptom)

A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.

  - an unverified domain routes NOBODY and cannot be asserted; it reserves the name
  - an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
    real owner, and lapsing rotates the token so a record left over from an abandoned
    attempt cannot satisfy a later claim
  - a verified domain never expires — re-proving on a timer would log a customer out
    over a DNS edit made months later
  - routing and confinement read the VERIFIED set only, never the typed column
  - configuring SSO now requires a verified email address
  - platform admins are emailed when a domain is claimed; nothing is ever sent to the
    claimed domain, which would let any tenant make this product email third parties

Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.

BLOCKERS FROM THE REVIEW

  - two unauthenticated remote crashes, both one request, both "async handler throws
    before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
    fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
    permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
  - the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
    refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
    parsed and compared by RANGE. 42 cases verified.
  - the takeover fix had NO test — the test named after it asserted two struct fields
    and passed with the guard deleted. The decision is now a pure function and four
    mutations were confirmed to turn the suite red.
  - the PUT path never received the TOCTOU fix, so two orgs could end up holding one
    domain and forEmail handed routing to the attacker's older row.

ALSO

  - linking compared slugs, so an org could never rotate its own IdP, and fell open on
    an empty auth_provider. It now asks which ORGANIZATION owns the slug.
  - an account stranded by a deleted provider can be reclaimed by password reset —
    proof of the mailbox, which is stronger than the IdP assertion that created it.
  - /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
    it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
  - the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
    fresh bucket — a real login brute-force bypass.
  - domain_not_allowed and account_exists_other_provider rendered as "please try
    again", advice that can never work.
  - malformed asserted addresses are refused rather than trimmed into shape.
  - dead config (microsoftTenantId defaulted to 'common', which the provider code now
    refuses) and the orphaned google-auth-library dependency removed.

1591 tests pass. Domain lifecycle verified end to end against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 19:23:46 -05:00
ScreenTinker d26aaebef6 SSO: fix an account takeover, a remote crash, and login CSRF found in review
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.

TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.

The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.

REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.

LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.

FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.

PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.

MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.

SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).

Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.

Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.

⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.

1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 18:12:07 -05:00
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