Skip to content

Commit cf746c9

Browse files
os-zhuangclaude
andauthored
fix(auth): server-gate the "Sign in with SSO" button on features.sso (#2006)
LoginForm rendered the SSO button unconditionally, so a deployment without enterprise SSO wired (the default for self-hosted / os dev local runs) showed a button whose POST /sign-in/sso route isn't mounted — clicking it surfaced the misleading "No SSO provider is configured for this email domain." only at click time. The button is now gated on features.sso from GET /auth/config, mirroring how SocialSignInButtons already gates social providers. Defaults to hidden, so a failed config fetch or an older server that doesn't report the flag simply omits the button. Adds LoginForm.test.tsx covering off/absent/on/fetch-failure. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cde7502 commit cf746c9

4 files changed

Lines changed: 137 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/auth': patch
3+
---
4+
5+
fix(auth): only render the "Sign in with SSO" button when the server reports it
6+
7+
`LoginForm` rendered the SSO button unconditionally, so a deployment without
8+
enterprise SSO wired (the default for self-hosted / `os dev` local runs) showed
9+
a button whose `POST /sign-in/sso` route isn't mounted — clicking it surfaced
10+
the misleading "No SSO provider is configured for this email domain." only at
11+
click time.
12+
13+
The button is now gated on `features.sso` from `GET /auth/config`, mirroring how
14+
`SocialSignInButtons` already gates social providers. It defaults to hidden, so a
15+
failed config fetch or an older server that doesn't report the flag simply omits
16+
the button rather than offering a dead end. Requires the matching
17+
`@objectstack/plugin-auth` change that surfaces `features.sso`.

packages/auth/src/LoginForm.tsx

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import React, { useState } from 'react';
9+
import React, { useEffect, useState } from 'react';
1010
import { useAuth } from './useAuth';
1111
import { SocialSignInButtons } from './SocialSignInButtons';
1212
import type { AuthLinkComponentProps } from './types';
@@ -115,11 +115,32 @@ export function LoginForm({
115115
labels = {},
116116
errorMessages,
117117
}: LoginFormProps) {
118-
const { signIn, isLoading } = useAuth();
118+
const { signIn, isLoading, getAuthConfig } = useAuth();
119119
const [email, setEmail] = useState('');
120120
const [password, setPassword] = useState('');
121121
const [error, setError] = useState<string | null>(null);
122122
const [hasSocialProviders, setHasSocialProviders] = useState(false);
123+
// Enterprise SSO is opt-in server-side (`@better-auth/sso`). Mirror the
124+
// social-provider pattern below: ask the server whether SSO is wired and
125+
// only render the "Sign in with SSO" button when it is. Defaults to hidden,
126+
// so a server that doesn't report `features.sso` (or a failed config fetch)
127+
// never shows a button whose `/sign-in/sso` route 404s at click time.
128+
const [ssoEnabled, setSsoEnabled] = useState(false);
129+
130+
useEffect(() => {
131+
let cancelled = false;
132+
Promise.resolve()
133+
.then(() => getAuthConfig())
134+
.then((config) => {
135+
if (!cancelled) setSsoEnabled(config?.features?.sso === true);
136+
})
137+
.catch(() => {
138+
// SSO is an enhancement, not required — leave the button hidden.
139+
});
140+
return () => {
141+
cancelled = true;
142+
};
143+
}, [getAuthConfig]);
123144

124145
const l = {
125146
emailLabel: labels.emailLabel ?? 'Email',
@@ -246,14 +267,16 @@ export function LoginForm({
246267
{isLoading ? l.submittingButton : l.submitButton}
247268
</button>
248269

249-
<button
250-
type="button"
251-
onClick={handleSso}
252-
disabled={isLoading}
253-
className="flex w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50"
254-
>
255-
{l.ssoButton}
256-
</button>
270+
{ssoEnabled && (
271+
<button
272+
type="button"
273+
onClick={handleSso}
274+
disabled={isLoading}
275+
className="flex w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50"
276+
>
277+
{l.ssoButton}
278+
</button>
279+
)}
257280
</form>
258281
</div>
259282

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Tests for LoginForm — specifically the server-gated "Sign in with SSO"
3+
* button. The button must only render when the server's `/auth/config`
4+
* reports `features.sso`, mirroring how social providers are gated. Otherwise
5+
* a self-hosted / local deployment (where `@better-auth/sso` isn't wired)
6+
* shows a button whose `/sign-in/sso` route 404s, surfacing the misleading
7+
* "No SSO provider is configured for this email domain." only at click time.
8+
*/
9+
10+
import React from 'react';
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { render, screen, waitFor } from '@testing-library/react';
13+
import { AuthProvider } from '../AuthProvider';
14+
import { LoginForm } from '../LoginForm';
15+
import type { AuthClient, AuthPublicConfig } from '../types';
16+
17+
const SSO_BUTTON = { name: 'Sign in with SSO' } as const;
18+
19+
function createMockClient(
20+
config: AuthPublicConfig,
21+
overrides: Partial<AuthClient> = {},
22+
): AuthClient {
23+
return {
24+
signIn: vi.fn().mockResolvedValue({
25+
user: { id: '1', name: 'Test User', email: 'test@test.com' },
26+
session: { token: 'tok123' },
27+
}),
28+
signUp: vi.fn().mockResolvedValue({ user: { id: '2' }, session: null, requiresVerification: false }),
29+
signOut: vi.fn().mockResolvedValue(undefined),
30+
getSession: vi.fn().mockResolvedValue(null),
31+
forgotPassword: vi.fn().mockResolvedValue(undefined),
32+
resetPassword: vi.fn().mockResolvedValue(undefined),
33+
updateUser: vi.fn().mockResolvedValue({ id: '1', name: 'Updated', email: 'test@test.com' }),
34+
getConfig: vi.fn().mockResolvedValue(config),
35+
...overrides,
36+
} as unknown as AuthClient;
37+
}
38+
39+
function renderLogin(client: AuthClient) {
40+
return render(
41+
<AuthProvider authUrl="/api/auth" client={client}>
42+
<LoginForm />
43+
</AuthProvider>,
44+
);
45+
}
46+
47+
describe('LoginForm — server-gated SSO button', () => {
48+
it('hides the SSO button when the server does not report features.sso', async () => {
49+
renderLogin(createMockClient({ features: { sso: false } }));
50+
51+
// Email/password form is always present…
52+
await screen.findByLabelText('Email');
53+
// …but the SSO button must not render.
54+
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
55+
});
56+
57+
it('hides the SSO button when features is absent entirely (older server)', async () => {
58+
renderLogin(createMockClient({}));
59+
60+
await screen.findByLabelText('Email');
61+
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
62+
});
63+
64+
it('shows the SSO button only when the server reports features.sso = true', async () => {
65+
renderLogin(createMockClient({ features: { sso: true } }));
66+
67+
await waitFor(() => {
68+
expect(screen.getByRole('button', SSO_BUTTON)).toBeTruthy();
69+
});
70+
});
71+
72+
it('keeps the button hidden when the config fetch fails (SSO is an enhancement)', async () => {
73+
renderLogin(
74+
createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('boom')) }),
75+
);
76+
77+
await screen.findByLabelText('Email');
78+
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
79+
});
80+
});

packages/auth/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ export interface AuthPublicConfig {
119119
passkeys?: boolean;
120120
magicLink?: boolean;
121121
organization?: boolean;
122+
/**
123+
* Whether enterprise SSO (domain-routed `@better-auth/sso`) is wired on
124+
* the server. When falsy the `/sign-in/sso` route isn't mounted, so the
125+
* login UI hides the "Sign in with SSO" button instead of rendering one
126+
* that only fails at click time with "No SSO provider is configured".
127+
*/
128+
sso?: boolean;
122129
/**
123130
* When `false`, the server's `beforeCreateOrganization` hook blocks
124131
* creation of new organizations. The org plugin endpoints (list, update,

0 commit comments

Comments
 (0)