Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scim-transaction-scope-at-request-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': patch
---

SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.
77 changes: 60 additions & 17 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -923,6 +923,22 @@ export function ipMatchesRange(ip: string, range: string): boolean {
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* [#14522] The better-auth endpoint path prefix every SCIM 2.0 protocol
* endpoint lives under (`/scim/v2/Users`, `/scim/v2/Groups/:groupId`, …) —
* the same predicate `@better-auth/scim` uses for its own after-hook matcher
* (`context.path?.startsWith("/scim/v2")`). A request under it runs inside
* `scimRequestScope`; see `handleRequest`.
*/
const SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2';

function isScimProtocolPath(endpointPath: string | undefined): boolean {
return (
endpointPath === SCIM_PROTOCOL_PATH_PREFIX ||
endpointPath?.startsWith(`${SCIM_PROTOCOL_PATH_PREFIX}/`) === true
);
}

/**
* #6039 — is this `SendSmsResult.error` the quota wall's refusal?
*
Expand DownExpand Up@@ -3266,17 +3282,22 @@ export class AuthManager {
if (enabled.scim) {
await this.addOptionalPlugin(plugins, 'scim', async () => {
const { scim } = await import('@better-auth/scim');
const { verifyScimBearerToken, scimRequestScope } = await import('./scim-connection-service.js');
const { verifyScimBearerToken } = await import('./scim-connection-service.js');
const secret = this.resolveAuthSecret();
return scim({
connections: [],
authentication: {
verifyBearerToken: async (input) => {
// Mark the remainder of this request's async chain as a SCIM
// protocol request, so the adapter runs its provisioning writes
// inside a REAL engine transaction (see scimRequestScope's
// rationale in scim-connection-service.ts).
scimRequestScope.enterWith({ scim: true });
// ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request
// scope that makes the adapter open a REAL engine transaction is
// opened by `handleRequest` with `run(...)` around the whole
// request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be
// stamped from this callback and never reached the writes: an
// `enterWith` marks only the async resource it runs in and that
// resource's descendants, and the vendor resumes the endpoint
// handler from a continuation captured BEFORE this verifier ran
// (measured on 1.7.2 — zero engine transactions across a SCIM
// POST + PATCH; pinned by `scim-transaction-scope.test.ts`).
const engine = this.config.dataEngine;
if (!engine) return null; // no store to verify against — fail closed
return verifyScimBearerToken(engine as never, secret, input.token);
Expand DownExpand Up@@ -4732,10 +4753,32 @@ export class AuthManager {
// is left with an identity that still occupies the org roster and can no
// longer sign in. Nothing tells the operator, and there is no way back.
const endpointPath = this.betterAuthEndpointPath(request);

// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
// `scimRequestScope`, which is what makes the adapter's `transaction`
// config open a REAL engine transaction around the vendor's provisioning
// multi-writes (`objectql-adapter.ts`, the scoping note there). Opened
// HERE, with `run(...)` around the whole request, for the same reason the
// actor-attribution scope above is: `run` has a callback boundary that
// every `als.run` the vendor performs underneath nests inside. The stamp
// used to be an `enterWith` inside the SCIM plugin's `verifyBearerToken`
// callback, and it never reached the writes — the vendor resumes the
// endpoint handler from a continuation captured before the verifier ran
// (measured on 1.7.2: zero `engine.transaction` calls across
// `POST /Users` + `PATCH /Users/{id}`). Keyed on the endpoint path prefix
// so it is exactly as narrow as before — SCIM protocol requests only; the
// non-SCIM flows keep their sequential posture, which the scoping note
// records as load-bearing. Pinned by `scim-transaction-scope.test.ts`.
const runRequest = isScimProtocolPath(endpointPath)
? async (): Promise<Response> => {
const { scimRequestScope } = await import('./scim-connection-service.js');
return scimRequestScope.run({ scim: true }, runHandler);
}
: runHandler;
const vendorResponse =
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
? await this.runSubjectErasureAtomically(runHandler)
: await runHandler();
? await this.runSubjectErasureAtomically(runRequest)
: await runRequest();

// [#10349] The better-auth-native `/admin/` routes refuse an anonymous
// caller through the vendor's `adminMiddleware`
Expand DownExpand Up@@ -4917,15 +4960,15 @@ export class AuthManager {
* so it never half-lands: the account stays enabled and nothing is
* skipped silently.
*
* ⚠️ What does NOT roll back today: the vendor runs this callback inside
* What ALSO rolls back: the vendor runs this callback inside
* `runWithTransaction`, which on this adapter is a real engine transaction
* only while `scimRequestScope` is set — and that scope, stamped inside
* `verifyBearerToken`, is not observed at write time on 1.7.2 (measured:
* zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So
* while `scimRequestScope` is set — and `handleRequest` opens that scope
* around every SCIM protocol request (#14522; it was once stamped inside
* `verifyBearerToken` with `enterWith` and never reached the writes). So
* the vendor's own `scimUser.active = false` write, made before this
* callback, survives a refusal and the SCIM resource reads inactive while
* the account is enabled. #14522 owns that seam; the #14360 suite pins the
* residual so its fix flips the pin deliberately.
* callback, is rolled back with the refusal, and the SCIM resource keeps
* reading `active: true` for the account that stayed enabled — pinned by
* the #14360 suite's face (c).
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
Expand All@@ -4938,8 +4981,8 @@ export class AuthManager {
*
* Every read and write goes through `context.database` — the adapter the
* vendor bound to its transaction — never through an `internalAdapter`
* resolved outside it, so the moment #14522 makes that transaction real,
* the ban commits or rolls back with the SCIM mutation it belongs to.
* resolved outside it, so the ban commits or rolls back with the SCIM
* mutation it belongs to.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -791,8 +791,16 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// Core better-auth flows never had native DB transactions here (the factory
// default is the sequential as-is fallback), so they KEEP that historical
// posture; the real transaction opens exactly where upstream's assertion
// demands it — inside an authenticated SCIM protocol request, marked by the
// auth manager's `verifyBearerToken` via `scimRequestScope`. Remaining
// demands it — inside a SCIM protocol request (`/scim/v2/*`), the scope
// `AuthManager.handleRequest` opens with `scimRequestScope.run(...)` around
// the whole request. ⚠️ It was once stamped with `enterWith` inside the
// `verifyBearerToken` callback and never reached this seam: an `enterWith`
// marks only the async resource it runs in and that resource's descendants,
// and the vendor resumes the endpoint handler from a continuation captured
// before the verifier ran — measured on 1.7.2 as zero engine transactions
// across POST + PATCH /Users while the mount-time assertion stayed green.
// The scope is therefore pinned at RUN time (`scim-transaction-scope.test.ts`:
// a SCIM mutation observed to call `engine.transaction`). Remaining
// declared degrades on that path: an engine with no `transaction` API runs
// the callback directly, and a driver without `beginTransaction` follows
// the engine's ADR-0119 D1 warn-once degrade.
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-auth/src/scim-connection-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,10 +42,21 @@ import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Request-scoped marker: "the current async chain is a SCIM protocol
* request". Entered by the auth manager's `verifyBearerToken` wrapper (the
* first application code every authenticated SCIM request runs) via
* `enterWith`, so it holds for the remainder of that request's async chain —
* including the provisioning writes the plugin performs afterwards.
* request". Opened by `AuthManager.handleRequest` with `run(...)` around every
* request whose better-auth endpoint path is under `/scim/v2`, so it holds
* for that request's whole async chain — the endpoint handler and the
* provisioning writes the plugin performs inside it.
*
* ⛔ Not `enterWith`, and not from inside the `verifyBearerToken` callback:
* that is where it used to be stamped, and the store never reached the
* writes. An `enterWith` marks only the async resource it runs in and that
* resource's descendants; the vendor awaits the verifier from the endpoint's
* own frame and resumes the handler from a continuation captured before the
* verifier ran. Measured on `@better-auth/scim` 1.7.2: zero
* `engine.transaction` calls across `POST /Users` + `PATCH /Users/{id}`,
* `inScimRequestScope()` false inside every identity write. `run(...)` has a
* callback boundary; every `als.run` the vendor performs underneath nests
* inside it. Pinned at run time by `scim-transaction-scope.test.ts`.
*
* Read by `objectql-adapter.ts`'s `config.transaction`: SCIM requests get a
* REAL engine transaction (the atomicity upstream's
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -475,16 +475,17 @@ describe('[#14360] deactivating the last administrator is refused through SCIM,
expect(row?.ban_reason ?? null).toBeNull();
await expectSignInAccepted(h, owner.email);

// RESIDUAL — pinned as observed, filed as #14522, ⛔ not this card's to
// fix: the vendor's own `scimUser.active = false` write, made BEFORE the
// callback inside what it believes is a transaction, survives the
// refusal, because the adapter's #3653 SCIM transaction scoping never
// opens an engine transaction on 1.7.2 (measured: 0 `engine.transaction`
// and 0 `driver.beginTransaction` calls across POST + PATCH /Users). So
// the SCIM resource reports `active: false` while the account is still
// enabled. When #14522 lands, this line flips to `true` DELIBERATELY —
// that is the whole reason it is asserted rather than left unread.
expect(await scimActive(h, owner.scimId)).toBe(false);
// [#14522] The vendor's own `scimUser.active = false` write, made BEFORE
// the callback inside its transaction, is rolled back WITH the refusal:
// the adapter's #3653 SCIM transaction scoping opens a real engine
// transaction now that the scope is opened at `handleRequest` (it was
// stamped with `enterWith` inside `verifyBearerToken` and never reached
// the writes — measured as 0 `engine.transaction` calls across POST +
// PATCH /Users). So the SCIM resource keeps reporting `active: true` for
// the account that stayed enabled. This line read `false` on purpose
// while that residual was open and was flipped DELIBERATELY with the fix;
// the positive control below is the genuine `false`.
expect(await scimActive(h, owner.scimId)).toBe(true);
}, 60_000);

it('(c) positive control: with a second administrator left behind, the same request succeeds', async () => {
Expand Down
Loading
Loading