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
20 changes: 20 additions & 0 deletions .changeset/scim-provider-key-and-sso-scim-parity.md
Original file line number Diff line number Diff line change
@@ -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 `<organization>:<provider_id>`
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.
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Provider ID",
help: "Stable SCIM provider identifier (e.g. \"okta-scim\")"
},
provider_key: {
label: "Provider Key",
help: "Derived <organization>:<provider_id> 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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "ID de proveedor",
help: "Identificador de proveedor SCIM estable (p. ej. «okta-scim»)"
},
provider_key: {
label: "Provider Key",
help: "Derived <organization>:<provider_id> 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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "プロバイダー ID",
help: "安定した SCIM プロバイダー識別子(例: 「okta-scim」)"
},
provider_key: {
label: "Provider Key",
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
},
scim_token: {
label: "SCIM トークン(ハッシュ)",
help: "この SCIM 接続のハッシュ化されたベアラー資格情報——平文はトークン生成時に一度だけ表示されます。機密情報のため、公開しないでください。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1875,6 +1875,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "提供方 ID",
help: "稳定的 SCIM 提供方标识符(如 “okta-scim”)"
},
provider_key: {
label: "Provider Key",
help: "Derived <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly."
},
scim_token: {
label: "SCIM 令牌(哈希)",
help: "该 SCIM 连接的哈希 Bearer 凭据——明文仅在生成令牌时显示一次。敏感信息,请勿泄露。"
Expand Down
28 changes: 28 additions & 0 deletions packages/platform-objects/src/identity/sys-scim-provider.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ export const SysScimProvider = ObjectSchema.create({
group: 'Identity',
}),

// `@better-auth/scim`'s uniqueness boundary for a connection:
// `<organizationId>:<providerId>`, 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 <organization>:<provider_id> uniqueness key maintained by @better-auth/scim; do not write directly.',
group: 'System',
}),

scim_token: Field.text({
label: 'SCIM Token (hash)',
required: false,
Expand Down Expand Up @@ -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 `<organization>:<provider_id>`, 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: {
Expand Down
106 changes: 102 additions & 4 deletions packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -63,6 +70,8 @@ import {
SysSession,
SysTeam,
SysTeamMember,
SysScimProvider,
SysSsoProvider,
SysTwoFactor,
SysUser,
SysVerification,
Expand All @@ -80,6 +89,7 @@ import {
buildPhoneNumberPluginSchema,
buildTwoFactorPluginSchema,
} from './auth-schema-config.js';
import { resolveProtocolName } from './objectql-adapter.js';

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

Expand All @@ -89,6 +99,9 @@ const PLATFORM_OBJECTS: Record<string, PlatformObject> = 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]),
);

Expand Down Expand Up @@ -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<string, { fields?: Record<string, unknown> }> }> = [
{ 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<string, { fields?: Record<string, unknown> }> },
{ label: 'scim', schema: (scim({} as never) as any).schema as Record<string, { fields?: Record<string, unknown> }> },
]) {
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([]);
});
}
}
});
Loading