From b4502e04cd02ef1d9d50e10c0c07f707aae6fbe4 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 23:58:03 +0800 Subject: [PATCH 1/2] feat(auth): surface features.sso in public /auth/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPublicConfig() advertised every other auth capability flag (oidcProvider, twoFactor, multiOrgEnabled, …) but omitted enterprise SSO, even though the manager already computes whether @better-auth/sso is wired (OS_SSO_ENABLED / plugins.sso). The login UI therefore had nothing to gate on and rendered the "Sign in with SSO" button unconditionally; on a deployment without SSO wired, clicking it only then surfaced "No SSO provider is configured for this email domain." features.sso is now 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. Tests cover default-off, config-on, and OS_SSO_ENABLED env override. Co-Authored-By: Claude Opus 4.8 --- .changeset/auth-config-sso-feature-flag.md | 18 ++++++++ .../plugin-auth/src/auth-manager.test.ts | 43 +++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 9 ++++ 3 files changed, 70 insertions(+) create mode 100644 .changeset/auth-config-sso-feature-flag.md diff --git a/.changeset/auth-config-sso-feature-flag.md b/.changeset/auth-config-sso-feature-flag.md new file mode 100644 index 0000000000..848495bd73 --- /dev/null +++ b/.changeset/auth-config-sso-feature-flag.md @@ -0,0 +1,18 @@ +--- +"@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`, 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 console +login form consumes this to hide the button when SSO is off (objectui side). diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index be30ffd0a4..610c4f2fd5 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({ diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c0764915fd..1217901445 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1462,6 +1462,14 @@ export class AuthManager { // frontend will render UI for endpoints that 404. const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED; const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; + // Enterprise SSO (@better-auth/sso, domain-routed). Resolve it with the + // EXACT logic that decides whether the plugin is wired in `buildPlugins()` + // (`sso: ssoFromEnv ?? pluginConfig.sso ?? false`) so the two can never + // disagree. Surfacing it lets the login UI hide the "Sign in with SSO" + // button when `/sign-in/sso` isn't mounted, instead of rendering a button + // that only reveals "no SSO provider configured" at click time. + const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; + const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const features = { @@ -1471,6 +1479,7 @@ export class AuthManager { organization: pluginConfig.organization ?? true, multiOrgEnabled, oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, + sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? false, ...(termsUrl ? { termsUrl } : {}), From 4ecec2a6d0f6ae8aa21ee757c7e226e68bd53072 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 26 Jun 2026 00:08:36 +0800 Subject: [PATCH 2/2] feat(auth): refine features.sso to provider-existence at /auth/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the coarse features.sso flag: getPublicConfig() still returns the "wired" capability (cheap, sync), but the /auth/config route now refines it to "usable" via the new AuthManager.isSsoUsable() — wired AND >=1 sys_sso_provider row exists. This hides the "Sign in with SSO" button not only when SSO is off but also when it's enabled yet no IdP is configured yet (previously the button showed and errored for everyone at click time). isSsoUsable() only queries sys_sso_provider when wired and fails OPEN to the wired flag on any introspection failure (no data engine, query throws), so the login config endpoint never 500s. The coarse computation is factored into a private isSsoWired() shared by getPublicConfig() and isSsoUsable(). Both the plugin-auth route and the hono adapter /config path apply the refinement (the latter guarded so it's a no-op against an older auth service). 5 new unit tests cover not-wired, zero-providers, >=1 provider, no-engine fail-open, and query-throws fail-open. Co-Authored-By: Claude Opus 4.8 --- .changeset/auth-config-sso-feature-flag.md | 14 +++- packages/adapters/hono/src/index.ts | 6 ++ .../plugin-auth/src/auth-manager.test.ts | 81 +++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 52 +++++++++--- .../plugins/plugin-auth/src/auth-plugin.ts | 9 ++- 5 files changed, 147 insertions(+), 15 deletions(-) diff --git a/.changeset/auth-config-sso-feature-flag.md b/.changeset/auth-config-sso-feature-flag.md index 848495bd73..6d9d739840 100644 --- a/.changeset/auth-config-sso-feature-flag.md +++ b/.changeset/auth-config-sso-feature-flag.md @@ -12,7 +12,13 @@ to gate on, so it rendered a "Sign in with SSO" button unconditionally — and o 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`, 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 console -login form consumes this to hide the button when SSO is off (objectui side). +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 610c4f2fd5..f5340fb570 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1158,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 1217901445..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, @@ -1462,14 +1462,6 @@ export class AuthManager { // frontend will render UI for endpoints that 404. const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED; const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined; - // Enterprise SSO (@better-auth/sso, domain-routed). Resolve it with the - // EXACT logic that decides whether the plugin is wired in `buildPlugins()` - // (`sso: ssoFromEnv ?? pluginConfig.sso ?? false`) so the two can never - // disagree. Surfacing it lets the login UI hide the "Sign in with SSO" - // button when `/sign-in/sso` isn't mounted, instead of rendering a button - // that only reveals "no SSO provider configured" at click time. - const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED; - const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const features = { @@ -1479,7 +1471,11 @@ export class AuthManager { organization: pluginConfig.organization ?? true, multiOrgEnabled, oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false, - sso: ssoFromEnv ?? (pluginConfig as any).sso ?? 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 } : {}), @@ -1493,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));