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
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
a3a5abff8c75d0629c3abe520dbe448edace3155
d6e7a84f7ccb32acad3fa435385ac15448df485e
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 @@ -35,3 +35,6 @@ export { SysOauthAccessToken } from './sys-oauth-access-token.object.js';
export { SysOauthRefreshToken } from './sys-oauth-refresh-token.object.js';
export { SysOauthConsent } from './sys-oauth-consent.object.js';
export { SysJwks } from './sys-jwks.object.js';

// ── External SSO (relying-party, @better-auth/sso) ─────────────────
export { SysSsoProvider } from './sys-sso-provider.object.js';
177 changes: 177 additions & 0 deletions packages/platform-objects/src/identity/sys-sso-provider.object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
* sys_sso_provider — Registered external SSO identity provider (OIDC / SAML)
*
* Backed by `@better-auth/sso`'s `ssoProvider` model. Each row is an external
* IdP that THIS environment federates LOGIN to (the relying-party / client
* side) — e.g. the customer's Okta / Entra / Google Workspace. This is the
* per-environment SSO **mechanism** (ADR-0024): OPEN, configured in the env,
* and cloud-free for self-host.
*
* better-auth stores the protocol detail as a JSON blob in `oidc_config`
* (OIDC: clientId, clientSecret, endpoints, scopes, mapping, pkce, …) or
* `saml_config` (SAML: entryPoint, cert, identifierFormat, mapping, …) — not
* as separate columns. Field set mirrors `@better-auth/sso@1.6.20`'s
* `BaseSSOProvider`: issuer, oidcConfig, samlConfig, userId, providerId,
* organizationId, domain.
*
* All mutations route through better-auth's `/api/v1/auth/sso/*` endpoints
* (register / delete-provider) so config validation and secret handling run;
* the generic data layer is read-only (see `enable.apiMethods`).
*
* @namespace sys
*/
export const SysSsoProvider = ObjectSchema.create({
name: 'sys_sso_provider',
label: 'SSO Provider',
pluralLabel: 'SSO Providers',
icon: 'shield-check',
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/sso) — see ADR-0024.',
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
},
description: 'External SSO identity providers (OIDC / SAML) this environment federates login to',
displayNameField: 'provider_id',
titleFormat: '{provider_id}',
compactLayout: ['provider_id', 'issuer', 'domain'],

