Skip to content

Commit 4ecec2a

Browse files
os-zhuangclaude
andcommitted
feat(auth): refine features.sso to provider-existence at /auth/config
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 <noreply@anthropic.com>
1 parent b4502e0 commit 4ecec2a

5 files changed

Lines changed: 147 additions & 15 deletions

File tree

.changeset/auth-config-sso-feature-flag.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ to gate on, so it rendered a "Sign in with SSO" button unconditionally — and o
1212
a self-hosted / local deployment where SSO isn't wired, clicking it only then
1313
surfaced "No SSO provider is configured for this email domain."
1414

15-
The config now includes `features.sso`, resolved with the EXACT logic that
16-
decides whether the plugin is mounted in `buildPlugins()`, so the advertised
17-
capability can never disagree with the actual `/sign-in/sso` route. The console
18-
login form consumes this to hide the button when SSO is off (objectui side).
15+
The config now includes `features.sso`. `getPublicConfig()` returns the coarse
16+
"is the plugin wired" flag — resolved with the EXACT logic that decides whether
17+
the plugin is mounted in `buildPlugins()`, so the advertised capability can never
18+
disagree with the actual `/sign-in/sso` route. The `/auth/config` route then
19+
refines it to "usable" via the new `AuthManager.isSsoUsable()`, which additionally
20+
requires at least one `sys_sso_provider` row to exist — so a freshly-enabled but
21+
unconfigured SSO setup doesn't advertise a button that errors for everyone.
22+
`isSsoUsable()` only queries when wired and fails open to the wired flag on any
23+
introspection error (no data engine, query failure), so config never 500s. The
24+
console login form consumes `features.sso` to hide the button (objectui side).

