Skip to content

Commit 13e5845

Browse files
os-zhuangclaude
andauthored
feat(auth): @better-auth/sso scaffold — per-env external IdP (OIDC/SAML) RP (#2322)
* feat(auth): @better-auth/sso — per-env external IdP (OIDC/SAML) relying party ADR-0024 V1: the OPEN per-env SSO mechanism. Lets an environment federate login to a customer's own OIDC/SAML IdP (Okta / Entra / Google), registered at runtime in the env (cloud-free for self-host). - platform-objects: new `sys_sso_provider` object mirroring @better-auth/sso @1.6.20's BaseSSOProvider (issuer, oidc_config/saml_config JSON blobs, user_id, provider_id, organization_id, domain); managedBy: better-auth; mutations via /api/v1/auth/sso/{register,delete-provider}. - plugin-auth: AUTH_SSO_PROVIDER_SCHEMA + buildSsoPluginSchema() (camelCase→ snake_case, per the buildOauthProviderPluginSchema template); auth-manager wires sso({ schema }) gated by enabled.sso (OS_SSO_ENABLED, mirrors OS_OIDC_PROVIDER_ENABLED). - package.json: add @better-auth/sso ^1.6.20. Follow-ups: provisionUser default-role, ssoClient() + admin UI (PR-2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): sso() takes no schema option; bridge ssoProvider via adapter map @better-auth/sso (1.6.20) exposes NO `schema` option (verified: no mergeSchema, runtime never reads options.schema), so the per-plugin schema-remap that oauthProvider uses is unavailable. Drop the invalid `schema` arg + the dead buildSsoPluginSchema(); call sso() bare (gated off by OS_SSO_ENABLED). Add ssoProvider -> sys_sso_provider to AUTH_MODEL_TO_PROTOCOL. KNOWN-INCOMPLETE (documented inline): the ACTIVE factory adapter (createObjectQLAdapterFactory) passes the raw better-auth model name to the data engine and maps fields only from per-model declarations — neither of which the sso plugin provides. Finishing the integration needs objectql-adapter to resolve schema-less plugin model + camelCase field names, then full E2E. Compiles green (DTS ok for plugin-auth + platform-objects); feature off by default. + pnpm-lock.yaml (@better-auth/sso ^1.6.20). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(auth): bridge schema-less plugin models in the factory adapter (sso) Make createObjectQLAdapterFactory resolve the table name (resolveProtocolName) AND map camelCase<->snake fields for models remapped via AUTH_MODEL_TO_PROTOCOL — i.e. better-auth plugins like @better-auth/sso that expose no `schema` option. Scoped by `objectName !== model`, so core / schema-declared models are byte-for-byte unchanged. This makes the @better-auth/sso integration (sys_sso_provider) functional at the data layer. Verified: 29 unit tests pass — 3 new sso-bridging tests drive the REAL better-auth adapter wrapper (sso plugin loaded): insert maps ssoProvider + oidcConfig -> sys_sso_provider + oidc_config, reads map back to camelCase, where fields are snaked; the 26 existing core-path tests are unchanged. Remaining: full browser E2E (login via a live external OIDC IdP -> JIT user + sys_account.provider_id + default role). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): register SysSsoProvider so the sys_sso_provider table provisions Without adding SysSsoProvider to authIdentityObjects (the manifest the Auth plugin provisions), the table is never created and the sso plugin's writes fail at runtime. Caught by the live E2E: with this, sys_sso_provider provisions with the correct snake columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump .objectui-sha to d6e7a84f7ccb32acad3fa435385ac15448df485e (SSO login button) Pulls in objectui#2000 (the "Sign in with SSO" login-page entry) so the framework's bundled @objectstack/console ships the SSO UI alongside this PR's @better-auth/sso backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c4a8c8 commit 13e5845

10 files changed

Lines changed: 525 additions & 27 deletions

