diff --git a/.changeset/auth-config-sso-feature-flag.md b/.changeset/auth-config-sso-feature-flag.md new file mode 100644 index 0000000000..6d9d739840 --- /dev/null +++ b/.changeset/auth-config-sso-feature-flag.md @@ -0,0 +1,24 @@ +--- +"@objectstack/plugin-auth": patch +--- + +feat(auth): surface `features.sso` in the public `/auth/config` response + +`getPublicConfig()` reported every other auth capability flag (`oidcProvider`, +`twoFactor`, `multiOrgEnabled`, …) but omitted enterprise SSO, even though the +manager already computes whether the domain-routed `@better-auth/sso` plugin is +wired (`OS_SSO_ENABLED` / `plugins.sso`). Without it the login UI had no signal +to gate on, so it rendered a "Sign in with SSO" button unconditionally — and on +a self-hosted / local deployment where SSO isn't wired, clicking it only then +surfaced "No SSO provider is configured for this email domain." + +The config now includes `features.sso`. `getPublicConfig()` returns the coarse +"is the plugin wired" flag — resolved with the EXACT logic that decides whether +the plugin is mounted in `buildPlugins()`, so the advertised capability can never +disagree with the actual `/sign-in/sso` route. The `/auth/config` route then +refines it to "usable" via the new `AuthManager.isSsoUsable()`, which additionally +requires at least one `sys_sso_provider` row to exist — so a freshly-enabled but +unconfigured SSO setup doesn't advertise a button that errors for everyone. +`isSsoUsable()` only queries when wired and fails open to the wired flag on any +introspection error (no data engine, query failure), so config never 500s. The +console login form consumes `features.sso` to hide the button (objectui side). diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index c5531a2f04..d50601179c 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -290,6 +290,12 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { try { const config = (authService as any).getPublicConfig?.(); if (config) { + // Refine the coarse "SSO wired" flag to "SSO usable" (≥1 provider + // configured), mirroring the plugin-auth /config route. Guarded so + // it's a safe no-op against an auth service predating the method. + if (config.features?.sso && typeof (authService as any).isSsoUsable === 'function') { + config.features.sso = await (authService as any).isSsoUsable(); + } return c.json({ success: true, data: config, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index be30ffd0a4..f5340fb570 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1046,6 +1046,7 @@ describe('AuthManager', () => { magicLink: false, organization: true, oidcProvider: false, + sso: false, deviceAuthorization: false, admin: false, multiOrgEnabled: false, @@ -1054,6 +1055,48 @@ describe('AuthManager', () => { }); }); + // Enterprise SSO (@better-auth/sso) is opt-in: the plugin is only wired + // when `plugins.sso` / `OS_SSO_ENABLED` is on. The public config MUST + // report the same value so the login UI can hide the "Sign in with SSO" + // button when the `/sign-in/sso` route isn't mounted (otherwise the + // button only fails at click time with "No SSO provider is configured"). + it('should report features.sso=false by default', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + }); + warnSpy.mockRestore(); + + expect(manager.getPublicConfig().features.sso).toBe(false); + }); + + it('should report features.sso=true when enabled via plugins config', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + }); + warnSpy.mockRestore(); + + expect(manager.getPublicConfig().features.sso).toBe(true); + }); + + it('should let OS_SSO_ENABLED env override the config (matches buildPlugins wiring)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.OS_SSO_ENABLED; + process.env.OS_SSO_ENABLED = 'true'; + 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('should filter out disabled providers', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const manager = new AuthManager({ @@ -1115,6 +1158,87 @@ describe('AuthManager', () => { }); }); + // The `/auth/config` route refines the coarse `features.sso` ("wired") flag + // to "usable" via isSsoUsable() so the login UI hides the "Sign in with SSO" + // button BOTH when SSO is off and when it's on but no IdP is configured yet. + describe('isSsoUsable – refines features.sso to "≥1 provider configured"', () => { + const makeEngine = (countImpl: () => Promise | number) => + ({ + insert: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + count: vi.fn().mockImplementation(countImpl), + update: vi.fn(), + delete: vi.fn(), + }); + + it('returns false when SSO is not wired (skips the provider query entirely)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const engine = makeEngine(() => 5); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + dataEngine: engine as any, + }); + warnSpy.mockRestore(); + + expect(await manager.isSsoUsable()).toBe(false); + expect(engine.count).not.toHaveBeenCalled(); + }); + + it('returns false when wired but zero providers are configured', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const engine = makeEngine(() => 0); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + dataEngine: engine as any, + }); + warnSpy.mockRestore(); + + expect(await manager.isSsoUsable()).toBe(false); + // Reads sys_sso_provider under a system context (RLS would otherwise zero it out). + expect(engine.count.mock.calls[0][0]).toBe('sys_sso_provider'); + expect(engine.count.mock.calls[0][1]?.context?.isSystem).toBe(true); + }); + + it('returns true when wired and at least one provider exists', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + dataEngine: makeEngine(() => 1) as any, + }); + warnSpy.mockRestore(); + + expect(await manager.isSsoUsable()).toBe(true); + }); + + it('fails open to wired when there is no data engine to consult', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + }); + warnSpy.mockRestore(); + + expect(await manager.isSsoUsable()).toBe(true); + }); + + it('fails open to wired when the provider-count query throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true } as any, + dataEngine: makeEngine(() => { + throw new Error('db down'); + }) as any, + }); + warnSpy.mockRestore(); + + expect(await manager.isSsoUsable()).toBe(true); + }); + }); + describe('WebContainer request-state polyfill', () => { const sym = Symbol.for('better-auth:global'); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c0764915fd..7157b143e8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -14,7 +14,7 @@ import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec'; -import { createObjectQLAdapterFactory } from './objectql-adapter.js'; +import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -1471,6 +1471,11 @@ export class AuthManager { organization: pluginConfig.organization ?? true, multiOrgEnabled, oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, + // Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config` + // route refines this to "usable" (≥1 provider configured) via + // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button + // both when SSO is off AND when it's on but no IdP exists yet. + sso: this.isSsoWired(), deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? false, ...(termsUrl ? { termsUrl } : {}), @@ -1484,6 +1489,42 @@ export class AuthManager { }; } + /** + * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag. + * Resolved with the EXACT logic that decides whether the plugin is mounted + * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the + * advertised capability can never disagree with the actual `/sign-in/sso` + * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting. + */ + private isSsoWired(): boolean { + const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; + const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; + return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false; + } + + /** + * Whether enterprise SSO is actually *usable*, not merely wired: the plugin + * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP + * matching still happens at `/sign-in/sso`; this answers the coarser "is + * there any point showing the SSO button at all", so a freshly-enabled but + * unconfigured SSO setup doesn't advertise a button that errors for everyone. + * + * Fails OPEN to the wired flag when providers can't be counted (no data + * engine, query error) — a config-introspection hiccup must never make the + * login page hide a button that genuinely works. + */ + public async isSsoUsable(): Promise { + if (!this.isSsoWired()) return false; + const engine = this.getDataEngine(); + if (!engine) return true; // wired but can't verify — fall open + try { + const count = await withSystemReadContext(engine).count('sys_sso_provider'); + return typeof count === 'number' ? count > 0 : true; + } catch { + return true; // provider introspection failed — keep the wired behaviour + } + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index a21718c66a..8b2c351aad 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -608,9 +608,16 @@ export class AuthPlugin implements Plugin { // Register /config before the wildcard so it takes precedence. // better-auth has no /config endpoint, so without this explicit route // the wildcard below forwards the request and better-auth returns 404. - rawApp.get(`${basePath}/config`, (c: any) => { + rawApp.get(`${basePath}/config`, async (c: any) => { try { const config = this.authManager!.getPublicConfig(); + // Refine the coarse "SSO wired" flag to "SSO usable" (≥1 provider + // configured) so the login UI also hides the "Sign in with SSO" button + // when SSO is enabled but no IdP exists yet — not just when it's off. + // Only queries when wired; falls open on any error (see isSsoUsable). + if (config.features?.sso) { + config.features.sso = await this.authManager!.isSsoUsable(); + } return c.json({ success: true, data: config }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error));