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
3 changes: 3 additions & 0 deletions packages/platform-objects/src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
117 changes: 117 additions & 0 deletions packages/platform-objects/src/identity/sys-scim-provider.object.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
47 changes: 47 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
26 changes: 25 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
46 changes: 46 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>('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<any>('objectql');
Expand Down
31 changes: 31 additions & 0 deletions packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/plugin-auth/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SysOrganization,
SysSession,
SysSsoProvider,
SysScimProvider,
SysTeam,
SysTeamMember,
SysTwoFactor,
Expand Down Expand Up @@ -54,6 +55,7 @@ export const authIdentityObjects: any[] = [
SysJwks,
SysDeviceCode,
SysSsoProvider,
SysScimProvider,
];

/** Manifest header shared by compile-time config and runtime registration. */
Expand Down
16 changes: 8 additions & 8 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ export const AUTH_MODEL_TO_PROTOCOL: Record<string, string> = {
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',
};

/**
Expand Down
Loading