File tree

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a3a5abff8c75d0629c3abe520dbe448edace3155
1+
d6e7a84f7ccb32acad3fa435385ac15448df485e

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ export { SysOauthAccessToken } from './sys-oauth-access-token.object.js';
3535
export { SysOauthRefreshToken } from './sys-oauth-refresh-token.object.js';
3636
export { SysOauthConsent } from './sys-oauth-consent.object.js';
3737
export { SysJwks } from './sys-jwks.object.js';
38+
39+
// ── External SSO (relying-party, @better-auth/sso) ─────────────────
40+
export { SysSsoProvider } from './sys-sso-provider.object.js';
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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_sso_provider — Registered external SSO identity provider (OIDC / SAML)
7+
*
8+
* Backed by `@better-auth/sso`'s `ssoProvider` model. Each row is an external
9+
* IdP that THIS environment federates LOGIN to (the relying-party / client
10+
* side) — e.g. the customer's Okta / Entra / Google Workspace. This is the
11+
* per-environment SSO **mechanism** (ADR-0024): OPEN, configured in the env,
12+
* and cloud-free for self-host.
13+
*
14+
* better-auth stores the protocol detail as a JSON blob in `oidc_config`
15+
* (OIDC: clientId, clientSecret, endpoints, scopes, mapping, pkce, …) or
16+
* `saml_config` (SAML: entryPoint, cert, identifierFormat, mapping, …) — not
17+
* as separate columns. Field set mirrors `@better-auth/sso@1.6.20`'s
18+
* `BaseSSOProvider`: issuer, oidcConfig, samlConfig, userId, providerId,
19+
* organizationId, domain.
20+
*
21+
* All mutations route through better-auth's `/api/v1/auth/sso/*` endpoints
22+
* (register / delete-provider) so config validation and secret handling run;
23+
* the generic data layer is read-only (see `enable.apiMethods`).
24+
*
25+
* @namespace sys
26+
*/
27+
export const SysSsoProvider = ObjectSchema.create({
28+
name: 'sys_sso_provider',
29+
label: 'SSO Provider',
30+
pluralLabel: 'SSO Providers',
31+
icon: 'shield-check',
32+
isSystem: true,
33+
managedBy: 'better-auth',
34+
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
35+
protection: {
36+
lock: 'full',
37+
reason: 'Identity table managed by better-auth (@better-auth/sso) — see ADR-0024.',
38+
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
39+
},
40+
description: 'External SSO identity providers (OIDC / SAML) this environment federates login to',
41+
displayNameField: 'provider_id',
42+
titleFormat: '{provider_id}',
43+
compactLayout: ['provider_id', 'issuer', 'domain'],
44+
45+
// All mutations go through @better-auth/sso's endpoints under
46+
// /api/v1/auth/sso/* (register / delete-provider) rather than the generic
47+
// data layer, so server-side config validation + secret handling run.
48+
actions: [
49+
{
50+
name: 'register_sso_provider',
51+
label: 'Register SSO Provider',
52+
icon: 'plus-circle',
53+
variant: 'primary',
54+
mode: 'create',
55+
locations: ['list_toolbar'],
56+
type: 'api',
57+
method: 'POST',
58+
target: '/api/v1/auth/sso/register',
59+
refreshAfter: true,
60+
params: [
61+
{ name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' },
62+
{ name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' },
63+
{ name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' },
64+
{ name: 'clientId', label: 'Client ID', type: 'text', required: true },
65+
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true },
66+
],
67+
},
68+
{
69+
name: 'delete_sso_provider',
70+
label: 'Delete SSO Provider',
71+
icon: 'trash-2',
72+
variant: 'danger',
73+
mode: 'delete',
74+
locations: ['list_item', 'record_header'],
75+
type: 'api',
76+
method: 'POST',
77+
target: '/api/v1/auth/sso/delete-provider',
78+
confirmText: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.',
79+
successMessage: 'SSO provider deleted',
80+
refreshAfter: true,
81+
params: [
82+
{ name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true },
83+
],
84+
},
85+
],
86+
87+
listViews: {
88+
all: {
89+
type: 'grid',
90+
name: 'all',
91+
label: 'All',
92+
data: { provider: 'object', object: 'sys_sso_provider' },
93+
columns: ['provider_id', 'issuer', 'domain', 'created_at'],
94+
sort: [{ field: 'provider_id', order: 'asc' }],
95+
pagination: { pageSize: 50 },
96+
},
97+
},
98+
99+
fields: {
100+
id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }),
101+
102+
provider_id: Field.text({
103+
label: 'Provider ID',
104+
required: true,
105+
searchable: true,
106+
maxLength: 255,
107+
description: 'Stable provider identifier (unique within the environment)',
108+
group: 'Identity',
109+
}),
110+
111+
issuer: Field.text({
112+
label: 'Issuer',
113+
required: true,
114+
maxLength: 2048,
115+
description: 'IdP issuer URL',
116+
group: 'Identity',
117+
}),
118+
119+
domain: Field.text({
120+
label: 'Email Domain',
121+
required: true,
122+
maxLength: 255,
123+
description: 'Email domain routed to this IdP (e.g. acme.com)',
124+
group: 'Identity',
125+
}),
126+
127+
oidc_config: Field.textarea({
128+
label: 'OIDC Config',
129+
required: false,
130+
description: 'JSON: clientId, clientSecret, endpoints, scopes, mapping, pkce (managed by better-auth)',
131+
group: 'Protocol',
132+
}),
133+
134+
saml_config: Field.textarea({
135+
label: 'SAML Config',
136+
required: false,
137+
description: 'JSON: entryPoint, cert, identifierFormat, mapping (managed by better-auth)',
138+
group: 'Protocol',
139+
}),
140+
141+
user_id: Field.lookup('sys_user', {
142+
label: 'Registered By',
143+
required: false,
144+
description: 'User who registered this provider',
145+
group: 'System',
146+
}),
147+
148+
organization_id: Field.text({
149+
label: 'Organization',
150+
required: false,
151+
maxLength: 255,
152+
description: 'Organization scope (when org-scoped SSO is used)',
153+
group: 'System',
154+
}),
155+
156+
created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true, group: 'System' }),
157+
updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true, group: 'System' }),
158+
},
159+
160+
indexes: [
161+
{ fields: ['provider_id'], unique: true },
162+
{ fields: ['domain'] },
163+
{ fields: ['user_id'] },
164+
],
165+
166+
enable: {
167+
trackHistory: true,
168+
searchable: true,
169+
apiEnabled: true,
170+
// Mutations go through /api/v1/auth/sso/* (register / delete-provider);
171+
// the generic data layer is read-only so sysadmins cannot bypass
172+
// server-side validation / secret handling.
173+
apiMethods: ['get', 'list'],
174+
trash: false,
175+
mru: false,
176+
},
177+
});

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/sso": "^1.6.20",
2324
"@noble/hashes": "^2.2.0",
2425
"@objectstack/core": "workspace:*",
2526
"@objectstack/platform-objects": "workspace:*",

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,8 @@ export class AuthManager {
625625
// override per-environment without touching the application bundle.
626626
const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
627627
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined;
628+
const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED;
629+
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined;
628630
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
629631
const enabled = {
630632
organization: pluginConfig.organization ?? true,
@@ -634,6 +636,7 @@ export class AuthManager {
634636
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
635637
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
636638
admin: pluginConfig.admin ?? false,
639+
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
637640
};
638641

639642
// bearer() — ALWAYS enabled.
@@ -926,6 +929,26 @@ export class AuthManager {
926929
}));
927930
}
928931

