diff --git a/.changeset/adr-0024-sso-quality.md b/.changeset/adr-0024-sso-quality.md new file mode 100644 index 0000000000..cd8d27f148 --- /dev/null +++ b/.changeset/adr-0024-sso-quality.md @@ -0,0 +1,9 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +--- + +Auth: SSO quality polish (ADR-0024 / cloud#551) + +- **plugin-auth**: `OS_OIDC_PROVIDER_ENABLED` / `OS_SSO_ENABLED` / `OS_SCIM_ENABLED` now parse with the shared `readBooleanEnv` helper (same as `OS_AUTH_TWO_FACTOR` etc.), so the platform-standard truthy set works (`true`/`1`/`yes`/`on`, case-insensitive) instead of only the literal `'true'` — a repeated operator footgun where `OS_SSO_ENABLED=1` silently parsed as disabled. Added unit tests. +- **platform-objects**: `sys_sso_provider`'s list view gets a per-object empty state ("No SSO providers yet" + a pointer to "Register SSO Provider"), replacing the shared identity-object copy ("records are created automatically … cannot be added here") which is wrong for this object — it HAS a register action. diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 3fec44f952..53b70ecf57 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -120,6 +120,14 @@ export const SysSsoProvider = ObjectSchema.create({ columns: ['provider_id', 'issuer', 'domain', 'created_at'], sort: [{ field: 'provider_id', order: 'asc' }], pagination: { pageSize: 50 }, + // Per-object empty state — the shared identity-object copy ("created + // automatically … cannot be added here") is wrong for this object, which + // HAS a "Register SSO Provider" action. Point admins at it instead. + emptyState: { + title: 'No SSO providers yet', + message: 'Register your organization’s external OIDC IdP (Okta, Entra, Auth0, …) with “Register SSO Provider”. Members whose email domain matches can then sign in through it.', + icon: 'log-in', + }, }, }, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 390ceb82aa..b11c75a3f5 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1149,6 +1149,47 @@ describe('AuthManager', () => { } }); + // ADR-0024 / cloud#551 — OS_SSO_ENABLED uses the shared `readBooleanEnv` + // parser, so the platform-standard truthy/falsy set works (not only the + // literal `'true'`). Operators kept setting `OS_SSO_ENABLED=1` and getting + // a silently-disabled RP. + it.each(['1', 'true', 'TRUE', 'yes', 'on'])( + 'should treat OS_SSO_ENABLED=%s as enabled', + (val) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.OS_SSO_ENABLED; + process.env.OS_SSO_ENABLED = val; + try { + const manager = new AuthManager({ secret: 'test-secret-at-least-32-chars-long' }); + expect(manager.getPublicConfig().features.sso).toBe(true); + } finally { + if (prev === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prev; + warnSpy.mockRestore(); + } + }, + ); + + it.each(['0', 'false', 'off', 'no'])( + 'should treat OS_SSO_ENABLED=%s as disabled even when plugins.sso=true', + (val) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.OS_SSO_ENABLED; + process.env.OS_SSO_ENABLED = val; + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + }); + expect(manager.getPublicConfig().features.sso).toBe(false); + } finally { + if (prev === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prev; + warnSpy.mockRestore(); + } + }, + ); + it('should filter out disabled providers', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const manager = new AuthManager({ diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 66a6708dcc..7832433588 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1057,12 +1057,14 @@ export class AuthManager { // `OS_MULTI_ORG_ENABLED` / `OS_DISABLE_SIGNUP` pattern). When set, the // env var WINS over the config-file setting so platform operators can // 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 scimEnv = (globalThis as any)?.process?.env?.OS_SCIM_ENABLED; - const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === 'true' : undefined; + // Use the shared `readBooleanEnv` parser (same as OS_AUTH_TWO_FACTOR / + // OS_AUTH_PASSWORD_REJECT_BREACHED / OS_DISABLE_SIGNUP) so these accept the + // platform-standard truthy set (`true`/`1`/`yes`/`on`, case-insensitive) + // instead of only the literal string `'true'` — a repeated operator footgun + // (`OS_SSO_ENABLED=1` silently parsed as disabled). + const oidcFromEnv = readBooleanEnv('OS_OIDC_PROVIDER_ENABLED'); + const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); + const scimFromEnv = readBooleanEnv('OS_SCIM_ENABLED'); // @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. @@ -2008,8 +2010,9 @@ export class AuthManager { * `planAllowsSso` config, since that arrives via `plugins.sso`). */ public isSsoWired(): boolean { - const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; - const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; + // Same parser as `buildPluginList` (`readBooleanEnv`) so the advertised + // capability can never disagree with the actually-mounted route. + const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false; }