Skip to content

fix(platform): harden SSO trust boundaries (binding, replay, origins) - #3131

Merged
larryro merged 7 commits into
mainfrom
fix/sso-org-binding
Sep 3, 2026
Merged

fix(platform): harden SSO trust boundaries (binding, replay, origins)#3131
larryro merged 7 commits into
mainfrom
fix/sso-org-binding

Conversation

@larryro

@larryro larryro commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes the SSO trust-boundary class from the deep review: org-scoped identity binding, the inert trusted-headers internal-secret guard, SAML assertion replay, proxy-origin cookie/redirect correctness, and Entra group-sync truncation.

The SSO org-binding contract (finding 1, critical)

findOrCreateSsoUser matched the asserted email across all orgs, silently auto-joined the user into the connection's org, and minted a user-scoped session. Org admins fully self-serve their IdP config (PUT /api/app/sso/config/*, orgSettings gate), so any org could point its connection at an IdP it controls, assert a victim's email, and hold a session as that user — cross-org account takeover.

The contract now enforced (inside the same transaction, before any write):

  • Existing user, member of the connection's org → sign in; attach/refresh the provider account; role sync per autoProvisionRole (unchanged).
  • Existing user, NOT a member of that org → refuse. No account link, no auto-join, no session. The login page shows the actionable sso.errors.notOrgMember message (en/de/fr), and an sso_login_failed audit row is written on both lanes (the OIDC callback previously audited only thrown errors, never business refusals — it now mirrors the ACS posture).
  • No user with that email anywhere → JIT-create the user, org-bound, exactly as before.

Membership therefore comes only from flows the user or the org's own directory controls: invite acceptance (better-auth core requires the invitee's authenticated session + email match before the member row exists), SCIM provisioning (org-resolved from the per-org token hash; SCIM already refuses cross-tenant email collisions with 409), the org-admin add-member surface (audited), or SSO JIT creation of a genuinely new user. Users can still legitimately span orgs — each org that wants an existing user must bring them in through one of those flows first.

Session minting keeps binding activeOrganizationId to the connection's org (asserted by tests now). Better Auth's set-active re-checks membership on every switch.

Per-finding fixes

# Finding Fix Tests
1 SSO links users by global email (critical, ×2) Membership gate in domains/sso/service.ts (above) domains/sso/service.test.ts (refusal writes nothing; member sign-in; JIT create; session org binding) + integration probe: existing outside-org user refused, no auto-join, audited
2 TRUSTED_HEADERS_INTERNAL_SECRET compared against itself (high, ×3) The guard now compares the caller-supplied header (Remote-Internal-Secret, renamable via TRUSTED_SECRET_HEADER) against the env secret, constant-time (timingSafeEqual). Fail closed: TRUSTED_HEADERS_ENABLED without the secret refuses with a config error. The stock Caddy chain forwards /api/trusted-headers/* verbatim and injects nothing — the legit path is the operator's authenticating proxy injecting the header, now documented (en/de/fr: self-hosted authentication guide + environment reference) domains/sso/trusted-headers.test.ts (9: service guard + route door, wrong/missing/unset refused, right secret passes, custom header name) + integration probes
3 SAML assertions replayable — no InResponseTo validation (high) ValidateInResponseTo.ifPresent + a PG-backed CacheProvider over new app.saml_request_ids (migration 0060): issued AuthnRequest IDs are shared across instances, a response's InResponseTo must match one, node-saml consumes it on success (one-time use; atomic DELETE..RETURNING). ifPresent keeps IdP-initiated posts supported — their replay window stays bounded by NotBefore/NotOnOrAfter + audience, which node-saml enforces (documented in buildSaml) saml/validate_assertion.test.ts (real node-saml + real signed assertions: replay refused, forged id refused, one-time consumption, IdP-initiated still accepted, redirect stores the id) + saml-request-cache.test.ts (provider contract) + integration probes (row lifecycle, replay + forged refused over real PG)
4 SAML login dead-ends behind the proxy (high) publicOrigin() (SITE_URL, falling back to the request origin — the OIDC handlers' existing inline idiom, now shared) used by the SAML ACS, /api/sso/set-session, and the trusted-headers door, which had the same defect: behind TLS termination they set the unprefixed cookie while Better Auth (useSecureCookies from SITE_URL) reads __Secure-…, and redirects targeted the internal upstream login/public_origin.test.ts (4: prefers SITE_URL, trailing-slash normalization, http stays http, fallback)
5 Entra group fetch ignores Graph pagination; truncation strips memberships (high) getGroups/getAppRoles follow @odata.nextLink to exhaustion, capped at 50 pages (~5000 entries); past the cap (or any non-OK page) they throw, which lands in the callers' existing failed-fetch path — team sync skipped, memberships preserved — instead of a silent partial list that syncTeamsFromGroupNames would prune against. A nextLink leaving the Graph origin is refused so the bearer token never follows a foreign link entra_id/adapter.test.ts (7 new: two-page union, single page, cap throws, off-origin refused, non-OK propagates, app-role union, failed fetch degrades to [])

Refuted / narrowed

  • "Session minting must bind the session's active org" — already true (createSsoUserSession sets activeOrganizationId); now asserted by tests and documented.
  • Generic OIDC / OAuth2 adapters — their getGroups reads userinfo claims, no pagination lane exists there; finding 5 is Entra-only.

Breaking / operator-visible changes

  • Trusted headers now require the internal secret. A deployment running TRUSTED_HEADERS_ENABLED=true without TRUSTED_HEADERS_INTERNAL_SECRET refuses logins with a config error (and a precise operator log line) after upgrade — previously the mode ran with no request authentication at all, which is the vulnerability. Docs updated (en/de/fr).
  • Cross-org SSO attach is refused where it used to silently auto-join. Users who relied on logging into a second org via that org's IdP without being members must be invited / SCIM-provisioned once; the login-page error says exactly that.

Cross-class discoveries (follow-up candidates)

  1. SSO sessions are user-scoped after the org gate. An org's IdP asserting one of its own members still yields a session that can set-active into any other org that user belongs to (Better Auth verifies membership, but the rogue-IdP scenario for shared members remains). Restricting SSO-minted sessions to their connection's org — or requiring fresh auth on switch — is an org-activation-model decision worth a dedicated issue.
  2. POST /api/app/members (admin add-existing-user) is consent-free — an org admin who learns a victim's internal userId can add them as a member without an invitation acceptance, which weakens the membership gate (it is audited and requires the non-guessable userId, so materially harder than the email-only vector fixed here).
  3. Provider-account clobbering for multi-org users: the account row is keyed (userId, providerId), so a user signing into two orgs that both use e.g. entra-id has accountId/tokens overwritten by whichever org they logged into last. No session impact, but Graph-token consumers could read the other org's token. Pre-existing; out of this class.
  4. The OIDC callback never audited business refusals (only thrown errors) — fixed here for the !result.success path; worth remembering the pattern for other reused 0.4 handlers.
  5. migrations:check (named in the repo contract) does not exist as a script in this repo state — the migration is proven by backend:integration per the create-migration skill.

Verification

  • bun run --filter @tale/platform typecheck — green.
  • bun run --filter @tale/platform lint — green.
  • Touched vitest files (7 files, 63 tests) — green, including the real-node-saml replay suite and the platform i18n parity/orphan suite.
  • bun run --filter @tale/docs test — 194/194 green (locale-tree/outline over the en/de/fr doc edits).
  • bun run --filter @tale/ui test — all 1168 tests green incl. the de/fr voice/terminology checks; one suite (app-shell.test.tsx) errors loading a font through the worktree's node_modules symlink (Vite fs-allow, unrelated to this diff — no packages/ui file is touched).
  • bun run --filter @tale/platform backend:integration against a throwaway Postgres + MinIO — 281/281 checks passed (boot migrations incl. 0060 applied twice concurrently, plus the new probes: SAML: InResponseTo one-time use, SAML: an existing user outside the org is refused, trusted-headers auth secret gate).

/me/memberOf and /me/appRoleAssignments page at 100 entries; reading only
page 1 silently truncated the group list, and with autoProvisionTeam the
team sync then PRUNED every membership beyond the first page on each
login. Follow @odata.nextLink to exhaustion, bounded at 50 pages — past
the cap (or on any non-OK page) the read throws, landing in the callers'
existing failed-fetch path: team sync skipped, memberships preserved.
A nextLink leaving the Graph origin is refused so the bearer token never
follows a foreign link.
Behind the reverse proxy req.url carries the internal upstream origin
(http even when the deployment terminates TLS). The SAML ACS handler,
/api/sso/set-session and the trusted-headers door derived the login
finish from it, so on standard https deployments they set the unprefixed
better-auth.session_token cookie while Better Auth (useSecureCookies
from SITE_URL) reads __Secure-better-auth.session_token — the user
authenticated, then landed logged out; redirects pointed at the internal
host. Prefer SITE_URL, falling back to the request origin only when it
is unset — the idiom the OIDC authorize/callback handlers always used,
now shared as publicOrigin().
The route passed process.env.TRUSTED_HEADERS_INTERNAL_SECRET as the
caller-supplied secret, so trustedHeadersAuthenticate compared the env
var against itself — the guard could never fail, and anyone reaching the
endpoint minted a session as whoever Remote-Email named. The stock proxy
chain forwards /api/trusted-headers/* verbatim, so topology was the only
defense.

Now the caller value comes from the request header the authenticating
proxy injects (Remote-Internal-Secret, renamable via
TRUSTED_SECRET_HEADER) and is compared constant-time against the env
secret. Fail closed: TRUSTED_HEADERS_ENABLED without the secret refuses
with a config error instead of running open. Breaking for deployments
that enabled the mode without a secret — documented in the self-hosted
authentication guide and environment reference (en/de/fr).
…ache

buildSaml never set validateInResponseTo (node-saml defaults to never)
and the AuthnRequest ID from getAuthorizeUrlAsync was discarded, so a
captured SAMLResponse validated repeatedly until NotOnOrAfter closed —
each replay minting a fresh session.

Wire ValidateInResponseTo.ifPresent with a CacheProvider backed by the
new app.saml_request_ids table (0059): issued request IDs are stored
where every instance can see them, a response's InResponseTo must match
one, and node-saml consumes the ID on success — one-time use, atomic
DELETE..RETURNING. ifPresent (not always) keeps IdP-initiated posts
working; their replay window stays bounded by the assertion's
NotBefore/NotOnOrAfter, which node-saml already enforces alongside the
audience restriction. Rows expire after node-saml's 8h request window,
pruned lazily on the next save.
findOrCreateSsoUser matched the asserted email across ALL orgs, silently
auto-joined the user into the connection's org, and minted a session as
them. Org admins fully self-serve their IdP config, so any org could
point its connection at an IdP it controls, assert a victim's email, and
walk away with a session as that user — cross-org account takeover.

The contract now: an org's IdP signs in users the org already has (a
member row — invited and accepted, SCIM-provisioned, or admin-added) and
JIT-creates users new to the deployment, org-bound. An existing user
with no membership in the connection's org is refused inside the same
transaction, before any write — no account link, no auto-join, no
session — with the actionable sso.errors.notOrgMember error on the
login page (en/de/fr) and an sso_login_failed audit row on both the
OIDC callback (which never audited business refusals) and SAML ACS
lanes. Session minting keeps binding activeOrganizationId to the
connection's org.
…ct TS

Explicit Response/unknown annotations break the TS7022 inference cycle
in the pagination loop (nextLink feeds the next fetch), the adapter's
optional getGroups/getAppRoles are exercised through throwing wrappers
instead of unbound destructured references, and the Hono request helper
is async so its Response | Promise<Response> return normalizes.
The parallel fix branch fix/tasks-org-scoping (PR #3130, opened first)
already claims 0059 off the same main; filename order IS apply order and
the number must never be reused, so this branch takes the next slot.
Nothing has shipped under the old name.
@larryro
larryro marked this pull request as ready for review September 2, 2026 11:19
@larryro
larryro merged commit 92f1958 into main Sep 3, 2026
23 checks passed
@larryro
larryro deleted the fix/sso-org-binding branch September 3, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant