diff --git a/.changeset/scim-provider-key-and-sso-scim-parity.md b/.changeset/scim-provider-key-and-sso-scim-parity.md new file mode 100644 index 0000000000..d42c679a7c --- /dev/null +++ b/.changeset/scim-provider-key-and-sso-scim-parity.md @@ -0,0 +1,20 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(auth): provision `sys_scim_provider.provider_key` — SCIM provider creation failed the moment SCIM was switched on (#3653) + +`@better-auth/scim` declares `providerKey` as `required: true, unique: true` +and writes it on every provider insert — a derived `:` +uniqueness key it owns end to end. `sys_scim_provider` never provisioned the +column, so the adapter emitted a `provider_key` no table had: the same failure +shape as #3624, waiting behind the `OS_SCIM_ENABLED` flag. + +Found by extending the better-auth parity gate to `@better-auth/sso` and +`@better-auth/scim`. Neither accepts a `schema` option, so `getAuthTables()` is +blind to them and they were excluded when that gate shipped; the gate now reads +each plugin's own declared schema and resolves columns the way the adapter +actually does for a bridged model. `@better-auth/sso` came back fully covered. + +Existing environments pick the column up through the driver's additive schema +sync; it stays null on pre-upgrade rows, which the nullable UNIQUE index admits. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 15bd7814f7..f4f9312d4d 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1875,6 +1875,10 @@ export const enObjects: NonNullable = { label: "Provider ID", help: "Stable SCIM provider identifier (e.g. \"okta-scim\")" }, + provider_key: { + label: "Provider Key", + help: "Derived : uniqueness key maintained by @better-auth/scim; do not write directly." + }, scim_token: { label: "SCIM Token (hash)", help: "Hashed bearer credential for this SCIM connection — the plaintext is shown once at generate-token. Sensitive; do not expose." diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index da367b9647..4db6adc44b 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1875,6 +1875,10 @@ export const esESObjects: NonNullable = { label: "ID de proveedor", help: "Identificador de proveedor SCIM estable (p. ej. «okta-scim»)" }, + provider_key: { + label: "Provider Key", + help: "Derived : uniqueness key maintained by @better-auth/scim; do not write directly." + }, scim_token: { label: "Token SCIM (hash)", help: "Credencial bearer con hash de esta conexión SCIM: el texto plano se muestra una sola vez al generar el token. Sensible; no lo exponga." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index e1c4208c9b..38a2c0425d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1875,6 +1875,10 @@ export const jaJPObjects: NonNullable = { label: "プロバイダー ID", help: "安定した SCIM プロバイダー識別子(例: 「okta-scim」)" }, + provider_key: { + label: "Provider Key", + help: "Derived : uniqueness key maintained by @better-auth/scim; do not write directly." + }, scim_token: { label: "SCIM トークン(ハッシュ)", help: "この SCIM 接続のハッシュ化されたベアラー資格情報——平文はトークン生成時に一度だけ表示されます。機密情報のため、公開しないでください。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index de242374f5..806d44ea0d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1875,6 +1875,10 @@ export const zhCNObjects: NonNullable = { label: "提供方 ID", help: "稳定的 SCIM 提供方标识符(如 “okta-scim”)" }, + provider_key: { + label: "Provider Key", + help: "Derived : uniqueness key maintained by @better-auth/scim; do not write directly." + }, scim_token: { label: "SCIM 令牌(哈希)", help: "该 SCIM 连接的哈希 Bearer 凭据——明文仅在生成令牌时显示一次。敏感信息,请勿泄露。" diff --git a/packages/platform-objects/src/identity/sys-scim-provider.object.ts b/packages/platform-objects/src/identity/sys-scim-provider.object.ts index 8bddcbe422..936e8c41e4 100644 --- a/packages/platform-objects/src/identity/sys-scim-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-provider.object.ts @@ -78,6 +78,22 @@ export const SysScimProvider = ObjectSchema.create({ group: 'Identity', }), + // `@better-auth/scim`'s uniqueness boundary for a connection: + // `:`, declared `required: true, unique: true, + // returned: false` upstream and written on every provider insert. Same + // shape as `sys_team_member.membership_key` — a derived key the library + // owns end to end, never authored or read from the ObjectStack side. + // Provisioned here because the adapter writes the column; without it SCIM + // provider creation fails the moment SCIM is switched on. See #3653. + provider_key: Field.text({ + label: 'Provider Key', + required: false, + readonly: true, + maxLength: 512, + description: 'Derived : uniqueness key maintained by @better-auth/scim; do not write directly.', + group: 'System', + }), + scim_token: Field.text({ label: 'SCIM Token (hash)', required: false, @@ -110,6 +126,18 @@ export const SysScimProvider = ObjectSchema.create({ { fields: ['provider_id'], unique: true }, { fields: ['organization_id'] }, { fields: ['user_id'] }, + // UNIQUE mirrors @better-auth/scim's own declaration. Nullable, so rows + // provisioned before the column existed admit repeated NULLs on sqlite / + // postgres / mysql. + // + // NOTE: upstream's boundary is `:`, i.e. the + // SAME provider_id in two different organizations is legal there, while + // the `provider_id` unique index above forbids it. That is a stricter + // constraint than the library assumes, not something this column changes — + // left as-is deliberately and raised separately (#3653) rather than + // relaxed here, since loosening a live uniqueness constraint is its own + // decision. + { fields: ['provider_key'], unique: true }, ], enable: { diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts index 889b13eb8a..f99264efd1 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -41,10 +41,15 @@ * * `@better-auth/oauth-provider` is deliberately absent: it ships as its own * package with its own pinned version and already has the dedicated gate named - * above. `@better-auth/sso` / `@better-auth/scim` are absent because they - * expose no `schema` option at all — their mapping is applied at the adapter - * layer (see `objectql-adapter.ts`), so `getAuthTables()` cannot report the - * columns they actually write. + * above. + * + * `@better-auth/sso` / `@better-auth/scim` accept no `schema` option, so + * `getAuthTables()` cannot see them — they were the hole this gate shipped + * with (#3653). The second describe block below closes it by reading each + * plugin's OWN declared schema and resolving columns the way the ADAPTER + * does for a bridged model (mechanical camelCase → snake_case in + * `objectql-adapter.ts`), which is the rule that actually governs their + * writes. */ import { describe, expect, it } from 'vitest'; @@ -53,6 +58,8 @@ import { organization, twoFactor, admin } from 'better-auth/plugins'; import { phoneNumber } from 'better-auth/plugins/phone-number'; import { jwt } from 'better-auth/plugins/jwt'; import { deviceAuthorization } from 'better-auth/plugins/device-authorization'; +import { sso } from '@better-auth/sso'; +import { scim } from '@better-auth/scim'; import { SysAccount, SysDeviceCode, @@ -63,6 +70,8 @@ import { SysSession, SysTeam, SysTeamMember, + SysScimProvider, + SysSsoProvider, SysTwoFactor, SysUser, SysVerification, @@ -80,6 +89,7 @@ import { buildPhoneNumberPluginSchema, buildTwoFactorPluginSchema, } from './auth-schema-config.js'; +import { resolveProtocolName } from './objectql-adapter.js'; type PlatformObject = { name: string; fields?: Record }; @@ -89,6 +99,9 @@ const PLATFORM_OBJECTS: Record = Object.fromEntries( SysUser, SysSession, SysAccount, SysVerification, SysOrganization, SysMember, SysInvitation, SysTeam, SysTeamMember, SysTwoFactor, SysDeviceCode, SysJwks, + // Bridged at the adapter layer rather than via a plugin `schema` option — + // see the sso/scim block at the bottom of this file (#3653). + SysSsoProvider, SysScimProvider, ] as unknown as PlatformObject[]).map((o) => [o.name, o]), ); @@ -167,3 +180,88 @@ describe('better-auth schema ↔ platform-objects parity (#3624)', () => { }); } }); + +// --------------------------------------------------------------------------- +// @better-auth/sso + @better-auth/scim (#3653) +// --------------------------------------------------------------------------- + +/** + * These two plugins hardcode their model names and read no `schema` option, so + * `getAuthTables()` above is blind to them. The adapter bridges them instead: + * `AUTH_MODEL_TO_PROTOCOL` maps the model, and `createObjectQLAdapterFactory` + * mechanically camelCase→snake_cases every field of a bridged model on the way + * in. That mechanical rule — not any hand-written mapping — is what decides + * the column they actually write, so it is what this reproduces. + * + * Mirrors the adapter's own `camelToSnake`. + */ +function adapterColumn(field: string): string { + return field.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); +} + +/** + * SCIM models with no platform object, acknowledged rather than silently + * skipped. These four are SCIM **group** provisioning (`/Groups` push from the + * IdP); ObjectStack ships only the provider row today, so an IdP pushing + * groups would write tables that do not exist — filed as its own feature gap. + * + * Pinned as an exact set on purpose: a NEW unmapped model is a build failure, + * so this list can never quietly grow the way the original hole did. + */ +const KNOWN_UNMAPPED_MODELS = new Set([ + 'scimGroup', + 'scimGroupMember', + 'scimGroupRole', + 'scimGroupRoleGrant', +]); + +describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parity (#3653)', () => { + const plugins: Array<{ label: string; schema: Record }> }> = [ + { label: 'sso', schema: (sso() as any).schema }, + { label: 'scim', schema: (scim({} as never) as any).schema }, + ]; + + it('both plugins still expose a readable schema (the gate must not pass vacuously)', () => { + for (const { label, schema } of plugins) { + expect(Object.keys(schema ?? {}).length, `${label} exposed no schema`).toBeGreaterThan(0); + } + }); + + it('the set of models with no platform object is exactly the acknowledged one', () => { + const unmapped = plugins + .flatMap(({ schema }) => Object.keys(schema ?? {})) + .filter((model) => !PLATFORM_OBJECTS[resolveProtocolName(model)]); + expect( + unmapped.sort(), + 'a model gained or lost a platform object. A NEW name here means the plugin added a table ' + + 'nothing provisions — declare the object and map it in AUTH_MODEL_TO_PROTOCOL. A name that ' + + 'DISAPPEARED means it is now provisioned — drop it from KNOWN_UNMAPPED_MODELS so it is ' + + 'covered by the column check below.', + ).toEqual([...KNOWN_UNMAPPED_MODELS].sort()); + }); + + for (const { label, schema } of [ + { label: 'sso', schema: (sso() as any).schema as Record }> }, + { label: 'scim', schema: (scim({} as never) as any).schema as Record }> }, + ]) { + for (const [model, def] of Object.entries(schema ?? {})) { + if (KNOWN_UNMAPPED_MODELS.has(model)) continue; + const objectName = resolveProtocolName(model); + it(`every ${label}/${model} column exists on ${objectName}`, () => { + const object = PLATFORM_OBJECTS[objectName]; + expect(object, `${model} must map to a platform object via AUTH_MODEL_TO_PROTOCOL`).toBeDefined(); + const declared = new Set(['id', ...Object.keys(object.fields ?? {})]); + const missing = Object.keys(def.fields ?? {}) + .map(adapterColumn) + .filter((column) => !declared.has(column)); + expect( + missing, + `columns ${label} can write to ${objectName} but the platform object does not declare: ` + + `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/. ` + + 'These plugins take no schema option, so the adapter snake_cases their fields ' + + 'mechanically; there is no mapping to add, only the column.', + ).toEqual([]); + }); + } + } +});