Skip to content

Commit 911c7aa

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(plugin-auth): env-side SCIM 2.0 via @better-auth/scim (ADR-0071, OPEN mechanism) (#2356)
* feat(plugin-auth): install @better-auth/scim — env-side SCIM 2.0 Service Provider (ADR-0071) Adds the OPEN SCIM mechanism so an external IdP (Okta/Entra) can auto- provision/deprovision this environment's users. Mirrors the @better-auth/sso wiring: - add @better-auth/scim dep; push scim({storeSCIMToken:'hashed'}) under OS_SCIM_ENABLED; force the admin plugin on (active:false -> ban needs it) - map scimProvider -> sys_scim_provider in AUTH_MODEL_TO_PROTOCOL (the adapter auto-bridges camelCase->snake_case); fix the now-stale adapter TODO comment - new sys_scim_provider object (token stored hashed, read-only over data API) - AUTH_SCIM_PROVIDER_SCHEMA field map; export SysScimProvider Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(plugin-auth): register sys_scim_provider in the auth manifest Exporting the object from platform-objects/identity is necessary but not sufficient — schema provisioning is driven by plugin-auth's explicit authIdentityObjects list (the D7 compile-time + runtime registration). Without this the table was never created and @better-auth/scim's generate-token 500'd on `no such table: sys_scim_provider`. Verified E2E: token gen, provision (201), deprovision (active:false -> banned), list — all green on the objectos-ee rig. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(plugin-auth): stamp source=idp_provisioned for SCIM-provisioned users @better-auth/scim creates sys_account bypassing better-auth's databaseHooks.account.create.after, so stampIdentitySource never fired and SCIM users landed source=env_native instead of idp_provisioned (ADR-0024 D4). Fix: register an ObjectQL afterInsert hook on sys_account at kernel:ready that stamps idp_provisioned for any non-credential account whose user has no local credential — robust to the creation path (catches SCIM and OAuth alike), idempotent, and never breaks the insert. Uses ctx.getService('objectql') (the auth manager's getDataEngine() is not wired at kernel:ready) and the QueryAST `where` key (not `filter` — a wrong key is silently ignored and counts every row, the latent bug also present in stampIdentitySource's federated branch). Verified E2E on the objectos-ee rig: SCIM-provisioned user -> idp_provisioned; local dev admin (credential) stays env_native. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(plugin-auth): cover the @better-auth/scim wiring (off by default; OS_SCIM_ENABLED forces scim + admin) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 914aaa9 commit 911c7aa

10 files changed

Lines changed: 299 additions & 9 deletions

File tree

packages/platform-objects/src/identity/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ export { SysJwks } from './sys-jwks.object.js';
3838

3939
// ── External SSO (relying-party, @better-auth/sso) ─────────────────
4040
export { SysSsoProvider } from './sys-sso-provider.object.js';
41+
42+
// ── SCIM 2.0 provisioning (@better-auth/scim) ──────────────────────
43+
export { SysScimProvider } from './sys-scim-provider.object.js';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_scim_provider — Registered SCIM 2.0 connection (@better-auth/scim)
7+
*
8+
* Backed by `@better-auth/scim`'s `scimProvider` model. Each row is a SCIM
9+
* connection: a bearer token an external IdP (Okta / Entra / OneLogin) uses to
10+
* **auto-provision / deprovision** THIS environment's users. The environment is
11+
* the SCIM **Service Provider** (the receiver); the IdP is the SCIM **client**
12+
* (the sender). This is the paid Identity lifecycle (ADR-0071) — the mechanism
13+
* is OPEN (here, framework `plugin-auth`); enablement is entitlement-gated by
14+
* the cloud / EE license.
15+
*
16+
* `scim_token` holds the connection's bearer credential. With the plugin's
17+
* `storeSCIMToken: 'hashed'` (the default this env wires) it stores only a
18+
* HASH — the plaintext is returned exactly once at `/scim/generate-token`. Even
19+
* so, treat this object as sensitive: it is read-only over the generic data API
20+
* and the token is excluded from list views.
21+
*
22+
* All mutations route through @better-auth/scim's endpoints under
23+
* `/api/v1/auth/scim/*` (generate-token / delete-provider-connection) and the
24+
* SCIM 2.0 protocol under `/api/v1/auth/scim/v2/*`; the generic data layer is
25+
* read-only (see `enable.apiMethods`).
26+
*
27+
* @namespace sys
28+
*/
29+
export const SysScimProvider = ObjectSchema.create({
30+
name: 'sys_scim_provider',
31+
label: 'SCIM Provider',
32+
pluralLabel: 'SCIM Providers',
33+
icon: 'users',
34+
isSystem: true,
35+
managedBy: 'better-auth',
36+
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
37+
protection: {
38+
lock: 'full',
39+
reason: 'Identity table managed by better-auth (@better-auth/scim) — see ADR-0071.',
40+
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
41+
},
42+
description: 'SCIM 2.0 connections (bearer tokens) external IdPs use to provision/deprovision this environment\'s users',
43+
displayNameField: 'provider_id',
44+
titleFormat: '{provider_id}',
45+
compactLayout: ['provider_id', 'organization_id'],
46+
47+
listViews: {
48+
all: {
49+
type: 'grid',
50+
name: 'all',
51+
label: 'All',
52+
data: { provider: 'object', object: 'sys_scim_provider' },
53+
// scim_token is intentionally excluded — never surface the credential.
54+
columns: ['provider_id', 'organization_id', 'created_at'],
55+
sort: [{ field: 'provider_id', order: 'asc' }],
56+
pagination: { pageSize: 50 },
57+
},
58+
},
59+
60+
fields: {
61+
id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }),
62+
63+
provider_id: Field.text({
64+
label: 'Provider ID',
65+
required: true,
66+
searchable: true,
67+
maxLength: 255,
68+
description: 'Stable SCIM provider identifier (e.g. "okta-scim")',
69+
group: 'Identity',
70+
}),
71+
72+
scim_token: Field.text({
73+
label: 'SCIM Token (hash)',
74+
required: false,
75+
readonly: true,
76+
maxLength: 1024,
77+
description: 'Hashed bearer credential for this SCIM connection — the plaintext is shown once at generate-token. Sensitive; do not expose.',
78+
group: 'Secret',
79+
}),
80+
81+
organization_id: Field.text({
82+
label: 'Organization',
83+
required: false,
84+
maxLength: 255,
85+
description: 'Organization scope of this token (org-scoped tokens restrict provisioning to that org)',
86+
group: 'System',
87+
}),
88+
89+
user_id: Field.lookup('sys_user', {
90+
label: 'Owned By',
91+
required: false,
92+
description: 'User who generated this token (when provider-ownership is enabled)',
93+
group: 'System',
94+
}),
95+
96+
created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true, group: 'System' }),
97+
updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true, group: 'System' }),
98+
},
99+
100+
indexes: [
101+
{ fields: ['provider_id'], unique: true },
102+
{ fields: ['organization_id'] },
103+
{ fields: ['user_id'] },
104+
],
105+
106+
enable: {
107+
trackHistory: true,
108+
searchable: false,
109+
apiEnabled: true,
110+
// Mutations + token issuance go through @better-auth/scim's endpoints
111+
// under /api/v1/auth/scim/*; the generic data layer is read-only so the
112+
// credential cannot be written/bypassed through it.
113+
apiMethods: ['list'],
114+
trash: false,
115+
mru: false,
116+
},
117+
});

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@better-auth/core": "^1.6.20",
2222
"@better-auth/oauth-provider": "^1.6.20",
23+
"@better-auth/scim": "^1.6.20",
2324
"@better-auth/sso": "^1.6.20",
2425
"@noble/hashes": "^2.2.0",
2526
"@objectstack/core": "workspace:*",

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,53 @@ describe('AuthManager', () => {
422422
expect(orgPlugin._opts.schema.session.fields.activeOrganizationId).toBe('active_organization_id');
423423
});
424424

