|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import type { Auth, BetterAuthOptions } from 'better-auth'; |
| 4 | +import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim'; |
4 | 5 | // better-auth value imports (betterAuth + plugins) are deferred via dynamic |
5 | 6 | // import() in getOrCreateAuth() / buildPluginList() so that disabled plugins |
6 | 7 | // never get loaded into the process. See Stage 2F (RSS investigation). |
@@ -101,6 +102,11 @@ import { |
101 | 102 | LAST_LOCAL_CREDENTIAL_CODE, |
102 | 103 | LAST_LOCAL_CREDENTIAL_MESSAGE, |
103 | 104 | } from './last-local-credential.js'; |
| 105 | +import { |
| 106 | + applyUserBan, |
| 107 | + applyUserUnban, |
| 108 | + SCIM_DEACTIVATION_BAN_REASON, |
| 109 | +} from './user-ban-write.js'; |
104 | 110 | import { |
105 | 111 | PHONE_SMS_TOPICS, |
106 | 112 | builtinPhoneSmsBody, |
@@ -3276,6 +3282,16 @@ export class AuthManager { |
3276 | 3282 | return verifyScimBearerToken(engine as never, secret, input.token); |
3277 | 3283 | }, |
3278 | 3284 | }, |
| 3285 | + // [#14360] The host half of `active`: stable @better-auth/scim |
| 3286 | + // writes no `banned` itself any more (the 1.6.x coupling left the |
| 3287 | + // package in 1.7.0) — it hands the aggregate lifecycle state to |
| 3288 | + // this callback inside the SCIM transaction and only revokes |
| 3289 | + // sessions. Routed to the platform's own ban write; the break-glass |
| 3290 | + // last-administrator guard judges it at the engine. See |
| 3291 | + // `reconcileScimUserLifecycle` for the contract and the measurement. |
| 3292 | + identity: { |
| 3293 | + reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context), |
| 3294 | + }, |
3279 | 3295 | }); |
3280 | 3296 | }); |
3281 | 3297 | } |
@@ -4840,6 +4856,140 @@ export class AuthManager { |
4840 | 4856 | return auth.api; |
4841 | 4857 | } |
4842 | 4858 |
|
| 4859 | + /** |
| 4860 | + * [#14360] `identity.reconcileUser` — the host half of SCIM `active`. |
| 4861 | + * |
| 4862 | + * `@better-auth/scim` 1.7.0 removed its own `banned` write (1.6.30 mapped |
| 4863 | + * `active` onto the admin plugin's ban and refused a deactivation without |
| 4864 | + * that plugin; on the installed 1.7.2 the substring `ban` occurs zero times |
| 4865 | + * in the package) and replaced it with this optional callback: the vendor |
| 4866 | + * computes the user's AGGREGATE lifecycle state — `active` is true while |
| 4867 | + * any participating SCIM source says so — inside the request's |
| 4868 | + * transaction, calls the host, and then revokes the user's sessions when |
| 4869 | + * the state is inactive (`dist/index.mjs`, the identity facade's |
| 4870 | + * `reconcileUser`). Without a host implementation an IdP's `active: false` |
| 4871 | + * revoked sessions and wrote nothing: `sys_user.banned` stayed false and a |
| 4872 | + * local-password user signed straight back in, while ADR-0071, the |
| 4873 | + * generated docs and the #13816 refusal all asserted the ban. |
| 4874 | + * |
| 4875 | + * This method restores declared = enforced by routing the state to the |
| 4876 | + * platform's OWN ban write (`admin-ban-endpoints.ts`): |
| 4877 | + * |
| 4878 | + * - `active: false` on a row that is not banned ⇒ `applyUserBan` with |
| 4879 | + * `SCIM_DEACTIVATION_BAN_REASON` and no expiry. The vendor's |
| 4880 | + * `session.create` hook (`BANNED_USER`) then refuses sign-in — the same |
| 4881 | + * enforcement the admin ban has, because it is the same write. On a row |
| 4882 | + * that is ALREADY banned with an expiry (an administrator's timed ban), |
| 4883 | + * the deactivation makes that ban permanent — `banExpires` is cleared, |
| 4884 | + * `banned` and the administrator's reason are left untouched — because |
| 4885 | + * the vendor's session hook auto-lifts an expired ban and would admit a |
| 4886 | + * principal the IdP still holds deactivated, and this callback is not |
| 4887 | + * re-invoked until the IdP mutates that user again. |
| 4888 | + * - `active: true` on a row banned WITH that reason ⇒ `applyUserUnban`. |
| 4889 | + * A ban carrying any other reason was placed by an administrator and is |
| 4890 | + * not the IdP's to lift: an attribute sync (every SCIM PUT carries |
| 4891 | + * `active: true`) must not silently re-admit a user banned for cause. |
| 4892 | + * Known collision, documented rather than reserved: an administrator |
| 4893 | + * who types the reason `Deactivated via SCIM` on the admin mount |
| 4894 | + * produces a ban this rule reads as the IdP's, so an `active: true` |
| 4895 | + * lifts it. Reserving the string on the admin mount would change that |
| 4896 | + * surface, which is not this hook's to do. |
| 4897 | + * - Anything else is a no-op. The callback is contractually idempotent |
| 4898 | + * ("Implementations must be idempotent") and the vendor invokes it on |
| 4899 | + * EVERY user mutation, so a PATCH that changes only `displayName` |
| 4900 | + * touches no ban column. |
| 4901 | + * |
| 4902 | + * A consequence worth stating: on 1.7.2 a SCIM `DELETE /Users/{id}` no |
| 4903 | + * longer deletes the better-auth user (the vendor tombstones the source); |
| 4904 | + * it leaves the user with no active source, so this callback disables the |
| 4905 | + * account. Re-provisioning through the tombstone re-links the same user, |
| 4906 | + * the state turns active, and the SCIM ban is lifted by the second bullet. |
| 4907 | + * |
| 4908 | + * The break-glass last-administrator guard (ADR-0024 D5.2, #5892) is an |
| 4909 | + * ENGINE `beforeUpdate` hook on `sys_user`, so it judges this write exactly |
| 4910 | + * as it judges the admin mount's: deactivating the last administrator |
| 4911 | + * throws its 403 `PERMISSION_DENIED`, the adapter rethrows it as an |
| 4912 | + * `APIError`, the vendor re-throws `APIError`s unchanged out of this |
| 4913 | + * callback (`runSCIMApplicationCallback`, measured on 1.7.2 — any other |
| 4914 | + * throw becomes a SCIM 500 "SCIM identity reconciliation failed" carrying |
| 4915 | + * the original as `cause`), and the IdP receives a SCIM error with |
| 4916 | + * `status: "403"` and the guard's own explanation. The ban is ONE write, |
| 4917 | + * so it never half-lands: the account stays enabled and nothing is |
| 4918 | + * skipped silently. |
| 4919 | + * |
| 4920 | + * ⚠️ What does NOT roll back today: the vendor runs this callback inside |
| 4921 | + * `runWithTransaction`, which on this adapter is a real engine transaction |
| 4922 | + * only while `scimRequestScope` is set — and that scope, stamped inside |
| 4923 | + * `verifyBearerToken`, is not observed at write time on 1.7.2 (measured: |
| 4924 | + * zero `engine.transaction` calls across a SCIM POST + PATCH; #14522). So |
| 4925 | + * the vendor's own `scimUser.active = false` write, made before this |
| 4926 | + * callback, survives a refusal and the SCIM resource reads inactive while |
| 4927 | + * the account is enabled. #14522 owns that seam; the #14360 suite pins the |
| 4928 | + * residual so its fix flips the pin deliberately. |
| 4929 | + * |
| 4930 | + * Deliberately NOT applied here: the last-LOCAL-credential guard the admin |
| 4931 | + * mount re-runs (`isLastLocalCredentialHolder`). That guard protects the |
| 4932 | + * password escape hatch from an administrator's click; on this path the |
| 4933 | + * identity provider is the authority for the user it deprovisions, and |
| 4934 | + * keeping a departed user's password alive because it happened to be the |
| 4935 | + * last one is the wrong direction for a deprovisioning contract. 1.6.x |
| 4936 | + * never applied it on the SCIM path either — the vendor wrote the column |
| 4937 | + * straight through the adapter. |
| 4938 | + * |
| 4939 | + * Every read and write goes through `context.database` — the adapter the |
| 4940 | + * vendor bound to its transaction — never through an `internalAdapter` |
| 4941 | + * resolved outside it, so the moment #14522 makes that transaction real, |
| 4942 | + * the ban commits or rolls back with the SCIM mutation it belongs to. |
| 4943 | + */ |
| 4944 | + private async reconcileScimUserLifecycle( |
| 4945 | + state: SCIMIdentityState, |
| 4946 | + context: SCIMTransactionContext, |
| 4947 | + ): Promise<void> { |
| 4948 | + const db = context.database; |
| 4949 | + const user = await db.findOne<{ banned?: unknown; banReason?: unknown; banExpires?: unknown }>({ |
| 4950 | + model: 'user', |
| 4951 | + where: [{ field: 'id', value: state.userId }], |
| 4952 | + }); |
| 4953 | + if (!user) { |
| 4954 | + // The vendor holds a `scimSubject` for this user inside the same |
| 4955 | + // transaction, so a missing row is an invariant break, not a state to |
| 4956 | + // reconcile. Thrown, not logged: the vendor turns it into a SCIM 500 |
| 4957 | + // and rolls the mutation back — a deactivation that cannot find its |
| 4958 | + // account must not report success. |
| 4959 | + throw new Error( |
| 4960 | + `[auth] SCIM identity reconciliation: better-auth user '${state.userId}' has no sys_user row`, |
| 4961 | + ); |
| 4962 | + } |
| 4963 | + const writer = { |
| 4964 | + updateUser: (id: string, data: Record<string, unknown>) => |
| 4965 | + db.update({ model: 'user', where: [{ field: 'id', value: id }], update: data }), |
| 4966 | + }; |
| 4967 | + const banned = user.banned === true; |
| 4968 | + if (!state.active) { |
| 4969 | + if (banned) { |
| 4970 | + // Already disabled — by an earlier SCIM pass or by an administrator. |
| 4971 | + // An administrator's TIMED ban is made permanent: the vendor's session |
| 4972 | + // hook auto-lifts an expired ban, and nothing re-invokes this callback |
| 4973 | + // until the IdP mutates the user again — so left alone, the expiry |
| 4974 | + // would re-admit a principal the IdP still holds deactivated. The |
| 4975 | + // reason stays the administrator's; only the expiry goes. |
| 4976 | + if (user.banExpires !== null && user.banExpires !== undefined) { |
| 4977 | + await writer.updateUser(state.userId, { banExpires: null, updatedAt: new Date() }); |
| 4978 | + } |
| 4979 | + return; |
| 4980 | + } |
| 4981 | + await applyUserBan(writer, state.userId, { |
| 4982 | + banReason: SCIM_DEACTIVATION_BAN_REASON, |
| 4983 | + banExpires: null, |
| 4984 | + }); |
| 4985 | + return; |
| 4986 | + } |
| 4987 | + if (!banned) return; |
| 4988 | + // An administrator's ban is not the IdP's to lift. |
| 4989 | + if (user.banReason !== SCIM_DEACTIVATION_BAN_REASON) return; |
| 4990 | + await applyUserUnban(writer, state.userId); |
| 4991 | + } |
| 4992 | + |
4843 | 4993 | /** |
4844 | 4994 | * Get the underlying better-auth context for low-level operations such as |
4845 | 4995 | * `internalAdapter.createAccount` / `password.hash`. |
|
0 commit comments