Skip to content

feat(sso): enterprise sso saml/oidc and scim provisioning (phase c.7) - #61

Merged
axelhamil merged 29 commits into
devfrom
feat/sso-scim
Aug 28, 2026
Merged

feat(sso): enterprise sso saml/oidc and scim provisioning (phase c.7)#61
axelhamil merged 29 commits into
devfrom
feat/sso-scim

Conversation

@axelhamil

Copy link
Copy Markdown
Owner

Phase C.7 — enterprise SSO (SAML/OIDC) and SCIM provisioning, built task-by-task with a review gate after each task, a whole-branch review, and two fix rounds.

What ships

  • Plugins mounted: @better-auth/sso + @better-auth/scim, with the observed route constants captured in one file so a library rename lands in a single place.
  • Schema: sso_provider / scim_provider, plus organization.sso_enforced. Migration 0016 is idempotent for tables that had drifted onto dev without one, so it applies cleanly to a db:push database and to a db:migrate chain alike.
  • 13 domain events through the transactional outbox, each with a live emit site and a correctly distinguished actor.
  • Registration hardening: business-tier gate on the request's target org, blocked under impersonation, SHA-256 and signed assertions forced, the whole SHA-1/MD5 family rejected in every spelling the plugin accepts.
  • Enforcement: a domain-based predicate applied to all four sign-in paths, keyed on the request rather than on user linkage.
  • Break-glass: a platform-admin route to lift enforcement on a locked-out org, and an org-owner route to turn it on.
  • Front: /settings/sso (provider, domain verification, SCIM connection, enforcement toggle), the sign-in SSO entry, and an SSO_REQUIRED redirect instead of a dead-end error.
  • Docs: docs/SSO-LOCAL.md for the local Keycloak round-trip, plus the closeout across ROADMAP/EVENTS/FEATURES/HISTORY/OVERVIEW/MODULES.

Defects caught and fixed during review

Worth reading before approving — several were live security holes, not style:

  • A business-tier gate that checked the wrong organization. It read the newest session row, so a user with a newer business-tier session could register SSO on a free-tier org. Now keyed on the request's target org.
  • A case-sensitive domain match. The plugin stores the domain verbatim from admin input; the predicate lowercased the email's domain. A provider registered as Acme.com never matched acme.com, so enforcement silently did nothing. Fixed on both the read and the two write paths.
  • SCIM bypassed the seat gate and the membership events. The plugin writes member rows with a raw adapter.create, skipping the org lifecycle hooks the seat cap and org.member.joined hang off, and /scim/generate-token had no tier gate — a free-tier org could provision past its cap with no audit row.
  • The passkey enforcement leg was disabled for exactly the users it protects. The guard skipped whenever the user owned an SSO account, so someone who signed in via SSO once and registered a passkey kept access after being deprovisioned at the IdP. Now discriminated on the request path.
  • Audit-trail forgery via a stranded snapshot. A 404-ing SCIM DELETE left an entry in a global map keyed on a bare user id, which then attributed an unrelated kick in another org to the SCIM connection owner. Reproduced live against the pre-fix build, then shown closed.

Verification

pnpm ci:check green (Biome 794 files, knip, jscpd 27 clones at baseline, type-check 12/12), 715 API + 147 app tests, check:a11y 21/21. Twelve of the thirteen event types were confirmed with real outbox_event rows.

Declared gaps (verified by code inspection, not exercised live): sso.domain.verified needs DNS TXT control; no real WebAuthn ceremony was driven, so the passkey leg was verified through the shared session hook instead; the SSO-callback success path after the final enforcement change rests on construction plus a probe rather than a Keycloak round-trip.

Known follow-ups, recorded rather than hidden: the enforcement query has no automated coverage (the repo has no real-DB test harness, and a mock-based test here would pass regardless of the WHERE clause); the a11y suite is coupled to an IP-keyed rate limit; SAML single-logout is declared but unwired; there is no operator runbook for the lockout-recovery path.

https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY

Migration 0016 unconditionally created api_token/notification/notification_preference,
which already exist with no journal row on any dev database that ran `pnpm db:push`
after those schema files landed on dev. Guard those three tables' CREATE TABLE / CREATE
INDEX / ADD CONSTRAINT with existence checks so the migration applies cleanly on both a
fresh database and a drifted one. sso_provider/scim_provider stay unguarded — they are
new in every starting state and a genuine collision there must still fail loudly.

