Skip to content

Commit 1e8a813

Browse files
os-zhuangclaude
andauthored
feat(auth): surface features.sso in public /auth/config (#2330)
* feat(auth): surface features.sso in public /auth/config 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 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6f0b7f commit 1e8a813

5 files changed

Lines changed: 204 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
feat(auth): surface `features.sso` in the public `/auth/config` response
6+
7+
`getPublicConfig()` reported every other auth capability flag (`oidcProvider`,
8+
`twoFactor`, `multiOrgEnabled`, …) but omitted enterprise SSO, even though the
9+
manager already computes whether the domain-routed `@better-auth/sso` plugin is
10+
wired (`OS_SSO_ENABLED` / `plugins.sso`). Without it the login UI had no signal
11+
to gate on, so it rendered a "Sign in with SSO" button unconditionally — and on
12+
a self-hosted / local deployment where SSO isn't wired, clicking it only then
13+
surfaced "No SSO provider is configured for this email domain."
14+
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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,7 @@ describe('AuthManager', () => {
10461046
magicLink: false,
10471047
organization: true,
10481048
oidcProvider: false,
1049+
sso: false,
10491050
deviceAuthorization: false,
10501051
admin: false,
10511052
multiOrgEnabled: false,
@@ -1054,6 +1055,48 @@ describe('AuthManager', () => {
10541055
});
10551056
});
10561057

1058+
// Enterprise SSO (@better-auth/sso) is opt-in: the plugin is only wired
1059+
// when `plugins.sso` / `OS_SSO_ENABLED` is on. The public config MUST
1060+
// report the same value so the login UI can hide the "Sign in with SSO"
1061+
// button when the `/sign-in/sso` route isn't mounted (otherwise the
1062+
// button only fails at click time with "No SSO provider is configured").
1063+
it('should report features.sso=false by default', () => {
1064+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1065+
const manager = new AuthManager({
1066+
secret: 'test-secret-at-least-32-chars-long',
1067+
});
1068+
warnSpy.mockRestore();
1069+
1070+
expect(manager.getPublicConfig().features.sso).toBe(false);
1071+
});
1072+
1073+
it('should report features.sso=true when enabled via plugins config', () => {
1074+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1075+
const manager = new AuthManager({
1076+
secret: 'test-secret-at-least-32-chars-long',
1077+
plugins: { sso: true } as any,
1078+
});
1079+
warnSpy.mockRestore();
1080+
1081+
expect(manager.getPublicConfig().features.sso).toBe(true);
1082+
});
1083+
1084+
it('should let OS_SSO_ENABLED env override the config (matches buildPlugins wiring)', () => {
1085+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1086+
const prev = process.env.OS_SSO_ENABLED;
1087+
process.env.OS_SSO_ENABLED = 'true';
1088+
try {
1089+
const manager = new AuthManager({
1090+
secret: 'test-secret-at-least-32-chars-long',
1091+
});
1092+
expect(manager.getPublicConfig().features.sso).toBe(true);
1093+
} finally {
1094+
if (prev === undefined) delete process.env.OS_SSO_ENABLED;
1095+
else process.env.OS_SSO_ENABLED = prev;
1096+
warnSpy.mockRestore();
1097+
}
1098+
});
1099+
10571100
it('should filter out disabled providers', () => {
10581101
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
10591102
const manager = new AuthManager({
@@ -1115,6 +1158,87 @@ describe('AuthManager', () => {
11151158
});
11161159
});
11171160

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+
11181242
describe('WebContainer request-state polyfill', () => {
11191243
const sym = Symbol.for('better-auth:global');
11201244

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

Lines changed: 42 additions & 1 deletion
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,
@@ -1471,6 +1471,11 @@ export class AuthManager {
14711471
organization: pluginConfig.organization ?? true,
14721472
multiOrgEnabled,
14731473
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? 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(),
14741479
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
14751480
admin: pluginConfig.admin ?? false,
14761481
...(termsUrl ? { termsUrl } : {}),
@@ -1484,6 +1489,42 @@ export class AuthManager {
14841489
};
14851490
}
14861491

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+
14871528
/**
14881529
* Returns the data engine wired into this auth manager. Used by route
14891530
* 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)