Skip to content

Commit 367bbf4

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 60d48a0 commit 367bbf4

5 files changed

Lines changed: 247 additions & 0 deletions

File tree

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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
buildOrganizationPluginSchema,
2424
buildTwoFactorPluginSchema,
2525
buildOauthProviderPluginSchema,
26+
buildSsoPluginSchema,
2627
buildDeviceAuthorizationPluginSchema,
2728
buildJwtPluginSchema,
2829
buildAdminPluginSchema,
@@ -625,6 +626,8 @@ export class AuthManager {
625626
// override per-environment without touching the application bundle.
626627
const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
627628
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined;
629+
const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED;
630+
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined;
628631
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
629632
const enabled = {
630633
organization: pluginConfig.organization ?? true,
@@ -634,6 +637,7 @@ export class AuthManager {
634637
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
635638
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
636639
admin: pluginConfig.admin ?? false,
640+
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
637641
};
638642

639643
// bearer() — ALWAYS enabled.
@@ -926,6 +930,24 @@ export class AuthManager {
926930
}));
927931
}
928932

933+
// External SSO (OIDC / SAML) relying-party — lets this environment federate
934+
// login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime-
935+
// registered providers live in `sys_sso_provider` (ADR-0024: the OPEN SSO
936+
// mechanism — cloud-free for self-host). Endpoints mount under
937+
// /api/v1/auth/sso/{register,providers,delete-provider,callback,…}.
938+
//
939+
// Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`).
940+
if (enabled.sso) {
941+
const { sso } = await import('@better-auth/sso');
942+
plugins.push(sso({
943+
schema: buildSsoPluginSchema(),
944+
// provisionUser / organizationProvisioning are the hook for assigning a
945+
// default env role on first federated login (ADR-0024 V1 minimal
946+
// decisions). JIT user creation works out of the box via account
947+
// linking; wire role defaults here in the follow-up.
948+
}));
949+
}
950+
929951
// Device Authorization Grant (RFC 8628) — for CLI / TV-style devices.
930952
// Exposes the standard `/device/{code,token,approve,deny}` endpoints
931953
// and persists pending requests in `sys_device_code`.

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,50 @@ 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+
/**
686+
* Builds the `schema` option for `@better-auth/sso`'s `sso()` plugin, pointing
687+
* its single `ssoProvider` model at ObjectStack's `sys_sso_provider` table.
688+
*
689+
* @returns An object suitable for `sso({ schema: … })`
690+
*/
691+
export function buildSsoPluginSchema() {
692+
return {
693+
ssoProvider: AUTH_SSO_PROVIDER_SCHEMA,
694+
};
695+
}
696+
653697
// ---------------------------------------------------------------------------
654698
// Helper: build device-authorization plugin schema option
655699
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)