Also trims the sso-paths.ts header to what a future reader needs and fixes a
self-contradicting sentence in the task-1 report.
…sion history

activeOrganizationIdFor picked the user's most-recently-created session row
regardless of which org the /sso/register request body actually named, and
without an expiry filter — a stale or unrelated business-tier session could
approve registration on a different, lower-tier org (gate bypass).

providersLimit only ever receives `user` (verified in the plugin's dist), so
it cannot see the target org and is no longer where the tier check lives; it
stays a flat per-user ceiling. The business-tier gate moves to hooks.before
on "/sso/register", reading body.organizationId directly.
…ration

SAML config is customer-supplied at registration; without normalisation
at the door an org can weaken its own signing posture and re-open the
XML-signature-wrapping surface behind the 2025-2026 SAML CVEs. Weak
flags are forced to the safe value; a weak algorithm is rejected
outright rather than silently upgraded, since an IdP configured for
SHA-1 would fail every assertion after a silent upgrade and the
operator would debug the wrong end of the connection.

Also unifies the two hooks.before branches on /sso/register onto the
SSO_PATHS.register constant (R18) and runs normalisation after the
entitlement gate: a request refused for lack of entitlement need not
have its body rewritten first.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…re string

The exact-match array only caught signatureAlgorithm/digestAlgorithm
values equal to "sha1" or "md5". @better-auth/sso accepts the same
weak algorithm as "rsa-sha1", "ecdsa-sha1" and full xmldsig URIs
(http://www.w3.org/2000/09/xmldsig#rsa-sha1), all of which sailed
through the exact match into Result.ok — the guard read as protection
it did not provide. Switch to a case-insensitive substring match on
the family name; no accepted strong algorithm (sha256/sha384/sha512
and their rsa-/ecdsa-/URI forms) contains "sha1" or "md5" as a
substring, so the match is sound without enumerating every prefix.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…bridge

Wires sso.provider.{registered,updated,deleted}, sso.domain.verified, and
sso.login.{success,failure} into the existing hooks.after/before bridge in
auth.ts. ctx.path in hooks.after is the endpoint's registered route pattern,
not the resolved URL (verified against better-auth's dispatchAuthEndpoint
source and live), so providerId is read from ctx.params instead of parsing
the path. Both SAML callback and ACS endpoints delegate to the same session
-creating code path and are handled identically. sso.login.success is
verified to fire on both the JIT-provisioning and returning-user paths via a
live Keycloak OIDC round-trip; sso.login.failure is verified via a real
domain-not-verified rejection and an unknown-SAML-provider rejection.

sso.enforcement.changed (the SSO_ENFORCEMENT_CHANGED catalog entry) is out of
scope here — Task 10 owns its emit site.
This file opens on "All SOTA 2026". Measured on 2026-08-17, it no longer is:
TypeScript is a major behind, mise.toml pins pnpm 10.33.2 while packageManager
declares 11.0.9 — whichever wins is accidental — and E.1 is about to hand-wire
a locale layout across 32 code-based route files, in the shape TanStack now
recommends generating instead.

G.1a runtime and package manager, G.1b TypeScript 7, G.1c Postgres 18,
G.1d dependency floor, G.1e file-based routing sequenced before E.1.

Two findings shape the scope. Every primary key in the schema is text() filled
by BetterAuth, so Postgres 18's native uuidv7 applies to new application tables
only — converting the existing ones is a BetterAuth question and has no place
in a version bump. And most dependencies are declared with a caret, so what is
stale is the lockfile, not the ranges: only typescript, @hono/zod-validator
(a caret does not cross a 0.x minor) and @types/pg need an edit.

Versions read from the npm registry, nodejs.org and Docker Hub; re-measure
before executing.

Claude-Session: https://claude.ai/code/session_01CPKJJ6jFtLQGSKYCHJauNQ
…ion owner as actor

Wires the SCIM half of the BetterAuth event bridge (hooks.after) for all
six scim.* event types, mirroring Task 6's SSO bridge. The provisioned
user is the event subject; the SCIM connection owner (resolved via a new
scimConnectionOwner query) is the actor, per rule #7.

Also widens the /api/auth/* verb list (GET/POST only until now) to
include PUT/PATCH/DELETE — SCIM 2.0 requires them on /Users/:userId, and
without this change three of the six event types could never fire in
this deployment (a routing-produced rule #6 orphan, not a missing emit
site). Confirmed BetterAuth's own router still 404s any unregistered
method/path combo after the change, and the full auth test suite is
unaffected.

Deviates from the task brief where ctx.context.returned is undefined for
PATCH/DELETE SCIM responses (204, no body) — the subject id falls back
to ctx.params.userId there instead of assuming a returned shape that
never exists for those two verbs.
Pure domainOf/isSsoEnforcedFor pair, testable without a database via
an injected lookup, plus the real Drizzle lookup in auth-queries.ts
that jointly requires domain match, domain_verified, and
organization.sso_enforced before treating a domain as enforced.
@better-auth/sso persists ssoProvider.domain verbatim (no casing
normalization on register/update), while domainOf() always lowercases
the email's domain before the enforcement lookup. A provider
registered with a mixed-case domain (e.g. "Acme.com") therefore never
matched, silently letting enterprise users bypass SSO enforcement via
password/magic-link/passkey.

- Write side: lowercase body.domain in the existing /sso/register
  hooks.before handler, so newly registered providers store the
  canonical form.
- Read side: compare via eq(sql`lower(${ssoProvider.domain})`, domain)
  instead of a bare eq(), so existing/legacy rows with mixed-case
  domains still match. Uses a typed sql fragment (column reference
  stays ${ssoProvider.domain}), not ilike, since ilike's wildcard
  semantics on "%"/"_" would turn this into a false-positive risk
  instead of a bypass.

Verified against a real Postgres instance: a provider stored with
domain "Acme.com" now resolves for a query domain of "acme.com".

No index exists today on ssoProvider.domain (only providerId is
indexed), so this is not a performance regression; a future index to
support this comparison would need to be a functional index on
lower(domain) rather than a plain btree on domain.
/sso/update-provider persists `domain` verbatim on its own update
statement (same as /sso/register), so leaving it un-normalized would
have made the read-side lower() the sole guard against a mixed-case
domain reaching the enforcement query for any provider edited after
registration.

Confirmed against the plugin's route list that /sso/register and
/sso/update-provider are the only two endpoints that write
ssoProvider.domain — every other "model: ssoProvider" site is a read
(providers list, get-provider, delete-provider snapshot, domain
verification, callback/sign-in lookups).
… paths

Blocks password sign-in, sign-up, and magic-link at hooks.before, and
passkey (no email in body) at databaseHooks.session.create.before, for
any org whose verified domain has SSO enforced.

Deviates from the task brief on two points, both known defects fixed
before implementation: `primaryAccountFor`/`emailFor` did not exist
anywhere (reused the existing findLatestLinkedAccount instead of
duplicating it, added only emailFor); and the brief's `providerId !==
"sso"/"oidc"` check is wrong (the SSO plugin sets account.providerId to
the registered provider's own id, never a fixed string) and would have
locked every enforced org's SSO login out of its own session creation —
replaced with a lookup against the registered ssoProvider table via the
existing findSsoProviderByProviderId.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Adds the break-glass path Task 9's enforcement needed: a platform-admin
route to lift a locked-out org's SSO requirement, and (ruling R2) the
org-owner route to turn enforcement on in the first place. Both share
one AdminActionService.setSsoEnforcement method (promoted to
shared/services since it now has two module-boundary consumers),
writing organization.sso_enforced and emitting
EventTypes.SSO_ENFORCEMENT_CHANGED in the same transaction, with the
triggering actor threaded explicitly from c.get("user").id.

Also fixes a pre-existing bun test mock-leak: several test files
stubbed multiTenantSchema without organization.id, which broke once a
service started reading it at runtime.
Builds the /settings/sso page: identity provider registration (OIDC/SAML
tabs), domain verification with a copyable TXT record, SCIM token
generation via the shared secret-reveal dialog, and an org owner/admin
SSO enforcement toggle wired to the Task 10 API route. Gated on the
business-tier "sso" entitlement with an upsell fallback, and on
organization:update at the route level since registration/SCIM require
org owner or admin per the better-auth sso/scim plugins.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…ing a second one

authClient.sso.providers() has no orderBy server-side, so "the org's
provider" could silently resolve to a different row across
reloads/refetches once an org had more than one. Sort the list by
providerId (the only stable field the endpoint returns) once in
ssoProvidersQueryOptions, and route every card through a single
primaryProviderFor() resolver instead of each re-filtering the list.

The provider card also no longer offers to register a second
provider once one exists — that was never scoped, and a silently
non-deterministic second provider was worse than no control. Also
documents the intentional providerId slug collision left to the
server's uniqueness check.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Adds a "Sign in with SSO" control on /sign-in (collapsible email step,
authClient.signIn.sso) and redirects a password sign-in straight into the
SSO flow on an SSO_REQUIRED rejection, using the providerId the server
already returns instead of re-deriving it from the email.

Task 12's docker-compose Keycloak service was already shipped in Task 1
(R11) and is untouched here. Closes out phase C.7: ROADMAP/FEATURES/EVENTS/
HISTORY updated with the real event counts (67->80 / 28->34 public /
39->46 internal, recomputed from packages/events/src/event-types.ts and
visibility-map.ts) and the two stale claims corrected (no sso
access-control statement exists; SCIM DELETE is an org departure, not a
grace-period deletion). docs/SSO-LOCAL.md documents the local Keycloak
round-trip, including two traps found only by running it live.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…age, and a11y flake

- include providerId on the passkey-leg 403 SSO_REQUIRED body, matching the
  other three enforced sign-in paths; verified live via the shared
  databaseHooks.session.create.before hook
- extract redirectToSsoIfRequired into auth-error.ts and reuse it from
  use-sign-up and use-magic-link, which previously dead-ended on a raw
  SSO_REQUIRED toast
- pin playwright workers to 4 in the a11y suite: the API's per-user global
  rate limit was tripped by parallel workers sharing one seeded identity,
  intermittently failing the sso settings page under load
- extend the sign-in keyboard tab-order test to cover the sso trigger and
  the email field it reveals
- correct OVERVIEW.md and MODULES.md, which still described sso/scim as
  an unshipped roadmap item
…quest

SCIM provisioning went through neither of the two authoritative seat gates:
`@better-auth/scim` writes the member row with a raw `adapter.create`, so
`beforeAddMember`/`afterAddMember` never fire. Combined with
`/scim/generate-token` carrying no tier gate at all, a Free-tier owner could
mint a token and provision past `maxMembers` with no billing enforcement, no
audit row and no webhook delivery. The token endpoint now goes through
`assertSsoEntitlementFor` — extracted on this second occurrence and shared with
`/sso/register` — `POST /scim/v2/Users` is capped before the endpoint runs, and
`org.member.joined` is emitted from the after-hook with the provisioned user as
subject and the connection owner as actor. Removal already reached
`afterRemoveMember`, so only its actor is corrected; a second emit would have
duplicated audit rows and webhook deliveries.

The passkey enforcement leg was disabled for exactly the users it protects:
keyed on whether the user owns an SSO-linked account, it waved through every
later sign-in of anyone who had ever used the IdP — passkey included — which
is the deprovisioning guarantee enforcement is sold on. The discriminator is
now the request: BetterAuth passes the endpoint context to
`session.create.before`, so only the four SSO callback paths (plus admin
impersonation, where the actor is a platform admin, not the enforced user) skip
the guard.

`sso.login.failure` is public but was emitted with no `organizationId`, which
`WebhookFanoutSubscriber` drops before the visibility check — the one signal
telling a customer their IdP broke reached nobody. The provider row loaded one
line above already carries it.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
`redirectToSsoIfRequired` covered the three email-bearing hooks but not the two
passkey ones, while the docs claimed all four paths redirect. The explicit
passkey button surfaced a bare `SSO_REQUIRED` toast and autofill returned
silently, so the one leg a user reaches without typing an email was the one
with no way forward. The autofill leg redirects without a toast: conditional UI
must stay quiet on expected failures, but an enforced domain is not one.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
`check:a11y` failed reproducibly and the rationale shipped with it named the
wrong bucket. Measured: the bucket that overflows is the IP-keyed one (61
against a 60/min ceiling, twice in a row) while the per-user bucket sat at 39 —
`sessionMiddleware` nulls the user for `/api/auth/*`, so a signed-in page's
session and organization queries are counted against the IP next to every
unauthenticated page load. Worker count bounds neither: the ceiling is per
minute across the whole run and the IP is identical from every worker.

The real defect is the tuning. A signed-in page view fires up to 8 API calls,
so a 60/min burst window allowed about seven navigations per minute before a
legitimate user got a 429. The minute window becomes the burst window (300) and
the hour window (1800) stays the sustained anti-abuse ceiling; every credential
path keeps its own far tighter fail-closed policy. Gate green three consecutive
runs, 21/21.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
Corrects the two mechanism descriptions the code no longer matched (the
enforcement guard keys on the request, not on the user's account linkage; the
scim entitlement gate now covers token generation) and records what this round
left open: the seat cap is a pre-endpoint count rather than a reservation, the
organization plugin misattributes the actor on every non-scim member hook, and
`/api/auth/*` is rate-limited per IP even for signed-in users.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
…ted kicks

scimDeprovisionActors was a global Map keyed on a bare userId, written
unconditionally in hooks.before and consumed only by afterRemoveMember —
a hook that never fires when the SCIM DELETE 404s or the user holds no
member row. A stranded entry then poisoned any later removal of that
user, by any admin, in any org, naming the SCIM connection owner as the
actor of an unrelated kick (audit-trail forgery on org.member.removed).

Introduce RequestSnapshots<T>, a small keyed store with a freshness TTL
and a consumer-supplied accepts() match, and use it for all three
before/after snapshot maps in auth.ts (SCIM deprovision actor, SSO
provider delete, SCIM connection delete). scimDeprovisionActors now
carries {actorUserId, organizationId} and the consumer requires an
organization match before trusting it; a non-matching entry is left in
place rather than deleted, since it may belong to a request still in
flight.

Reproduced live: a bearer-token DELETE against an unrelated user (404
"user not found") plants an entry; kicking that same user from a
different org through the ordinary remove-member flow previously
attributed the kick to the SCIM connection owner. Post-fix, the same
sequence falls back to the pre-existing (documented, unrelated) actor
gap instead of the forged identity.

Also: rewrite the seat-cap docblock, which claimed the SCIM path reused
assertSeatAvailableFor when it actually inlined the check because that
helper throws AppErrorException and SCIM needs an APIError. Extract
seatCapFor() as the shared predicate both callers now call, with the
comment stating the real reason for the two refusal shapes. And give
the SCIM seat-cap rejection (and future SCIM-thrown errors) an RFC 7644
error body via a small scimError() helper, since Okta/Entra expect
urn:ietf:params:scim:api:messages:2.0:Error rather than a generic
provider error.
/api/auth/send-verification-email is a public POST taking an arbitrary
email with no session, the same "make the server email a stranger"
primitive as request-password-reset and sign-in/magic-link — but it had
no dedicated policy, so it only inherited the burst-tuned global window
(300/min) after that window was widened from 60 to 300 for legitimate
navigation traffic. Give it the same fail-closed 3/15min policy as its
siblings, mounted in the same guard block ahead of the global rate
limiter.
Line 830 stated the passkey leg of SSO enforcement was keyed off
whether the session's linked account already belongs to a registered
SSO provider — an SSO-linked account is permanent, so that would wave
through every later passkey sign-in for a deprovisioned user. Fix round
2 item 2 replaced that with a request-path check. Correct the entry
inline as a "note, superseded" rather than silently rewriting it, in
the same spirit as the existing round-1 flake correction further down
this file.
…tration

/sso/register forced sha256 and signed assertions; /sso/update-provider
normalized only the domain. The plugin merges samlConfig as
`updates.X ?? current.X`, validates algorithms only when they appear in the
update body, and defaults to onDeprecated: "warn" since no saml.algorithms
is set at mount — so an org admin could register a compliant provider and
then PATCH { wantAssertionsSigned: false, signatureAlgorithm: "sha1" }.
samlify takes wantMessageSigned from that flag, so assertions stopped
needing a signature at all.

normalizeSamlConfig is already partial-update-safe: identity fields pass
through only when sent, security fields are always forced strong.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
The SCIM branches in the global hooks.before resolved the token owner by
base64-decoding the Authorization header with no check against the stored
hash, and runBeforeHooks executes before the endpoint's own bearer auth.
providerId is a deterministic slug of the org domain, so any member could
guess it: a forged token returned 402 with maxMembers when the org was full
versus 401 when it was not (a pre-auth cross-tenant billing oracle), and a
404-ing DELETE still planted a scimDeprovisionActors entry, letting a
same-org kick within the TTL be attributed to the SCIM connection owner.
R36's org-match guard closed only the cross-org case.

verifiedScimConnectionOwner hash-verifies the token before the owner is
resolved. The seat cap still runs for verified requests. Adds a dedicated
fail-closed rate-limit policy for /scim/*, which had none.

Claude-Session: https://claude.ai/code/session_01GHPzZiA3cHT9XBdoZrjMmY
@axelhamil
axelhamil merged commit f9ffc2c into dev Aug 28, 2026
1 check passed
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.24.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant