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
9 changes: 9 additions & 0 deletions .changeset/adr-0024-sso-quality.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},

Expand Down
41 changes: 41 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 11 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down