425+
// @better-auth/scim mounts the SCIM 2.0 Service Provider so an external IdP
426+
// can auto-provision/deprovision this env's users (ADR-0071). It is opt-in
427+
// via OS_SCIM_ENABLED and FORCES the admin plugin on (active:false → ban
428+
// runs through admin).
429+
it('should NOT register the scim plugin by default', async () => {
430+
let capturedConfig: any;
431+
(betterAuth as any).mockImplementation((config: any) => {
432+
capturedConfig = config;
433+
return { handler: vi.fn(), api: {} };
434+
});
435+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
436+
const manager = new AuthManager({
437+
secret: 'test-secret-at-least-32-chars-long',
438+
baseUrl: 'http://localhost:3000',
439+
});
440+
await manager.getAuthInstance();
441+
warnSpy.mockRestore();
442+
443+
expect(capturedConfig.plugins.map((p: any) => p.id)).not.toContain('scim');
444+
});
445+
446+
it('should register the scim plugin (and force admin on) when OS_SCIM_ENABLED is set', async () => {
447+
let capturedConfig: any;
448+
(betterAuth as any).mockImplementation((config: any) => {
449+
capturedConfig = config;
450+
return { handler: vi.fn(), api: {} };
451+
});
452+
const prev = process.env.OS_SCIM_ENABLED;
453+
process.env.OS_SCIM_ENABLED = 'true';
454+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
455+
try {
456+
const manager = new AuthManager({
457+
secret: 'test-secret-at-least-32-chars-long',
458+
baseUrl: 'http://localhost:3000',
459+
});
460+
await manager.getAuthInstance();
461+
const ids = capturedConfig.plugins.map((p: any) => p.id);
462+
expect(ids).toContain('scim');
463+
// active:false → ban needs the admin plugin; SCIM forces it on.
464+
expect(ids).toContain('admin');
465+
} finally {
466+
if (prev === undefined) delete process.env.OS_SCIM_ENABLED;
467+
else process.env.OS_SCIM_ENABLED = prev;
468+
warnSpy.mockRestore();
469+
}
470+
});
471+
425472
it('blocks slug change when the org has active environments', async () => {
426473
let capturedConfig: any;
427474
(betterAuth as any).mockImplementation((config: any) => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,12 @@ export class AuthManager {
822822
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined;
823823
const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED;
824824
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined;
825+
const scimEnv = (globalThis as any)?.process?.env?.OS_SCIM_ENABLED;
826+
const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === 'true' : undefined;
827+
// @better-auth/scim's `active:false` → ban runs through the admin plugin,
828+
// and org-scoped tokens need the organization plugin — so enabling SCIM
829+
// forces `admin` on (organization already defaults on). See ADR-0071.
830+
const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false;
825831
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
826832
const enabled = {
827833
organization: pluginConfig.organization ?? true,
@@ -830,8 +836,9 @@ export class AuthManager {
830836
magicLink: pluginConfig.magicLink ?? false,
831837
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
832838
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
833-
admin: pluginConfig.admin ?? false,
839+
admin: pluginConfig.admin ?? scimEffective,
834840
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
841+
scim: scimEffective,
835842
};
836843

837844
// bearer() — ALWAYS enabled.
@@ -1157,6 +1164,23 @@ export class AuthManager {
11571164
plugins.push(sso());
11581165
}
11591166

1167+
// External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an
1168+
// external IdP (Okta / Entra) auto-provision / deprovision THIS env's users
1169+
// (the paid Identity lifecycle, ADR-0071). The env is the SCIM Service
1170+
// Provider; endpoints mount under /api/v1/auth/scim/v2/{Users,…} (SCIM 2.0)
1171+
// and /api/v1/auth/scim/{generate-token,…} (management). `active:false` →
1172+
// ban + session revoke (needs the admin plugin, forced on above); org-scoped
1173+
// tokens need the organization plugin. Like @better-auth/sso it hardcodes
1174+
// its `scimProvider` model (no schema option) — bridged to `sys_scim_provider`
1175+
// via AUTH_MODEL_TO_PROTOCOL. Toggle with `OS_SCIM_ENABLED`.
1176+
//
1177+
// storeSCIMToken: 'hashed' — never persist the bearer in cleartext; the
1178+
// plaintext is returned exactly once from generate-token (for the IdP admin).
1179+
if (enabled.scim) {
1180+
const { scim } = await import('@better-auth/scim');
1181+
plugins.push(scim({ storeSCIMToken: 'hashed' }));
1182+
}
1183+
11601184
// Device Authorization Grant (RFC 8628) — for CLI / TV-style devices.
11611185
// Exposes the standard `/device/{code,token,approve,deny}` endpoints
11621186
// and persists pending requests in `sys_device_code`.

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,52 @@ export class AuthPlugin implements Plugin {
345345
await this.maybeSeedDevAdmin(ctx);
346346
});
347347

348+
// Identity-source provenance for accounts created OUTSIDE better-auth's
349+
// `databaseHooks` — @better-auth/scim creates `sys_account` at the adapter
350+
// level, which BYPASSES `account.create.after` / `stampIdentitySource`. This
351+
// ObjectQL `afterInsert` hook stamps `source=idp_provisioned` regardless of
352+
// the creation path, so SCIM-provisioned users are correctly marked as the
353+
// managed mirror (ADR-0024 D4 / ADR-0071 verification #1). It mirrors the
354+
// federated branch of `stampIdentitySource`, is idempotent, and never breaks
355+
// the insert. Complementary to (not a replacement for) the OAuth-path stamp.
356+
ctx.hook('kernel:ready', async () => {
357+
try {
358+
// Use the kernel's ObjectQL engine (available + hookable at kernel:ready);
359+
// the auth manager's getDataEngine() is not yet wired this early.
360+
const engine: any = ctx.getService<any>('objectql');
361+
if (!engine || typeof engine.registerHook !== 'function') return;
362+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
363+
engine.registerHook('afterInsert', async (hookCtx: any) => {
364+
try {
365+
if (hookCtx?.object !== 'sys_account') return;
366+
const acct: any = hookCtx.result ?? {};
367+
const providerId = acct.provider_id ?? acct.providerId;
368+
const userId = acct.user_id ?? acct.userId;
369+
// Only federated/SCIM accounts mark the user managed; a local
370+
// password (`credential`) keeps the user env-native.
371+
if (!userId || !providerId || providerId === 'credential') return;
372+
// QueryAST options use `where` (not `filter`); a wrong key is silently
373+
// ignored and counts every row — the bug that shipped env_native.
374+
const credCount = await engine.count('sys_account', {
375+
where: { user_id: userId, provider_id: 'credential' }, context: SYSTEM_CTX,
376+
});
377+
if (typeof credCount === 'number' && credCount > 0) return;
378+
const u = await engine.findOne('sys_user', {
379+
where: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX,
380+
});
381+
if (u && u.source !== 'idp_provisioned') {
382+
await engine.update('sys_user', { id: userId, source: 'idp_provisioned' }, { context: SYSTEM_CTX });
383+
}
384+
} catch {
385+
// Provenance must never break account creation.
386+
}
387+
}, { packageId: 'com.objectstack.plugin-auth' });
388+
ctx.logger.info('Identity-source afterInsert stamp registered on sys_account (SCIM-safe)');
389+
} catch {
390+
// Engine not available — skip; OAuth path still stamps via databaseHooks.
391+
}
392+
});
393+
348394
// Register auth middleware on ObjectQL engine (if available)
349395
try {
350396
const ql = ctx.getService<any>('objectql');

packages/plugins/plugin-auth/src/auth-schema-config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,37 @@ export const AUTH_SSO_PROVIDER_SCHEMA = {
688688
// it must be consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field
689689
// resolution in objectql-adapter.ts). See ADR-0024.
690690

691+
// ---------------------------------------------------------------------------
692+
// SCIM plugin – scimProvider table (@better-auth/scim)
693+
// ---------------------------------------------------------------------------
694+
695+
/**
696+
* `@better-auth/scim` plugin `scimProvider` model mapping.
697+
*
698+
* Each row is a SCIM connection: a bearer token an external IdP (Okta / Entra)
699+
* uses to auto-provision / deprovision THIS environment's users — the env is
700+
* the SCIM Service Provider (ADR-0071). Like `@better-auth/sso`, the plugin
701+
* hardcodes its model and exposes NO `schema` option, so the mapping is
702+
* consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field resolution in
703+
* objectql-adapter.ts), NOT handed to the plugin.
704+
*
705+
* | camelCase (better-auth) | snake_case (ObjectStack) |
706+
* |:------------------------|:-------------------------|
707+
* | providerId | provider_id |
708+
* | scimToken | scim_token |
709+
* | organizationId | organization_id |
710+
* | userId | user_id |
711+
*/
712+
export const AUTH_SCIM_PROVIDER_SCHEMA = {
713+
modelName: 'sys_scim_provider',
714+
fields: {
715+
providerId: 'provider_id',
716+
scimToken: 'scim_token',
717+
organizationId: 'organization_id',
718+
userId: 'user_id',
719+
},
720+
} as const;
721+
691722
// ---------------------------------------------------------------------------
692723
// Helper: build device-authorization plugin schema option
693724
// ---------------------------------------------------------------------------

packages/plugins/plugin-auth/src/manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SysOrganization,
2323
SysSession,
2424
SysSsoProvider,
25+
SysScimProvider,
2526
SysTeam,
2627
SysTeamMember,
2728
SysTwoFactor,
@@ -54,6 +55,7 @@ export const authIdentityObjects: any[] = [
5455
SysJwks,
5556
SysDeviceCode,
5657
SysSsoProvider,
58+
SysScimProvider,
5759
];
5860

5961
/** Manifest header shared by compile-time config and runtime registration. */

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export const AUTH_MODEL_TO_PROTOCOL: Record<string, string> = {
1616
session: SystemObjectName.SESSION,
1717
account: SystemObjectName.ACCOUNT,
1818
verification: SystemObjectName.VERIFICATION,
19-
// @better-auth/sso has NO `schema` option (verified vs 1.6.20 — no
20-
// mergeSchema, runtime never reads options.schema), so it cannot declare
21-
// its modelName/fields. Bridge the table name here. NOTE: the ACTIVE
22-
// factory adapter (createObjectQLAdapterFactory) passes the raw `model`
23-
// to dataEngine and does NOT yet consult resolveProtocolName for plugin
24-
// models — nor map sso's camelCase fields (oidcConfig→oidc_config …).
25-
// Finishing the @better-auth/sso integration needs that adapter work +
26-
// E2E (see ADR-0024 / sys_sso_provider). Off by default (OS_SSO_ENABLED).
19+
// Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode
20+
// their model name and accept NO `schema` option (verified vs 1.6.2x — no
21+
// mergeSchema, runtime never reads options.schema), so the table name is
22+
// bridged here and `createObjectQLAdapterFactory` (below) auto-maps their
23+
// camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→
24+
// scim_token, …) on every CRUD op via resolveProtocolName. Off by default
25+
// (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071.
2726
ssoProvider: 'sys_sso_provider',
27+
scimProvider: 'sys_scim_provider',
2828
};
2929

3030
/**

0 commit comments

Comments
 (0)