Skip to content

feat(sso)!: move to better-auth 1.7 with managed scim connections - #71

Merged
axelhamil merged 7 commits into
devfrom
chore/deps-latest
Sep 30, 2026
Merged

axelhamil merged 7 commits into
devfrom
chore/deps-latest

Conversation

@axelhamil

Copy link
Copy Markdown
Owner

Brings every dependency that can move to its latest release, including the better-auth 1.6.30 to 1.7.6 jump that had been held back by the SCIM config change.

Breaking. SCIM moves to one managed connection per organization, created and rotated by the owner from the SSO settings. Tokens are stored as an HMAC under the new SCIM_CREDENTIAL_HASH_SECRET (32+ chars, required in production), so every directory provisioned before must be reconnected with a newly issued token. The commit carries a BREAKING CHANGE: footer; semantic-release --dry-run computes a major.

What changed with 1.7. Owner-only scim:manage permission and three routes under /settings/organization/scim-connection (issuing requires the sso plan, reading and disconnecting stay open after a downgrade). Directories of an org without sso can no longer create users or members. SCIM creates accounts only inside the org's verified SSO domains (SCIM_DOMAIN_NOT_VERIFIED otherwise) and links an existing account only when its email and domain are verified. Seat caps and connection writes are serialized per org with an advisory lock. The Drizzle adapter runs in transactions, and the auth hooks (personal org self-heal, event emits) now join the plugin's transaction, which fixes a first SSO sign-in that would have failed on the member foreign key. Migrations 0022 and 0023 only add tables. The Stripe plugin also gets the subscription table it was missing, so its writes no longer fail.

Still pinned, verified. @simplewebauthn/server 13 (@better-auth/passkey 1.7.6 requires ^13, 14 breaks the type-check), @better-auth/utils 0.4.2 (the exact version better-auth 1.7.6 ships), conventional-changelog-conventionalcommits 9 (10 still breaks note generation with semantic-release 25's writer).

Checks. Pre-push green (920 api tests, also in random order). Three new real-database checks, each failing before its fix: check:auth-transaction, check:scim-seats, check:scim-connection. Exercised live against a local API: sign-up, sign-in, TOTP, passkey options, the full SCIM user cycle, the seat cap, domain refusal, rotation, downgrade. Not covered: a browser round trip against a real IdP.

Merge commit, not squash.

hono moves to 4.13.11 in both the api and the app, and the S3 client and
presigner move to 3.1142.0. Patch releases only, no code change needed.
The Stripe plugin writes its `subscription` rows through BetterAuth's
Drizzle adapter, which resolves every model from the schema object `db` is
built with. The billing tables were exported by `@packages/drizzle` but
never added to that object, so any subscription write coming from the
plugin (checkout completion, webhook updates) would have failed on a
missing model. better-auth 1.7 checks this at boot and reported it.

The billing schema now joins the adapter schema, with a comment saying why
a plugin-owned table has to be listed there.
Moves the whole better-auth family from 1.6.30 to 1.7.6. The pin existed
because `@better-auth/scim` 1.7 is a rewrite: per-provider bearer tokens,
`scim_provider` and the generate-token/delete-connection endpoints are gone,
replaced by managed connections the application drives through server
APIs, with identity and membership left to application callbacks.

Each organization now has at most one connection, its provisioning domain
being the organization id. `ScimConnectionService` and
`GET/POST/DELETE /settings/organization/scim-connection` read its status,
issue or rotate its token (rotation revokes every other credential) and
decommission it, gated by a new owner-only `scim:["manage"]` statement and
the `sso` entitlement. The settings card shows the expiry and offers rotate
and disconnect. Membership is a projection the plugin runs inside its own
transaction: an active user gets a member row if a seat is free (otherwise
a SCIM 402 that rolls the provisioning back), an inactive one loses it, an
owner is never removed. An existing account is linked only when its email
is verified on a verified SSO domain of the organization. The after-hook
reports every SCIM user change and the membership it caused, with the
connection creator as actor, and `scim.connection.token_rotated` joins the
catalog.

The plugin requires native transactions, so the Drizzle adapter runs with
`transaction: true`, and it stores tokens under a new
`SCIM_CREDENTIAL_HASH_SECRET`, required in production. Migrations 0022 and
0023 drop the legacy table and create the ten plugin tables with short
explicit foreign key names. Connections issued before this change stop
working: directories have to be reconnected with a new token.

The rest of 1.7 is adapted too: SAML registration sends the SP entity ID
as `issuer` and the IdP's as `idpMetadata.entityID` (the form field is
renamed), two-factor enable narrows its new result union, the removed
`scimClient` is dropped from the front, and the vitest peer exception that
only 1.6 needed is gone. Verified live against Postgres: sign-up, sign-in,
TOTP, passkey options, the full SCIM user lifecycle with its events, the
seat cap, rotation, disconnect and SAML registration.
The SCIM debt recorded at G.1d is closed in both the history and the
roadmap, and a new history entry keeps the non-obvious parts of the 1.7
migration (managed connections, transactional adapter, the new secret, the
SAML field split, the latent Stripe schema gap it surfaced) with what was
verified live and what was not.

