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
17 changes: 17 additions & 0 deletions .changeset/login-sso-button-server-gated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/auth': patch
---

fix(auth): only render the "Sign in with SSO" button when the server reports it

`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. It defaults to hidden, so a
failed config fetch or an older server that doesn't report the flag simply omits
the button rather than offering a dead end. Requires the matching
`@objectstack/plugin-auth` change that surfaces `features.sso`.
43 changes: 33 additions & 10 deletions packages/auth/src/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useAuth } from './useAuth';
import { SocialSignInButtons } from './SocialSignInButtons';
import type { AuthLinkComponentProps } from './types';
Expand Down Expand Up @@ -115,11 +115,32 @@ export function LoginForm({
labels = {},
errorMessages,
}: LoginFormProps) {
const { signIn, isLoading } = useAuth();
const { signIn, isLoading, getAuthConfig } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [hasSocialProviders, setHasSocialProviders] = useState(false);
// Enterprise SSO is opt-in server-side (`@better-auth/sso`). Mirror the
// social-provider pattern below: ask the server whether SSO is wired and
// only render the "Sign in with SSO" button when it is. Defaults to hidden,
// so a server that doesn't report `features.sso` (or a failed config fetch)
// never shows a button whose `/sign-in/sso` route 404s at click time.
const [ssoEnabled, setSsoEnabled] = useState(false);

useEffect(() => {
let cancelled = false;
Promise.resolve()
.then(() => getAuthConfig())
.then((config) => {
if (!cancelled) setSsoEnabled(config?.features?.sso === true);
})
.catch(() => {
// SSO is an enhancement, not required — leave the button hidden.
});
return () => {
cancelled = true;
};
}, [getAuthConfig]);

const l = {
emailLabel: labels.emailLabel ?? 'Email',
Expand Down Expand Up @@ -246,14 +267,16 @@ export function LoginForm({
{isLoading ? l.submittingButton : l.submitButton}
</button>

<button
type="button"
onClick={handleSso}
disabled={isLoading}
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"
>
{l.ssoButton}
</button>
{ssoEnabled && (
<button
type="button"
onClick={handleSso}
disabled={isLoading}
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"
>
{l.ssoButton}
</button>
)}
</form>
</div>

Expand Down
80 changes: 80 additions & 0 deletions packages/auth/src/__tests__/LoginForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Tests for LoginForm — specifically the server-gated "Sign in with SSO"
* button. The button must only render when the server's `/auth/config`
* reports `features.sso`, mirroring how social providers are gated. Otherwise
* a self-hosted / local deployment (where `@better-auth/sso` isn't wired)
* shows a button whose `/sign-in/sso` route 404s, surfacing the misleading
* "No SSO provider is configured for this email domain." only at click time.
*/

import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { AuthProvider } from '../AuthProvider';
import { LoginForm } from '../LoginForm';
import type { AuthClient, AuthPublicConfig } from '../types';

const SSO_BUTTON = { name: 'Sign in with SSO' } as const;

function createMockClient(
config: AuthPublicConfig,
overrides: Partial<AuthClient> = {},
): AuthClient {
return {
signIn: vi.fn().mockResolvedValue({
user: { id: '1', name: 'Test User', email: 'test@test.com' },
session: { token: 'tok123' },
}),
signUp: vi.fn().mockResolvedValue({ user: { id: '2' }, session: null, requiresVerification: false }),
signOut: vi.fn().mockResolvedValue(undefined),
getSession: vi.fn().mockResolvedValue(null),
forgotPassword: vi.fn().mockResolvedValue(undefined),
resetPassword: vi.fn().mockResolvedValue(undefined),
updateUser: vi.fn().mockResolvedValue({ id: '1', name: 'Updated', email: 'test@test.com' }),
getConfig: vi.fn().mockResolvedValue(config),
...overrides,
} as unknown as AuthClient;
}

function renderLogin(client: AuthClient) {
return render(
<AuthProvider authUrl="/api/auth" client={client}>
<LoginForm />
</AuthProvider>,
);
}

describe('LoginForm — server-gated SSO button', () => {
it('hides the SSO button when the server does not report features.sso', async () => {
renderLogin(createMockClient({ features: { sso: false } }));

// Email/password form is always present…
await screen.findByLabelText('Email');
// …but the SSO button must not render.
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
});

it('hides the SSO button when features is absent entirely (older server)', async () => {
renderLogin(createMockClient({}));

await screen.findByLabelText('Email');
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
});

it('shows the SSO button only when the server reports features.sso = true', async () => {
renderLogin(createMockClient({ features: { sso: true } }));

await waitFor(() => {
expect(screen.getByRole('button', SSO_BUTTON)).toBeTruthy();
});
});

it('keeps the button hidden when the config fetch fails (SSO is an enhancement)', async () => {
renderLogin(
createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('boom')) }),
);

await screen.findByLabelText('Email');
expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull();
});
});
7 changes: 7 additions & 0 deletions packages/auth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export interface AuthPublicConfig {
passkeys?: boolean;
magicLink?: boolean;
organization?: boolean;
/**
* Whether enterprise SSO (domain-routed `@better-auth/sso`) is wired on
* the server. When falsy the `/sign-in/sso` route isn't mounted, so the
* login UI hides the "Sign in with SSO" button instead of rendering one
* that only fails at click time with "No SSO provider is configured".
*/
sso?: boolean;
/**
* When `false`, the server's `beforeCreateOrganization` hook blocks
* creation of new organizations. The org plugin endpoints (list, update,
Expand Down
Loading