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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
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-active-false-reconcile-user.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/plugin-auth": patch
---

SCIM `active: false` disables the account again. Stable `@better-auth/scim` (1.7.0+) no longer writes the admin plugin's `banned` column itself — it hands the aggregate lifecycle state to an optional host callback, `identity.reconcileUser`, and only revokes sessions. `plugin-auth` passed no `identity` member, so an identity provider deactivating a user revoked sessions and wrote nothing: `sys_user.banned` stayed false and a user holding a local password signed straight back in. `AuthManager` now implements the callback and routes it to the platform's own ban write: `active: false` bans the user (reason `Deactivated via SCIM`, no expiry) — and makes an administrator's existing EXPIRING ban permanent (`banExpires` cleared, the administrator's reason kept), because the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the identity provider still holds deactivated — and the vendor's `BANNED_USER` sign-in refusal applies; `POST /Users` with `active: false` provisions the account disabled. `active: true` lifts a ban that carries that reason — an administrator's ban (any other reason) is not the identity provider's to lift, so an attribute sync never re-admits a user banned for cause; the one documented collision is an administrator who types the reason `Deactivated via SCIM` themselves, which produces a ban the identity provider can lift. The last-LOCAL-credential guard the `/admin/ban-user` mount re-runs is deliberately not applied on the SCIM path: an identity-provider deprovision can disable the last password-holding account while non-administrator SSO users remain. The break-glass last-administrator guard (ADR-0024 D5.2) judges the write at the engine, so deactivating the last administrator through SCIM is refused with a 403 SCIM error and the account stays active. A SCIM `DELETE /Users/{id}` — which on 1.7.2 tombstones the source rather than deleting the user — now leaves that account disabled too. No new public symbol: the shared write lives in a package-internal module.
16 changes: 6 additions & 10 deletions packages/plugins/plugin-auth/src/admin-ban-endpoints.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,9 @@
* The writes mirror better-auth's own handlers field for field — `banned` /
* `banReason` / `banExpires` / `updatedAt`, then `deleteUserSessions` — so a
* banned user is signed out and refused at sign-in by the vendor's OWN session
* hook (`BANNED_USER`), which is untouched. The default ban reason is
* hook (`BANNED_USER`), which is untouched. The write itself lives in the
* package-internal `user-ban-write.ts` (#14360), shared with the SCIM
* deprovisioning hook in `auth-manager.ts` — one write, two callers. The default ban reason is
* `'No reason'` because ObjectStack configures no `defaultBanReason`.
*
* ⚠️ Shadowing a vendor route detaches every better-auth hook keyed on its
Expand All@@ -72,6 +74,7 @@ import {
type CredentialAccountAdapter,
} from './last-local-credential.js';
import type { AdminActor, EndpointResult } from './admin-user-endpoints.js';
import { applyUserBan, applyUserUnban } from './user-ban-write.js';

/**
* Minimal better-auth `$context` surface these two routes touch. Mirrors what
Expand DownExpand Up@@ -161,11 +164,9 @@ export async function runAdminBanUser(
};
}

await ctx.internalAdapter.updateUser(userId, {
banned: true,
await applyUserBan(ctx.internalAdapter, userId, {
banReason,
...(banExpires ? { banExpires } : {}),
updatedAt: new Date(),
});
// Sign the banned user out everywhere, exactly as the vendor handler does.
await ctx.internalAdapter.deleteUserSessions(userId);
Expand DownExpand Up@@ -197,12 +198,7 @@ export async function runAdminUnbanUser(
const ctx = await deps.getAuthContext();
if (!(await ctx.internalAdapter.findUserById(userId))) return notFound();

await ctx.internalAdapter.updateUser(userId, {
banned: false,
banReason: null,
banExpires: null,
updatedAt: new Date(),
});
await applyUserUnban(ctx.internalAdapter, userId);

return { status: 200, body: { success: true, data: { userId, banned: false } } };
}
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Auth, BetterAuthOptions } from 'better-auth';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
Expand DownExpand Up@@ -101,6 +102,11 @@ import {
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
Expand DownExpand Up@@ -3276,6 +3282,16 @@ export class AuthManager {
return verifyScimBearerToken(engine as never, secret, input.token);
},
},
// [#14360] The host half of `active`: stable @better-auth/scim
// writes no `banned` itself any more (the 1.6.x coupling left the
// package in 1.7.0) — it hands the aggregate lifecycle state to
// this callback inside the SCIM transaction and only revokes
// sessions. Routed to the platform's own ban write; the break-glass
// last-administrator guard judges it at the engine. See
// `reconcileScimUserLifecycle` for the contract and the measurement.
identity: {
reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context),
},
});
});
}
Expand DownExpand Up@@ -4840,6 +4856,140 @@ export class AuthManager {
return auth.api;
}

/**
* [#14360] `identity.reconcileUser` — the host half of SCIM `active`.
*
* `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped
* `active` onto the admin plugin's ban and refused a deactivation without
* that plugin; on the installed 1.7.2 the substring `ban` occurs zero times
* in the package) and replaced it with this optional callback: the vendor
* computes the user's AGGREGATE lifecycle state — `active` is true while
* any participating SCIM source says so — inside the request's
* transaction, calls the host, and then revokes the user's sessions when
* the state is inactive (`dist/index.mjs`, the identity facade's
* `reconcileUser`). Without a host implementation an IdP's `active: false`
* revoked sessions and wrote nothing: `sys_user.banned` stayed false and a
* local-password user signed straight back in, while ADR-0071, the
* generated docs and the #13816 refusal all asserted the ban.
*
* This method restores declared = enforced by routing the state to the
* platform's OWN ban write (`admin-ban-endpoints.ts`):
*
* - `active: false` on a row that is not banned ⇒ `applyUserBan` with
* `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's
* `session.create` hook (`BANNED_USER`) then refuses sign-in — the same
* enforcement the admin ban has, because it is the same write. On a row
* that is ALREADY banned with an expiry (an administrator's timed ban),
* the deactivation makes that ban permanent — `banExpires` is cleared,
* `banned` and the administrator's reason are left untouched — because
* the vendor's session hook auto-lifts an expired ban and would admit a
* principal the IdP still holds deactivated, and this callback is not
* re-invoked until the IdP mutates that user again.
* - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`.
* A ban carrying any other reason was placed by an administrator and is
* not the IdP's to lift: an attribute sync (every SCIM PUT carries
* `active: true`) must not silently re-admit a user banned for cause.
* Known collision, documented rather than reserved: an administrator
* who types the reason `Deactivated via SCIM` on the admin mount
* produces a ban this rule reads as the IdP's, so an `active: true`
* lifts it. Reserving the string on the admin mount would change that
* surface, which is not this hook's to do.
* - Anything else is a no-op. The callback is contractually idempotent
* ("Implementations must be idempotent") and the vendor invokes it on
* EVERY user mutation, so a PATCH that changes only `displayName`
* touches no ban column.
*
* A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no
* longer deletes the better-auth user (the vendor tombstones the source);
* it leaves the user with no active source, so this callback disables the
* account. Re-provisioning through the tombstone re-links the same user,
* the state turns active, and the SCIM ban is lifted by the second bullet.
*
* The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an
* ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly
* as it judges the admin mount's: deactivating the last administrator
* throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an
* `APIError`, the vendor re-throws `APIError`s unchanged out of this
* callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other
* throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying
* the original as `cause`), and the IdP receives a SCIM error with
* `status: "403"` and the guard's own explanation. The ban is ONE write,
* 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
* `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
* 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.
*
* Deliberately NOT applied here: the last-LOCAL-credential guard the admin
* mount re-runs (`isLastLocalCredentialHolder`). That guard protects the
* password escape hatch from an administrator's click; on this path the
* identity provider is the authority for the user it deprovisions, and
* keeping a departed user's password alive because it happened to be the
* last one is the wrong direction for a deprovisioning contract. 1.6.x
* never applied it on the SCIM path either — the vendor wrote the column
* straight through the adapter.
*
* 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.
*/
private async reconcileScimUserLifecycle(
state: SCIMIdentityState,
context: SCIMTransactionContext,
): Promise<void> {
const db = context.database;
const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({
model: 'user',
where: [{ field: 'id', value: state.userId }],
});
if (!user) {
// The vendor holds a `scimSubject` for this user inside the same
// transaction, so a missing row is an invariant break, not a state to
// reconcile. Thrown, not logged: the vendor turns it into a SCIM 500
// and rolls the mutation back — a deactivation that cannot find its
// account must not report success.
throw new Error(
`[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`,
);
}
const writer = {
updateUser: (id: string, data: Record<string, unknown>) =>
db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }),
};
const banned = user.banned === true;
if (!state.active) {
if (banned) {
// Already disabled — by an earlier SCIM pass or by an administrator.
// An administrator's TIMED ban is made permanent: the vendor's session
// hook auto-lifts an expired ban, and nothing re-invokes this callback
// until the IdP mutates the user again — so left alone, the expiry
// would re-admit a principal the IdP still holds deactivated. The
// reason stays the administrator's; only the expiry goes.
if (user.banExpires !== null && user.banExpires !== undefined) {
await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() });
}
return;
}
await applyUserBan(writer, state.userId, {
banReason: SCIM_DEACTIVATION_BAN_REASON,
banExpires: null,
});
return;
}
if (!banned) return;
// An administrator's ban is not the IdP's to lift.
if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return;
await applyUserUnban(writer, state.userId);
}

/**
* Get the underlying better-auth context for low-level operations such as
* `internalAdapter.createAccount` / `password.hash`.
Expand Down
Loading
Loading