diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index 2627b192e..765c952f7 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -38,3 +38,6 @@ export { SysJwks } from './sys-jwks.object.js'; // ── External SSO (relying-party, @better-auth/sso) ───────────────── export { SysSsoProvider } from './sys-sso-provider.object.js'; + +// ── SCIM 2.0 provisioning (@better-auth/scim) ────────────────────── +export { SysScimProvider } from './sys-scim-provider.object.js'; diff --git a/packages/platform-objects/src/identity/sys-scim-provider.object.ts b/packages/platform-objects/src/identity/sys-scim-provider.object.ts new file mode 100644 index 000000000..3fd52248d --- /dev/null +++ b/packages/platform-objects/src/identity/sys-scim-provider.object.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_scim_provider — Registered SCIM 2.0 connection (@better-auth/scim) + * + * Backed by `@better-auth/scim`'s `scimProvider` model. Each row is a SCIM + * connection: a bearer token an external IdP (Okta / Entra / OneLogin) uses to + * **auto-provision / deprovision** THIS environment's users. The environment is + * the SCIM **Service Provider** (the receiver); the IdP is the SCIM **client** + * (the sender). This is the paid Identity lifecycle (ADR-0071) — the mechanism + * is OPEN (here, framework `plugin-auth`); enablement is entitlement-gated by + * the cloud / EE license. + * + * `scim_token` holds the connection's bearer credential. With the plugin's + * `storeSCIMToken: 'hashed'` (the default this env wires) it stores only a + * HASH — the plaintext is returned exactly once at `/scim/generate-token`. Even + * so, treat this object as sensitive: it is read-only over the generic data API + * and the token is excluded from list views. + * + * All mutations route through @better-auth/scim's endpoints under + * `/api/v1/auth/scim/*` (generate-token / delete-provider-connection) and the + * SCIM 2.0 protocol under `/api/v1/auth/scim/v2/*`; the generic data layer is + * read-only (see `enable.apiMethods`). + * + * @namespace sys + */ +export const SysScimProvider = ObjectSchema.create({ + name: 'sys_scim_provider', + label: 'SCIM Provider', + pluralLabel: 'SCIM Providers', + icon: 'users', + isSystem: true, + managedBy: 'better-auth', + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema. + protection: { + lock: 'full', + reason: 'Identity table managed by better-auth (@better-auth/scim) — see ADR-0071.', + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', + }, + description: 'SCIM 2.0 connections (bearer tokens) external IdPs use to provision/deprovision this environment\'s users', + displayNameField: 'provider_id', + titleFormat: '{provider_id}', + compactLayout: ['provider_id', 'organization_id'], + + listViews: { + all: { + type: 'grid', + name: 'all', + label: 'All', + data: { provider: 'object', object: 'sys_scim_provider' }, + // scim_token is intentionally excluded — never surface the credential. + columns: ['provider_id', 'organization_id', 'created_at'], + sort: [{ field: 'provider_id', order: 'asc' }], + pagination: { pageSize: 50 }, + }, + }, + + fields: { + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + + provider_id: Field.text({ + label: 'Provider ID', + required: true, + searchable: true, + maxLength: 255, + description: 'Stable SCIM provider identifier (e.g. "okta-scim")', + group: 'Identity', + }), + + scim_token: Field.text({ + label: 'SCIM Token (hash)', + required: false, + readonly: true, + maxLength: 1024, + description: 'Hashed bearer credential for this SCIM connection — the plaintext is shown once at generate-token. Sensitive; do not expose.', + group: 'Secret', + }), + + organization_id: Field.text({ + label: 'Organization', + required: false, + maxLength: 255, + description: 'Organization scope of this token (org-scoped tokens restrict provisioning to that org)', + group: 'System', + }), + + user_id: Field.lookup('sys_user', { + label: 'Owned By', + required: false, + description: 'User who generated this token (when provider-ownership is enabled)', + group: 'System', + }), + + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true, group: 'System' }), + updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true, group: 'System' }), + }, + + indexes: [ + { fields: ['provider_id'], unique: true }, + { fields: ['organization_id'] }, + { fields: ['user_id'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + // Mutations + token issuance go through @better-auth/scim's endpoints + // under /api/v1/auth/scim/*; the generic data layer is read-only so the + // credential cannot be written/bypassed through it. + apiMethods: ['list'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index ed253b4bd..50b39c7e2 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -20,6 +20,7 @@ "dependencies": { "@better-auth/core": "^1.6.20", "@better-auth/oauth-provider": "^1.6.20", + "@better-auth/scim": "^1.6.20", "@better-auth/sso": "^1.6.20", "@noble/hashes": "^2.2.0", "@objectstack/core": "workspace:*", diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index a2eec0b97..51dec9d1f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -422,6 +422,53 @@ describe('AuthManager', () => { expect(orgPlugin._opts.schema.session.fields.activeOrganizationId).toBe('active_organization_id'); }); + // @better-auth/scim mounts the SCIM 2.0 Service Provider so an external IdP + // can auto-provision/deprovision this env's users (ADR-0071). It is opt-in + // via OS_SCIM_ENABLED and FORCES the admin plugin on (active:false → ban + // runs through admin). + it('should NOT register the scim plugin by default', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + expect(capturedConfig.plugins.map((p: any) => p.id)).not.toContain('scim'); + }); + + it('should register the scim plugin (and force admin on) when OS_SCIM_ENABLED is set', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const prev = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + await manager.getAuthInstance(); + const ids = capturedConfig.plugins.map((p: any) => p.id); + expect(ids).toContain('scim'); + // active:false → ban needs the admin plugin; SCIM forces it on. + expect(ids).toContain('admin'); + } finally { + if (prev === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = prev; + warnSpy.mockRestore(); + } + }); + it('blocks slug change when the org has active environments', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 0056a8cb5..4027f1d87 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -822,6 +822,12 @@ export class AuthManager { const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; + const scimEnv = (globalThis as any)?.process?.env?.OS_SCIM_ENABLED; + const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === 'true' : undefined; + // @better-auth/scim's `active:false` → ban runs through the admin plugin, + // and org-scoped tokens need the organization plugin — so enabling SCIM + // forces `admin` on (organization already defaults on). See ADR-0071. + const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const enabled = { organization: pluginConfig.organization ?? true, @@ -830,8 +836,9 @@ export class AuthManager { magicLink: pluginConfig.magicLink ?? false, oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, deviceAuthorization: pluginConfig.deviceAuthorization ?? false, - admin: pluginConfig.admin ?? false, + admin: pluginConfig.admin ?? scimEffective, sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, + scim: scimEffective, }; // bearer() — ALWAYS enabled. @@ -1157,6 +1164,23 @@ export class AuthManager { plugins.push(sso()); } + // External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an + // external IdP (Okta / Entra) auto-provision / deprovision THIS env's users + // (the paid Identity lifecycle, ADR-0071). The env is the SCIM Service + // Provider; endpoints mount under /api/v1/auth/scim/v2/{Users,…} (SCIM 2.0) + // and /api/v1/auth/scim/{generate-token,…} (management). `active:false` → + // ban + session revoke (needs the admin plugin, forced on above); org-scoped + // tokens need the organization plugin. Like @better-auth/sso it hardcodes + // its `scimProvider` model (no schema option) — bridged to `sys_scim_provider` + // via AUTH_MODEL_TO_PROTOCOL. Toggle with `OS_SCIM_ENABLED`. + // + // storeSCIMToken: 'hashed' — never persist the bearer in cleartext; the + // plaintext is returned exactly once from generate-token (for the IdP admin). + if (enabled.scim) { + const { scim } = await import('@better-auth/scim'); + plugins.push(scim({ storeSCIMToken: 'hashed' })); + } + // Device Authorization Grant (RFC 8628) — for CLI / TV-style devices. // Exposes the standard `/device/{code,token,approve,deny}` endpoints // and persists pending requests in `sys_device_code`. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 0a498ec82..a8638279a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -345,6 +345,52 @@ export class AuthPlugin implements Plugin { await this.maybeSeedDevAdmin(ctx); }); + // Identity-source provenance for accounts created OUTSIDE better-auth's + // `databaseHooks` — @better-auth/scim creates `sys_account` at the adapter + // level, which BYPASSES `account.create.after` / `stampIdentitySource`. This + // ObjectQL `afterInsert` hook stamps `source=idp_provisioned` regardless of + // the creation path, so SCIM-provisioned users are correctly marked as the + // managed mirror (ADR-0024 D4 / ADR-0071 verification #1). It mirrors the + // federated branch of `stampIdentitySource`, is idempotent, and never breaks + // the insert. Complementary to (not a replacement for) the OAuth-path stamp. + ctx.hook('kernel:ready', async () => { + try { + // Use the kernel's ObjectQL engine (available + hookable at kernel:ready); + // the auth manager's getDataEngine() is not yet wired this early. + const engine: any = ctx.getService('objectql'); + if (!engine || typeof engine.registerHook !== 'function') return; + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + engine.registerHook('afterInsert', async (hookCtx: any) => { + try { + if (hookCtx?.object !== 'sys_account') return; + const acct: any = hookCtx.result ?? {}; + const providerId = acct.provider_id ?? acct.providerId; + const userId = acct.user_id ?? acct.userId; + // Only federated/SCIM accounts mark the user managed; a local + // password (`credential`) keeps the user env-native. + if (!userId || !providerId || providerId === 'credential') return; + // QueryAST options use `where` (not `filter`); a wrong key is silently + // ignored and counts every row — the bug that shipped env_native. + const credCount = await engine.count('sys_account', { + where: { user_id: userId, provider_id: 'credential' }, context: SYSTEM_CTX, + }); + if (typeof credCount === 'number' && credCount > 0) return; + const u = await engine.findOne('sys_user', { + where: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX, + }); + if (u && u.source !== 'idp_provisioned') { + await engine.update('sys_user', { id: userId, source: 'idp_provisioned' }, { context: SYSTEM_CTX }); + } + } catch { + // Provenance must never break account creation. + } + }, { packageId: 'com.objectstack.plugin-auth' }); + ctx.logger.info('Identity-source afterInsert stamp registered on sys_account (SCIM-safe)'); + } catch { + // Engine not available — skip; OAuth path still stamps via databaseHooks. + } + }); + // Register auth middleware on ObjectQL engine (if available) try { const ql = ctx.getService('objectql'); diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index aaf856008..b6a4fe51b 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -688,6 +688,37 @@ export const AUTH_SSO_PROVIDER_SCHEMA = { // it must be consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field // resolution in objectql-adapter.ts). See ADR-0024. +// --------------------------------------------------------------------------- +// SCIM plugin – scimProvider table (@better-auth/scim) +// --------------------------------------------------------------------------- + +/** + * `@better-auth/scim` plugin `scimProvider` model mapping. + * + * Each row is a SCIM connection: a bearer token an external IdP (Okta / Entra) + * uses to auto-provision / deprovision THIS environment's users — the env is + * the SCIM Service Provider (ADR-0071). Like `@better-auth/sso`, the plugin + * hardcodes its model and exposes NO `schema` option, so the mapping is + * consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field resolution in + * objectql-adapter.ts), NOT handed to the plugin. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | providerId | provider_id | + * | scimToken | scim_token | + * | organizationId | organization_id | + * | userId | user_id | + */ +export const AUTH_SCIM_PROVIDER_SCHEMA = { + modelName: 'sys_scim_provider', + fields: { + providerId: 'provider_id', + scimToken: 'scim_token', + organizationId: 'organization_id', + userId: 'user_id', + }, +} as const; + // --------------------------------------------------------------------------- // Helper: build device-authorization plugin schema option // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-auth/src/manifest.ts b/packages/plugins/plugin-auth/src/manifest.ts index 11814ed45..40017f65d 100644 --- a/packages/plugins/plugin-auth/src/manifest.ts +++ b/packages/plugins/plugin-auth/src/manifest.ts @@ -22,6 +22,7 @@ import { SysOrganization, SysSession, SysSsoProvider, + SysScimProvider, SysTeam, SysTeamMember, SysTwoFactor, @@ -54,6 +55,7 @@ export const authIdentityObjects: any[] = [ SysJwks, SysDeviceCode, SysSsoProvider, + SysScimProvider, ]; /** Manifest header shared by compile-time config and runtime registration. */ diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index d03aa64f0..ae2c12bf9 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -16,15 +16,15 @@ export const AUTH_MODEL_TO_PROTOCOL: Record = { session: SystemObjectName.SESSION, account: SystemObjectName.ACCOUNT, verification: SystemObjectName.VERIFICATION, - // @better-auth/sso has NO `schema` option (verified vs 1.6.20 — no - // mergeSchema, runtime never reads options.schema), so it cannot declare - // its modelName/fields. Bridge the table name here. NOTE: the ACTIVE - // factory adapter (createObjectQLAdapterFactory) passes the raw `model` - // to dataEngine and does NOT yet consult resolveProtocolName for plugin - // models — nor map sso's camelCase fields (oidcConfig→oidc_config …). - // Finishing the @better-auth/sso integration needs that adapter work + - // E2E (see ADR-0024 / sys_sso_provider). Off by default (OS_SSO_ENABLED). + // Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode + // their model name and accept NO `schema` option (verified vs 1.6.2x — no + // mergeSchema, runtime never reads options.schema), so the table name is + // bridged here and `createObjectQLAdapterFactory` (below) auto-maps their + // camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→ + // scim_token, …) on every CRUD op via resolveProtocolName. Off by default + // (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071. ssoProvider: 'sys_sso_provider', + scimProvider: 'sys_scim_provider', }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03da441dc..a08c48501 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1349,6 +1349,9 @@ importers: '@better-auth/oauth-provider': specifier: ^1.6.20 version: 1.6.20(8b7e5f0044af09e55a9541191cb216a7) + '@better-auth/scim': + specifier: ^1.6.20 + version: 1.6.22(dc20343214001cde89f611fe15bd9b19) '@better-auth/sso': specifier: ^1.6.20 version: 1.6.20(8b7e5f0044af09e55a9541191cb216a7) @@ -2612,6 +2615,14 @@ packages: prisma: optional: true + '@better-auth/scim@1.6.22': + resolution: {integrity: sha512-VC3I1S1wGqbx1fLCEkH5vkTfORyEbm7vX1G9SwwIHIG8GsaLb0kiwnRgK08o+ZDcq8ym+soXoMofQhF1htcfhA==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + better-auth: ^1.6.22 + better-call: 1.3.7 + '@better-auth/sso@1.6.20': resolution: {integrity: sha512-cT3dthGkfDAz/k9jTtBOfsBtxsEgbO2hs5nSQlb0FdO3L/8MLi19iaIoQd1dmEun93MNqenZmYEZrPDKWWiISQ==} peerDependencies: @@ -9300,6 +9311,14 @@ snapshots: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 + '@better-auth/scim@1.6.22(dc20343214001cde89f611fe15bd9b19)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + better-auth: 1.6.20(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.66.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.3(@typescript-eslint/types@8.61.1))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.55.3(@typescript-eslint/types@8.61.1))(typescript@6.0.3)(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(better-sqlite3@12.11.1)(mongodb@7.3.0(socks@2.8.9))(mysql2@3.22.4(@types/node@26.0.0))(next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(svelte@5.55.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9) + better-call: 1.3.6(zod@4.4.3) + zod: 4.4.3 + '@better-auth/sso@1.6.20(8b7e5f0044af09e55a9541191cb216a7)': dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260520.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)