932+
// External SSO (OIDC / SAML) relying-party — lets this environment federate
933+
// login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime-
934+
// registered providers live in `sys_sso_provider` (ADR-0024: the OPEN SSO
935+
// mechanism — cloud-free for self-host). Endpoints mount under
936+
// /api/v1/auth/sso/{register,providers,delete-provider,callback,…}.
937+
//
938+
// Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`).
939+
if (enabled.sso) {
940+
const { sso } = await import('@better-auth/sso');
941+
// NOTE: unlike `oauthProvider`, @better-auth/sso hardcodes its `ssoProvider`
942+
// model and accepts NO `schema` option (verified against 1.6.20 — no
943+
// mergeSchema, runtime never reads options.schema). Its table mapping to
944+
// `sys_sso_provider` must therefore be resolved by the better-auth adapter
945+
// / a global model map, not per-plugin here (see AUTH_SSO_PROVIDER_SCHEMA;
946+
// TODO confirm the resolved table name in E2E).
947+
// provisionUser / organizationProvisioning will assign a default env role
948+
// on first federated login (ADR-0024 V1); JIT works via account linking.
949+
plugins.push(sso());
950+
}
951+
929952
// Device Authorization Grant (RFC 8628) — for CLI / TV-style devices.
930953
// Exposes the standard `/device/{code,token,approve,deny}` endpoints
931954
// and persists pending requests in `sys_device_code`.

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,44 @@ export function buildOauthProviderPluginSchema() {
650650
*/
651651
export const buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
652652

653+
// ---------------------------------------------------------------------------
654+
// SSO plugin – ssoProvider table (@better-auth/sso)
655+
// ---------------------------------------------------------------------------
656+
657+
/**
658+
* `@better-auth/sso` plugin `ssoProvider` model mapping.
659+
*
660+
* Each row is an external OIDC/SAML IdP this environment federates login to
661+
* (the relying-party side — ADR-0024's OPEN per-env SSO mechanism). The
662+
* protocol detail lives in JSON blobs (`oidcConfig` / `samlConfig`); the model
663+
* itself is thin. Mirrors @better-auth/sso@1.6.20's `BaseSSOProvider`.
664+
*
665+
* | camelCase (better-auth) | snake_case (ObjectStack) |
666+
* |:------------------------|:-------------------------|
667+
* | providerId | provider_id |
668+
* | oidcConfig | oidc_config |
669+
* | samlConfig | saml_config |
670+
* | userId | user_id |
671+
* | organizationId | organization_id |
672+
* | issuer / domain | (same name — no remap) |
673+
*/
674+
export const AUTH_SSO_PROVIDER_SCHEMA = {
675+
modelName: 'sys_sso_provider',
676+
fields: {
677+
providerId: 'provider_id',
678+
oidcConfig: 'oidc_config',
679+
samlConfig: 'saml_config',
680+
userId: 'user_id',
681+
organizationId: 'organization_id',
682+
},
683+
} as const;
684+
685+
// NOTE: there is intentionally no `buildSsoPluginSchema()`. Unlike
686+
// `oauthProvider`, the @better-auth/sso plugin exposes NO `schema` option
687+
// (verified vs 1.6.20), so the mapping above cannot be handed to the plugin —
688+
// it must be consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field
689+
// resolution in objectql-adapter.ts). See ADR-0024.
690+
653691
// ---------------------------------------------------------------------------
654692
// Helper: build device-authorization plugin schema option
655693
// ---------------------------------------------------------------------------

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
SysOauthRefreshToken,
2222
SysOrganization,
2323
SysSession,
24+
SysSsoProvider,
2425
SysTeam,
2526
SysTeamMember,
2627
SysTwoFactor,
@@ -52,6 +53,7 @@ export const authIdentityObjects: any[] = [
5253
SysOauthConsent,
5354
SysJwks,
5455
SysDeviceCode,
56+
SysSsoProvider,
5557
];
5658

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

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from './auth-schema-config';
2525
import { SystemObjectName } from '@objectstack/spec/system';
2626
import type { IDataEngine } from '@objectstack/core';
27+
import { sso } from '@better-auth/sso';
2728

2829
describe('AUTH_MODEL_TO_PROTOCOL mapping', () => {
2930
it('should map all four core better-auth models to sys_ protocol names', () => {
@@ -279,3 +280,46 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
279280
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
280281
});
281282
});
283+
284+
describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-auth/sso)', () => {
285+
// The sso plugin exposes no `schema` option, so its `ssoProvider` table +
286+
// camelCase fields are bridged at the adapter layer. Pass the plugin so
287+
// better-auth's wrapper recognises the model (it validates against the
288+
// merged schema before delegating to our adapter methods).
289+
const makeAdapter = (findOneRow: any = { id: '1', provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' }) => {
290+
const engine = {
291+
insert: vi.fn().mockImplementation((_m: string, d: any) => Promise.resolve({ id: '1', ...d })),
292+
findOne: vi.fn().mockResolvedValue(findOneRow),
293+
find: vi.fn().mockResolvedValue([]),
294+
count: vi.fn().mockResolvedValue(0),
295+
update: vi.fn().mockResolvedValue({ id: '1' }),
296+
delete: vi.fn().mockResolvedValue(undefined),
297+
} as unknown as IDataEngine;
298+
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({ plugins: [sso()] } as any);
299+
return { engine, adapter };
300+
};
301+
302+
it('resolveProtocolName bridges ssoProvider -> sys_sso_provider', () => {
303+
expect(resolveProtocolName('ssoProvider')).toBe('sys_sso_provider');
304+
expect(AUTH_MODEL_TO_PROTOCOL.ssoProvider).toBe('sys_sso_provider');
305+
});
306+
307+
it('maps the ssoProvider model + camelCase fields to sys_sso_provider snake columns on insert', async () => {
308+
const { engine, adapter } = makeAdapter();
309+
await adapter.create({ model: 'ssoProvider', data: { providerId: 'okta', oidcConfig: '{"clientId":"x"}', domain: 'acme.com' } });
310+
const [tbl, payload] = (engine.insert as any).mock.calls[0];
311+
expect(tbl).toBe('sys_sso_provider');
312+
expect(payload).toMatchObject({ provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' });
313+
expect(payload).not.toHaveProperty('oidcConfig');
314+
});
315+
316+
it('maps snake columns back to camelCase on read', async () => {
317+
const { adapter } = makeAdapter();
318+
const row: any = await adapter.findOne({
319+
model: 'ssoProvider',
320+
where: [{ field: 'providerId', value: 'okta', operator: 'eq', connector: 'AND' }],
321+
});
322+
expect(row).toMatchObject({ providerId: 'okta', oidcConfig: '{"clientId":"x"}' });
323+
expect(row).not.toHaveProperty('oidc_config');
324+
});
325+
});

0 commit comments

Comments
 (0)