packages/adapters/hono/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,12 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
290290
try {
291291
const config = (authService as any).getPublicConfig?.();
292292
if (config) {
293+
// Refine the coarse "SSO wired" flag to "SSO usable" (≥1 provider
294+
// configured), mirroring the plugin-auth /config route. Guarded so
295+
// it's a safe no-op against an auth service predating the method.
296+
if (config.features?.sso && typeof (authService as any).isSsoUsable === 'function') {
297+
config.features.sso = await (authService as any).isSsoUsable();
298+
}
293299
return c.json({
294300
success: true,
295301
data: config,

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,87 @@ describe('AuthManager', () => {
11581158
});
11591159
});
11601160

1161+
// The `/auth/config` route refines the coarse `features.sso` ("wired") flag
1162+
// to "usable" via isSsoUsable() so the login UI hides the "Sign in with SSO"
1163+
// button BOTH when SSO is off and when it's on but no IdP is configured yet.
1164+
describe('isSsoUsable – refines features.sso to "≥1 provider configured"', () => {
1165+
const makeEngine = (countImpl: () => Promise<number> | number) =>
1166+
({
1167+
insert: vi.fn(),
1168+
findOne: vi.fn(),
1169+
find: vi.fn(),
1170+
count: vi.fn().mockImplementation(countImpl),
1171+
update: vi.fn(),
1172+
delete: vi.fn(),
1173+
});
1174+
1175+
it('returns false when SSO is not wired (skips the provider query entirely)', async () => {
1176+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1177+
const engine = makeEngine(() => 5);
1178+
const manager = new AuthManager({
1179+
secret: 'test-secret-at-least-32-chars-long',
1180+
dataEngine: engine as any,
1181+
});
1182+
warnSpy.mockRestore();
1183+
1184+
expect(await manager.isSsoUsable()).toBe(false);
1185+
expect(engine.count).not.toHaveBeenCalled();
1186+
});
1187+
1188+
it('returns false when wired but zero providers are configured', async () => {
1189+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1190+
const engine = makeEngine(() => 0);
1191+
const manager = new AuthManager({
1192+
secret: 'test-secret-at-least-32-chars-long',
1193+
plugins: { sso: true } as any,
1194+
dataEngine: engine as any,
1195+
});
1196+
warnSpy.mockRestore();
1197+
1198+
expect(await manager.isSsoUsable()).toBe(false);
1199+
// Reads sys_sso_provider under a system context (RLS would otherwise zero it out).
1200+
expect(engine.count.mock.calls[0][0]).toBe('sys_sso_provider');
1201+
expect(engine.count.mock.calls[0][1]?.context?.isSystem).toBe(true);
1202+
});
1203+
1204+
it('returns true when wired and at least one provider exists', async () => {
1205+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1206+
const manager = new AuthManager({
1207+
secret: 'test-secret-at-least-32-chars-long',
1208+
plugins: { sso: true } as any,
1209+
dataEngine: makeEngine(() => 1) as any,
1210+
});
1211+
warnSpy.mockRestore();
1212+
1213+
expect(await manager.isSsoUsable()).toBe(true);
1214+
});
1215+
1216+
it('fails open to wired when there is no data engine to consult', async () => {
1217+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1218+
const manager = new AuthManager({
1219+
secret: 'test-secret-at-least-32-chars-long',
1220+
plugins: { sso: true } as any,
1221+
});
1222+
warnSpy.mockRestore();
1223+
1224+
expect(await manager.isSsoUsable()).toBe(true);
1225+
});
1226+
1227+
it('fails open to wired when the provider-count query throws', async () => {
1228+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1229+
const manager = new AuthManager({
1230+
secret: 'test-secret-at-least-32-chars-long',
1231+
plugins: { sso: true } as any,
1232+
dataEngine: makeEngine(() => {
1233+
throw new Error('db down');
1234+
}) as any,
1235+
});
1236+
warnSpy.mockRestore();
1237+
1238+
expect(await manager.isSsoUsable()).toBe(true);
1239+
});
1240+
});
1241+
11611242
describe('WebContainer request-state polyfill', () => {
11621243
const sym = Symbol.for('better-auth:global');
11631244

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { IDataEngine } from '@objectstack/core';
1414
import type { IEmailService } from '@objectstack/spec/contracts';
1515
import { readEnvWithDeprecation } from '@objectstack/types';
1616
import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
17-
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
17+
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
1818
import {
1919
AUTH_USER_CONFIG,
2020
AUTH_SESSION_CONFIG,
@@ -1462,14 +1462,6 @@ export class AuthManager {
14621462
// frontend will render UI for endpoints that 404.
14631463
const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
14641464
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined;
1465-
// Enterprise SSO (@better-auth/sso, domain-routed). Resolve it with the
1466-
// EXACT logic that decides whether the plugin is wired in `buildPlugins()`
1467-
// (`sso: ssoFromEnv ?? pluginConfig.sso ?? false`) so the two can never
1468-
// disagree. Surfacing it lets the login UI hide the "Sign in with SSO"
1469-
// button when `/sign-in/sso` isn't mounted, instead of rendering a button
1470-
// that only reveals "no SSO provider configured" at click time.
1471-
const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED;
1472-
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined;
14731465
const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR');
14741466

14751467
const features = {
@@ -1479,7 +1471,11 @@ export class AuthManager {
14791471
organization: pluginConfig.organization ?? true,
14801472
multiOrgEnabled,
14811473
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
1482-
sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false,
1474+
// Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config`
1475+
// route refines this to "usable" (≥1 provider configured) via
1476+
// `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
1477+
// both when SSO is off AND when it's on but no IdP exists yet.
1478+
sso: this.isSsoWired(),
14831479
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
14841480
admin: pluginConfig.admin ?? false,
14851481
...(termsUrl ? { termsUrl } : {}),
@@ -1493,6 +1489,42 @@ export class AuthManager {
14931489
};
14941490
}
14951491

1492+
/**
1493+
* Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
1494+
* Resolved with the EXACT logic that decides whether the plugin is mounted
1495+
* in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
1496+
* advertised capability can never disagree with the actual `/sign-in/sso`
1497+
* route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
1498+
*/
1499+
private isSsoWired(): boolean {
1500+
const ssoEnv = (globalThis as any)?.process?.env?.OS_SSO_ENABLED;
1501+
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === 'true' : undefined;
1502+
return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false;
1503+
}
1504+
1505+
/**
1506+
* Whether enterprise SSO is actually *usable*, not merely wired: the plugin
1507+
* is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
1508+
* matching still happens at `/sign-in/sso`; this answers the coarser "is
1509+
* there any point showing the SSO button at all", so a freshly-enabled but
1510+
* unconfigured SSO setup doesn't advertise a button that errors for everyone.
1511+
*
1512+
* Fails OPEN to the wired flag when providers can't be counted (no data
1513+
* engine, query error) — a config-introspection hiccup must never make the
1514+
* login page hide a button that genuinely works.
1515+
*/
1516+
public async isSsoUsable(): Promise<boolean> {
1517+
if (!this.isSsoWired()) return false;
1518+
const engine = this.getDataEngine();
1519+
if (!engine) return true; // wired but can't verify — fall open
1520+
try {
1521+
const count = await withSystemReadContext(engine).count('sys_sso_provider');
1522+
return typeof count === 'number' ? count > 0 : true;
1523+
} catch {
1524+
return true; // provider introspection failed — keep the wired behaviour
1525+
}
1526+
}
1527+
14961528
/**
14971529
* Returns the data engine wired into this auth manager. Used by route
14981530
* handlers (e.g. bootstrap-status) that need to query identity tables

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,16 @@ export class AuthPlugin implements Plugin {
608608
// Register /config before the wildcard so it takes precedence.
609609
// better-auth has no /config endpoint, so without this explicit route
610610
// the wildcard below forwards the request and better-auth returns 404.
611-
rawApp.get(`${basePath}/config`, (c: any) => {
611+
rawApp.get(`${basePath}/config`, async (c: any) => {
612612
try {
613613
const config = this.authManager!.getPublicConfig();
614+
// Refine the coarse "SSO wired" flag to "SSO usable" (≥1 provider
615+
// configured) so the login UI also hides the "Sign in with SSO" button
616+
// when SSO is enabled but no IdP exists yet — not just when it's off.
617+
// Only queries when wired; falls open on any error (see isSsoUsable).
618+
if (config.features?.sso) {
619+
config.features.sso = await this.authManager!.isSsoUsable();
620+
}
614621
return c.json({ success: true, data: config });
615622
} catch (error) {
616623
const err = error instanceof Error ? error : new Error(String(error));

0 commit comments

Comments
 (0)