Skip to content

Commit 5487c20

Browse files
os-zhuangclaude
andauthored
fix(auth): close the sso/scim parity hole; provision sys_scim_provider.provider_key (#3653) (#3688)
The better-auth parity gate (#3647) shipped with a known hole: `@better-auth/sso` and `@better-auth/scim` accept no `schema` option, so `getAuthTables()` cannot see them and they were excluded with a comment. A comment is not a gate — filed as #3653 rather than left buried, and closed here. Both plugins DO expose their own `.schema`, and the adapter bridges them by mechanically camelCase→snake_casing every field of a bridged model (`objectql-adapter.ts`). That mechanical rule — not any hand-written mapping — is what decides the column they write, so the gate now reads the former and applies the latter. What it found on first run: @better-auth/sso clean. All 7 ssoProvider fields already provisioned. @better-auth/scim sys_scim_provider was missing `provider_key`. `providerKey` is declared `required: true, unique: true` upstream and written on every provider insert (a derived `<organization>:<provider_id>` key, same shape as `sys_team_member.membership_key`). Without the column, creating a SCIM provider fails the moment SCIM is switched on — #3624 again, behind a flag. The four SCIM **group** models (`scimGroup`, `scimGroupMember`, `scimGroupRole`, `scimGroupRoleGrant`) have no platform object at all. That is a feature-sized gap, not a missing column, so it is NOT silently skipped: `KNOWN_UNMAPPED_MODELS` pins them as an exact set and the set itself is asserted. A new unmapped model fails the build, and a model that gains an object also fails — telling whoever provisioned it to move it into the column check. #3653 now tracks that work. Also raised there, deliberately NOT changed here: `sys_scim_provider` carries a UNIQUE index on `provider_id` alone, while upstream's boundary is `<organization>:<provider_id>` — so the same provider id in two organizations is legal upstream and rejected here. Relaxing a live uniqueness constraint is its own decision. Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31468fc commit 5487c20

7 files changed

Lines changed: 166 additions & 4 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(auth): provision `sys_scim_provider.provider_key` — SCIM provider creation failed the moment SCIM was switched on (#3653)
6+
7+
`@better-auth/scim` declares `providerKey` as `required: true, unique: true`
8+
and writes it on every provider insert — a derived `<organization>:<provider_id>`
9+
uniqueness key it owns end to end. `sys_scim_provider` never provisioned the
10+
column, so the adapter emitted a `provider_key` no table had: the same failure
11+
shape as #3624, waiting behind the `OS_SCIM_ENABLED` flag.
12+
13+
Found by extending the better-auth parity gate to `@better-auth/sso` and
14+
`@better-auth/scim`. Neither accepts a `schema` option, so `getAuthTables()` is
15+
blind to them and they were excluded when that gate shipped; the gate now reads
16+
each plugin's own declared schema and resolves columns the way the adapter
17+
actually does for a bridged model. `@better-auth/sso` came back fully covered.
18+
19+
Existing environments pick the column up through the driver's additive schema
20+
sync; it stays null on pre-upgrade rows, which the nullable UNIQUE index admits.

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
18751875
label: "Provider ID",
18761876
help: "Stable SCIM provider identifier (e.g. \"okta-scim\")"
18771877
},
1878+
provider_key: {
1879+
label: "Provider Key",
1880+
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
1881+
},
18781882
scim_token: {
18791883
label: "SCIM Token (hash)",
18801884
help: "Hashed bearer credential for this SCIM connection — the plaintext is shown once at generate-token. Sensitive; do not expose."

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
18751875
label: "ID de proveedor",
18761876
help: "Identificador de proveedor SCIM estable (p. ej. «okta-scim»)"
18771877
},
1878+
provider_key: {
1879+
label: "Provider Key",
1880+
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
1881+
},
18781882
scim_token: {
18791883
label: "Token SCIM (hash)",
18801884
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."

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
18751875
label: "プロバイダー ID",
18761876
help: "安定した SCIM プロバイダー識別子(例: 「okta-scim」)"
18771877
},
1878+
provider_key: {
1879+
label: "Provider Key",
1880+
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
1881+
},
18781882
scim_token: {
18791883
label: "SCIM トークン(ハッシュ)",
18801884
help: "この SCIM 接続のハッシュ化されたベアラー資格情報——平文はトークン生成時に一度だけ表示されます。機密情報のため、公開しないでください。"

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
18751875
label: "提供方 ID",
18761876
help: "稳定的 SCIM 提供方标识符(如 “okta-scim”)"
18771877
},
1878+
provider_key: {
1879+
label: "Provider Key",
1880+
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
1881+
},
18781882
scim_token: {
18791883
label: "SCIM 令牌(哈希)",
18801884
help: "该 SCIM 连接的哈希 Bearer 凭据——明文仅在生成令牌时显示一次。敏感信息,请勿泄露。"

packages/platform-objects/src/identity/sys-scim-provider.object.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ export const SysScimProvider = ObjectSchema.create({
7878
group: 'Identity',
7979
}),
8080

81+
// `@better-auth/scim`'s uniqueness boundary for a connection:
82+
// `<organizationId>:<providerId>`, declared `required: true, unique: true,
83+
// returned: false` upstream and written on every provider insert. Same
84+
// shape as `sys_team_member.membership_key` — a derived key the library
85+
// owns end to end, never authored or read from the ObjectStack side.
86+
// Provisioned here because the adapter writes the column; without it SCIM
87+
// provider creation fails the moment SCIM is switched on. See #3653.
88+
provider_key: Field.text({
89+
label: 'Provider Key',
90+
required: false,
91+
readonly: true,
92+
maxLength: 512,
93+
description: 'Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly.',
94+
group: 'System',
95+
}),
96+
8197
scim_token: Field.text({
8298
label: 'SCIM Token (hash)',
8399
required: false,
@@ -110,6 +126,18 @@ export const SysScimProvider = ObjectSchema.create({
110126
{ fields: ['provider_id'], unique: true },
111127
{ fields: ['organization_id'] },
112128
{ fields: ['user_id'] },
129+
// UNIQUE mirrors @better-auth/scim's own declaration. Nullable, so rows
130+
// provisioned before the column existed admit repeated NULLs on sqlite /
131+
// postgres / mysql.
132+
//
133+
// NOTE: upstream's boundary is `<organization>:<provider_id>`, i.e. the
134+
// SAME provider_id in two different organizations is legal there, while
135+
// the `provider_id` unique index above forbids it. That is a stricter
136+
// constraint than the library assumes, not something this column changes —
137+
// left as-is deliberately and raised separately (#3653) rather than
138+
// relaxed here, since loosening a live uniqueness constraint is its own
139+
// decision.
140+
{ fields: ['provider_key'], unique: true },
113141
],
114142

115143
enable: {

packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@
4141
*
4242
* `@better-auth/oauth-provider` is deliberately absent: it ships as its own
4343
* package with its own pinned version and already has the dedicated gate named
44-
* above. `@better-auth/sso` / `@better-auth/scim` are absent because they
45-
* expose no `schema` option at all — their mapping is applied at the adapter
46-
* layer (see `objectql-adapter.ts`), so `getAuthTables()` cannot report the
47-
* columns they actually write.
44+
* above.
45+
*
46+
* `@better-auth/sso` / `@better-auth/scim` accept no `schema` option, so
47+
* `getAuthTables()` cannot see them — they were the hole this gate shipped
48+
* with (#3653). The second describe block below closes it by reading each
49+
* plugin's OWN declared schema and resolving columns the way the ADAPTER
50+
* does for a bridged model (mechanical camelCase → snake_case in
51+
* `objectql-adapter.ts`), which is the rule that actually governs their
52+
* writes.
4853
*/
4954

5055
import { describe, expect, it } from 'vitest';
@@ -53,6 +58,8 @@ import { organization, twoFactor, admin } from 'better-auth/plugins';
5358
import { phoneNumber } from 'better-auth/plugins/phone-number';
5459
import { jwt } from 'better-auth/plugins/jwt';
5560
import { deviceAuthorization } from 'better-auth/plugins/device-authorization';
61+
import { sso } from '@better-auth/sso';
62+
import { scim } from '@better-auth/scim';
5663
import {
5764
SysAccount,
5865
SysDeviceCode,
@@ -63,6 +70,8 @@ import {
6370
SysSession,
6471
SysTeam,
6572
SysTeamMember,
73+
SysScimProvider,
74+
SysSsoProvider,
6675
SysTwoFactor,
6776
SysUser,
6877
SysVerification,
@@ -80,6 +89,7 @@ import {
8089
buildPhoneNumberPluginSchema,
8190
buildTwoFactorPluginSchema,
8291
} from './auth-schema-config.js';
92+
import { resolveProtocolName } from './objectql-adapter.js';
8393

8494
type PlatformObject = { name: string; fields?: Record<string, unknown> };
8595

@@ -89,6 +99,9 @@ const PLATFORM_OBJECTS: Record<string, PlatformObject> = Object.fromEntries(
8999
SysUser, SysSession, SysAccount, SysVerification,
90100
SysOrganization, SysMember, SysInvitation, SysTeam, SysTeamMember,
91101
SysTwoFactor, SysDeviceCode, SysJwks,
102+
// Bridged at the adapter layer rather than via a plugin `schema` option —
103+
// see the sso/scim block at the bottom of this file (#3653).
104+
SysSsoProvider, SysScimProvider,
92105
] as unknown as PlatformObject[]).map((o) => [o.name, o]),
93106
);
94107

@@ -167,3 +180,88 @@ describe('better-auth schema ↔ platform-objects parity (#3624)', () => {
167180
});
168181
}
169182
});
183+
184+
// ---------------------------------------------------------------------------
185+
// @better-auth/sso + @better-auth/scim (#3653)
186+
// ---------------------------------------------------------------------------
187+
188+
/**
189+
* These two plugins hardcode their model names and read no `schema` option, so
190+
* `getAuthTables()` above is blind to them. The adapter bridges them instead:
191+
* `AUTH_MODEL_TO_PROTOCOL` maps the model, and `createObjectQLAdapterFactory`
192+
* mechanically camelCase→snake_cases every field of a bridged model on the way
193+
* in. That mechanical rule — not any hand-written mapping — is what decides
194+
* the column they actually write, so it is what this reproduces.
195+
*
196+
* Mirrors the adapter's own `camelToSnake`.
197+
*/
198+
function adapterColumn(field: string): string {
199+
return field.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
200+
}
201+
202+
/**
203+
* SCIM models with no platform object, acknowledged rather than silently
204+
* skipped. These four are SCIM **group** provisioning (`/Groups` push from the
205+
* IdP); ObjectStack ships only the provider row today, so an IdP pushing
206+
* groups would write tables that do not exist — filed as its own feature gap.
207+
*
208+
* Pinned as an exact set on purpose: a NEW unmapped model is a build failure,
209+
* so this list can never quietly grow the way the original hole did.
210+
*/
211+
const KNOWN_UNMAPPED_MODELS = new Set([
212+
'scimGroup',
213+
'scimGroupMember',
214+
'scimGroupRole',
215+
'scimGroupRoleGrant',
216+
]);
217+
218+
describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parity (#3653)', () => {
219+
const plugins: Array<{ label: string; schema: Record<string, { fields?: Record<string, unknown> }> }> = [
220+
{ label: 'sso', schema: (sso() as any).schema },
221+
{ label: 'scim', schema: (scim({} as never) as any).schema },
222+
];
223+
224+
it('both plugins still expose a readable schema (the gate must not pass vacuously)', () => {
225+
for (const { label, schema } of plugins) {
226+
expect(Object.keys(schema ?? {}).length, `${label} exposed no schema`).toBeGreaterThan(0);
227+
}
228+
});
229+
230+
it('the set of models with no platform object is exactly the acknowledged one', () => {
231+
const unmapped = plugins
232+
.flatMap(({ schema }) => Object.keys(schema ?? {}))
233+
.filter((model) => !PLATFORM_OBJECTS[resolveProtocolName(model)]);
234+
expect(
235+
unmapped.sort(),
236+
'a model gained or lost a platform object. A NEW name here means the plugin added a table '
237+
+ 'nothing provisions — declare the object and map it in AUTH_MODEL_TO_PROTOCOL. A name that '
238+
+ 'DISAPPEARED means it is now provisioned — drop it from KNOWN_UNMAPPED_MODELS so it is '
239+
+ 'covered by the column check below.',
240+
).toEqual([...KNOWN_UNMAPPED_MODELS].sort());
241+
});
242+
243+
for (const { label, schema } of [
244+
{ label: 'sso', schema: (sso() as any).schema as Record<string, { fields?: Record<string, unknown> }> },
245+
{ label: 'scim', schema: (scim({} as never) as any).schema as Record<string, { fields?: Record<string, unknown> }> },
246+
]) {
247+
for (const [model, def] of Object.entries(schema ?? {})) {
248+
if (KNOWN_UNMAPPED_MODELS.has(model)) continue;
249+
const objectName = resolveProtocolName(model);
250+
it(`every ${label}/${model} column exists on ${objectName}`, () => {
251+
const object = PLATFORM_OBJECTS[objectName];
252+
expect(object, `${model} must map to a platform object via AUTH_MODEL_TO_PROTOCOL`).toBeDefined();
253+
const declared = new Set(['id', ...Object.keys(object.fields ?? {})]);
254+
const missing = Object.keys(def.fields ?? {})
255+
.map(adapterColumn)
256+
.filter((column) => !declared.has(column));
257+
expect(
258+
missing,
259+
`columns ${label} can write to ${objectName} but the platform object does not declare: `
260+
+ `${missing.join(', ')} — add the field(s) to packages/platform-objects/src/identity/. `
261+
+ 'These plugins take no schema option, so the adapter snake_cases their fields '
262+
+ 'mechanically; there is no mapping to add, only the column.',
263+
).toEqual([]);
264+
});
265+
}
266+
}
267+
});

0 commit comments

Comments
 (0)