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
24 changes: 24 additions & 0 deletions .changeset/auth-config-sso-feature-flag.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,7 @@ describe('AuthManager', () => {
magicLink: false,
organization: true,
oidcProvider: false,
sso: false,
deviceAuthorization: false,
admin: false,
multiOrgEnabled: false,
Expand All @@ -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({
Expand Down Expand Up @@ -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> | 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');

Expand Down
43 changes: 42 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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<boolean> {
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
Expand Down
9 changes: 8 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down