It also states why three packages stay behind `latest`, each measured:
`@better-auth/utils` 0.4.2 is the exact version better-auth 1.7.6 ships,
`@simplewebauthn/server` 13 is the major passkey 1.7.6 depends on (14 brings
TS2883 back), and `conventional-changelog-conventionalcommits` 10 cannot
render notes with the writer 8 that semantic-release 25's stable plugins
still bundle.
Issuing a directory token lists the organization's connection, then
creates or rotates it, then revokes every other credential, across several
plugin transactions. Four concurrent first issues created four connections,
and concurrent rotations could each revoke the credential the other had
just issued. Writes for one organization now run behind an advisory lock
held for their whole duration.

The events are emitted after the plugin committed, on a savepoint. A failed
emit is reported to telemetry and the token is still returned: it is the
only copy of the credential that now works, and failing the request would
leave the owner with a revoked token and no replacement.

Only issuing a token requires the `sso` entitlement. Reading the status and
disconnecting stay available after a downgrade, so an owner can always cut
off a directory.

Proven by `check:scim-connection` against Postgres (four connections
before, one after) and by route and service tests.
With native transactions on, the SSO plugin creates the user and its
session in one transaction, and `session.create.before` bootstraps the
user's Personal organization. That bootstrap ran through `db`, on another
connection, where the user does not exist yet: the `member` insert failed
its foreign key and the whole sign-in with it. The adapter now receives
`db` through `withPublishedTransactions`, which publishes every transaction
it opens, and the in-hook writes (`ensurePersonalOrgFor`, the event bridge,
the pending-email and empty-org writes) join it, committing or rolling back
with the change that caused them.

The same mechanism closes the SCIM seat race: five concurrent provisionings
on a 3-seat plan ended with 6 members, because the count ran on another
connection without a lock. The count now runs on the plugin transaction
behind a per-organization advisory lock.

SCIM provisioning also stops trusting the directory with addresses the
organization does not control: an email outside its verified SSO domains is
refused with a SCIM 403 `SCIM_DOMAIN_NOT_VERIFIED`, instead of creating an
account that reserves someone else's address. Once the plan loses `sso`,
new users and members are refused with `SCIM_PLAN_REQUIRED`, while
deactivations and removals still apply.

Proven against Postgres by `check:auth-transaction` and `check:scim-seats`,
both failing before this change and wired into CI next to the existing
checks together with `check:scim-connection`. `@better-auth/core` joins the
dev dependencies, pinned to the version better-auth ships, for the
transaction helper these checks drive.
Marks the move to `@better-auth/scim` managed connections as the breaking
change it is for any deployment that provisions through SCIM, and records
the review fixes in the feature inventory and the history.

BREAKING CHANGE: SCIM tokens issued before the move to managed connections
no longer authenticate, since the legacy `scim_provider` table is dropped.
Every organization that provisions through SCIM must issue a new token from
the SSO settings page and paste it into its identity provider. The API also
needs a new `SCIM_CREDENTIAL_HASH_SECRET` (at least 32 characters, required
in production), the HMAC key directory tokens are stored under; rotating it
invalidates every issued token.
@axelhamil
axelhamil merged commit 06add15 into dev Sep 30, 2026
1 check passed
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