// All mutations go through @better-auth/sso's endpoints under
// /api/v1/auth/sso/* (register / delete-provider) rather than the generic
// data layer, so server-side config validation + secret handling run.
actions: [
{
name: 'register_sso_provider',
label: 'Register SSO Provider',
icon: 'plus-circle',
variant: 'primary',
mode: 'create',
locations: ['list_toolbar'],
type: 'api',
method: 'POST',
target: '/api/v1/auth/sso/register',
refreshAfter: true,
params: [
{ name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' },
{ name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' },
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' },
{ name: 'clientId', label: 'Client ID', type: 'text', required: true },
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true },
],
},
{
name: 'delete_sso_provider',
label: 'Delete SSO Provider',
icon: 'trash-2',
variant: 'danger',
mode: 'delete',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'POST',
target: '/api/v1/auth/sso/delete-provider',
confirmText: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.',
successMessage: 'SSO provider deleted',
refreshAfter: true,
params: [
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
],
},
],

listViews: {
all: {
type: 'grid',
name: 'all',
label: 'All',
data: { provider: 'object', object: 'sys_sso_provider' },
columns: ['provider_id', 'issuer', 'domain', '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 provider identifier (unique within the environment)',
group: 'Identity',
}),

issuer: Field.text({
label: 'Issuer',
required: true,
maxLength: 2048,
description: 'IdP issuer URL',
group: 'Identity',
}),

domain: Field.text({
label: 'Email Domain',
required: true,
maxLength: 255,
description: 'Email domain routed to this IdP (e.g. acme.com)',
group: 'Identity',
}),

oidc_config: Field.textarea({
label: 'OIDC Config',
required: false,
description: 'JSON: clientId, clientSecret, endpoints, scopes, mapping, pkce (managed by better-auth)',
group: 'Protocol',
}),

saml_config: Field.textarea({
label: 'SAML Config',
required: false,
description: 'JSON: entryPoint, cert, identifierFormat, mapping (managed by better-auth)',
group: 'Protocol',
}),

user_id: Field.lookup('sys_user', {
label: 'Registered By',
required: false,
description: 'User who registered this provider',
group: 'System',
}),

organization_id: Field.text({
label: 'Organization',
required: false,
maxLength: 255,
description: 'Organization scope (when org-scoped SSO is used)',
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: ['domain'] },
{ fields: ['user_id'] },
],

enable: {
trackHistory: true,
searchable: true,
apiEnabled: true,
// Mutations go through /api/v1/auth/sso/* (register / delete-provider);
// the generic data layer is read-only so sysadmins cannot bypass
// server-side validation / secret handling.
apiMethods: ['get', '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/sso": "^1.6.20",
"@noble/hashes": "^2.2.0",
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
Expand Down
23 changes: 23 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,8 @@ export class AuthManager {
// override per-environment without touching the application bundle.
const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
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 twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
const enabled = {
organization: pluginConfig.organization ?? true,
Expand All @@ -634,6 +636,7 @@ export class AuthManager {
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
admin: pluginConfig.admin ?? false,
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
};

// bearer() — ALWAYS enabled.
Expand Down Expand Up @@ -926,6 +929,26 @@ export class AuthManager {
}));
}

// External SSO (OIDC / SAML) relying-party — lets this environment federate
// login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime-
// registered providers live in `sys_sso_provider` (ADR-0024: the OPEN SSO
// mechanism — cloud-free for self-host). Endpoints mount under
// /api/v1/auth/sso/{register,providers,delete-provider,callback,…}.
//
// Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`).
if (enabled.sso) {
const { sso } = await import('@better-auth/sso');
// NOTE: unlike `oauthProvider`, @better-auth/sso hardcodes its `ssoProvider`
// model and accepts NO `schema` option (verified against 1.6.20 — no
// mergeSchema, runtime never reads options.schema). Its table mapping to
// `sys_sso_provider` must therefore be resolved by the better-auth adapter
// / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA;
// TODO confirm the resolved table name in E2E).
// provisionUser / organizationProvisioning will assign a default env role
// on first federated login (ADR-0024 V1); JIT works via account linking.
plugins.push(sso());
}

// 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
38 changes: 38 additions & 0 deletions packages/plugins/plugin-auth/src/auth-schema-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,44 @@ export function buildOauthProviderPluginSchema() {
*/
export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;

// ---------------------------------------------------------------------------
// SSO plugin – ssoProvider table (@better-auth/sso)
// ---------------------------------------------------------------------------

/**
* `@better-auth/sso` plugin `ssoProvider` model mapping.
*
* Each row is an external OIDC/SAML IdP this environment federates login to
* (the relying-party side — ADR-0024's OPEN per-env SSO mechanism). The
* protocol detail lives in JSON blobs (`oidcConfig` / `samlConfig`); the model
* itself is thin. Mirrors @better-auth/sso@1.6.20's `BaseSSOProvider`.
*
* | camelCase (better-auth) | snake_case (ObjectStack) |
* |:------------------------|:-------------------------|
* | providerId | provider_id |
* | oidcConfig | oidc_config |
* | samlConfig | saml_config |
* | userId | user_id |
* | organizationId | organization_id |
* | issuer / domain | (same name — no remap) |
*/
export const AUTH_SSO_PROVIDER_SCHEMA = {
modelName: 'sys_sso_provider',
fields: {
providerId: 'provider_id',
oidcConfig: 'oidc_config',
samlConfig: 'saml_config',
userId: 'user_id',
organizationId: 'organization_id',
},
} as const;

// NOTE: there is intentionally no `buildSsoPluginSchema()`. Unlike
// `oauthProvider`, the @better-auth/sso plugin exposes NO `schema` option
// (verified vs 1.6.20), so the mapping above cannot be handed to the plugin —
// it must be consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field
// resolution in objectql-adapter.ts). See ADR-0024.

// ---------------------------------------------------------------------------
// 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 @@ -21,6 +21,7 @@ import {
SysOauthRefreshToken,
SysOrganization,
SysSession,
SysSsoProvider,
SysTeam,
SysTeamMember,
SysTwoFactor,
Expand Down Expand Up @@ -52,6 +53,7 @@ export const authIdentityObjects: any[] = [
SysOauthConsent,
SysJwks,
SysDeviceCode,
SysSsoProvider,
];

/** Manifest header shared by compile-time config and runtime registration. */
Expand Down
44 changes: 44 additions & 0 deletions packages/plugins/plugin-auth/src/objectql-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './auth-schema-config';
import { SystemObjectName } from '@objectstack/spec/system';
import type { IDataEngine } from '@objectstack/core';
import { sso } from '@better-auth/sso';

describe('AUTH_MODEL_TO_PROTOCOL mapping', () => {
it('should map all four core better-auth models to sys_ protocol names', () => {
Expand Down Expand Up @@ -279,3 +280,46 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
});
});

describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-auth/sso)', () => {
// The sso plugin exposes no `schema` option, so its `ssoProvider` table +
// camelCase fields are bridged at the adapter layer. Pass the plugin so
// better-auth's wrapper recognises the model (it validates against the
// merged schema before delegating to our adapter methods).
const makeAdapter = (findOneRow: any = { id: '1', provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' }) => {
const engine = {
insert: vi.fn().mockImplementation((_m: string, d: any) => Promise.resolve({ id: '1', ...d })),
findOne: vi.fn().mockResolvedValue(findOneRow),
find: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn().mockResolvedValue({ id: '1' }),
delete: vi.fn().mockResolvedValue(undefined),
} as unknown as IDataEngine;
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({ plugins: [sso()] } as any);
return { engine, adapter };
};

it('resolveProtocolName bridges ssoProvider -> sys_sso_provider', () => {
expect(resolveProtocolName('ssoProvider')).toBe('sys_sso_provider');
expect(AUTH_MODEL_TO_PROTOCOL.ssoProvider).toBe('sys_sso_provider');
});

it('maps the ssoProvider model + camelCase fields to sys_sso_provider snake columns on insert', async () => {
const { engine, adapter } = makeAdapter();
await adapter.create({ model: 'ssoProvider', data: { providerId: 'okta', oidcConfig: '{"clientId":"x"}', domain: 'acme.com' } });
const [tbl, payload] = (engine.insert as any).mock.calls[0];
expect(tbl).toBe('sys_sso_provider');
expect(payload).toMatchObject({ provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' });
expect(payload).not.toHaveProperty('oidcConfig');
});

it('maps snake columns back to camelCase on read', async () => {
const { adapter } = makeAdapter();
const row: any = await adapter.findOne({
model: 'ssoProvider',
where: [{ field: 'providerId', value: 'okta', operator: 'eq', connector: 'AND' }],
});
expect(row).toMatchObject({ providerId: 'okta', oidcConfig: '{"clientId":"x"}' });
expect(row).not.toHaveProperty('oidc_config');
});
});
Loading