SSO login fix (#5167)

Fixes bug where SSO login with custom providers caused an
`InvalidClientRegistrationIdException: Invalid Client Registration with
Id: oidc` errors.

  Root Cause:
- Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc`
regardless of provider registration ID
- Frontend: Unknown providers were mapped back to 'oidc' instead of
using actual provider ID

Closes #5141

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com>
This commit is contained in:
Dario Ghunney Ware
2025-12-05 23:19:41 +00:00
committed by GitHub
parent 9fd8fd89ed
commit 82dbcfbb9b
12 changed files with 961 additions and 55 deletions

View File

@@ -0,0 +1,24 @@
/**
* Known OAuth providers with dedicated UI support.
* Custom providers are also supported - the backend determines availability.
*/
export const KNOWN_OAUTH_PROVIDERS = [
'github',
'google',
'apple',
'azure',
'keycloak',
'cloudron',
'authentik',
'oidc',
] as const;
export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
/**
* OAuth provider ID - can be any known provider or custom string.
* The backend configuration determines which providers are available.
*
* @example 'github' | 'google' | 'mycompany' | 'authentik'
*/
export type OAuthProvider = KnownOAuthProvider | (string & {});

View File

@@ -10,6 +10,7 @@
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
@@ -248,11 +249,14 @@ class SpringAuthClient {
}
/**
* Sign in with OAuth provider (GitHub, Google, etc.)
* Sign in with OAuth provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2 authorization endpoint
*
* @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany')
* Can be any known provider or custom string - the backend determines available providers
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
provider: OAuthProvider;
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {

View File

@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
import apiClient from '@app/services/apiClient';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
@@ -36,8 +37,13 @@ vi.mock('@app/hooks/useDocumentMeta', () => ({
useDocumentMeta: vi.fn(),
}));
// Mock fetch for provider list
global.fetch = vi.fn();
// Mock apiClient for provider list
vi.mock('@app/services/apiClient', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
const mockNavigate = vi.fn();
const mockBackendProbeState = {
@@ -89,14 +95,13 @@ describe('Login', () => {
refreshSession: vi.fn(),
});
// Mock fetch for login UI data
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({
// Mock apiClient for login UI data
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {},
}),
} as Response);
},
});
});
it('should render login form', async () => {
@@ -239,6 +244,136 @@ describe('Login', () => {
});
});
it('should use actual provider ID for OAuth login (authentik)', async () => {
const user = userEvent.setup();
// Mock provider list with authentik
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/authentik': 'Authentik',
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// Wait for OAuth button to appear
await waitFor(() => {
const button = screen.queryByText('Authentik');
expect(button).toBeTruthy();
}, { timeout: 3000 });
const oauthButton = screen.getByText('Authentik');
await user.click(oauthButton);
await waitFor(() => {
// Should use 'authentik' directly, NOT map to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'authentik',
options: { redirectTo: '/auth/callback' }
});
});
});
it('should use actual provider ID for OAuth login (custom provider)', async () => {
const user = userEvent.setup();
// Mock provider list with custom provider 'mycompany'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/mycompany': 'My Company SSO',
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// Wait for OAuth button to appear (will show 'Mycompany' as label)
await waitFor(() => {
const button = screen.queryByText('Mycompany');
expect(button).toBeTruthy();
}, { timeout: 3000 });
const oauthButton = screen.getByText('Mycompany');
await user.click(oauthButton);
await waitFor(() => {
// Should use 'mycompany' directly - this is the critical fix
// Previously it would map unknown providers to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'mycompany',
options: { redirectTo: '/auth/callback' }
});
});
});
it('should use oidc provider ID when explicitly configured', async () => {
const user = userEvent.setup();
// Mock provider list with 'oidc'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/oidc': 'OIDC',
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// Wait for OAuth button to appear
await waitFor(() => {
const button = screen.queryByText('OIDC');
expect(button).toBeTruthy();
}, { timeout: 3000 });
const oauthButton = screen.getByText('OIDC');
await user.click(oauthButton);
await waitFor(() => {
// Should use 'oidc' when explicitly configured
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'oidc',
options: { redirectTo: '/auth/callback' }
});
});
});
it('should show error on failed login', async () => {
const user = userEvent.setup();
const errorMessage = 'Invalid credentials';
@@ -359,13 +494,12 @@ describe('Login', () => {
it('should redirect to home when login disabled', async () => {
mockBackendProbeState.loginDisabled = true;
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: false,
providerList: {},
}),
} as Response);
},
});
render(
<TestWrapper>
@@ -381,15 +515,14 @@ describe('Login', () => {
});
it('should handle OAuth provider click', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/github': 'GitHub',
},
}),
} as Response);
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
@@ -416,13 +549,12 @@ describe('Login', () => {
});
it('should show email form by default when no SSO providers', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {}, // No providers
}),
} as Response);
},
});
render(
<TestWrapper>

View File

@@ -10,6 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout';
import { useBackendProbe } from '@app/hooks/useBackendProbe';
import apiClient from '@app/services/apiClient';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
// Import login components
import LoginHeader from '@app/routes/login/LoginHeader';
@@ -31,7 +32,7 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
const [password, setPassword] = useState('');
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const backendProbe = useBackendProbe();
@@ -226,25 +227,17 @@ export default function Login() {
);
}
// Known OAuth providers that have dedicated backend support
const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'apple', 'azure', 'keycloak', 'oidc'] as const;
type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
const signInWithProvider = async (provider: string) => {
const signInWithProvider = async (provider: OAuthProvider) => {
try {
setIsSigningIn(true);
setError(null);
// Map unknown providers to 'oidc' for the backend redirect
const backendProvider: KnownOAuthProvider = KNOWN_OAUTH_PROVIDERS.includes(provider as KnownOAuthProvider)
? (provider as KnownOAuthProvider)
: 'oidc';
console.log(`[Login] Signing in with provider: ${provider}`);
console.log(`[Login] Signing in with ${provider} (backend: ${backendProvider})`);
// Redirect to Spring OAuth2 endpoint
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
const { error } = await springAuth.signInWithOAuth({
provider: backendProvider,
provider: provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
});

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MantineProvider } from '@mantine/core';
import OAuthButtons from '@app/routes/login/OAuthButtons';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('OAuthButtons', () => {
const mockOnProviderClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render known providers with correct labels', () => {
const enabledProviders = ['google', 'github', 'authentik'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// Check that known providers are rendered with their labels
expect(screen.getByText('Google')).toBeTruthy();
expect(screen.getByText('GitHub')).toBeTruthy();
expect(screen.getByText('Authentik')).toBeTruthy();
});
it('should render unknown provider with capitalized label and generic icon', () => {
const enabledProviders = ['mycompany'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// Unknown provider should be capitalized
expect(screen.getByText('Mycompany')).toBeTruthy();
// Check that button has generic OIDC icon
const button = screen.getByText('Mycompany').closest('button');
expect(button).toBeTruthy();
const img = button?.querySelector('img');
expect(img?.src).toContain('oidc.svg');
});
it('should call onProviderClick with actual provider ID (not "oidc")', async () => {
const user = userEvent.setup();
const enabledProviders = ['mycompany'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
const button = screen.getByText('Mycompany');
await user.click(button);
// Should use actual provider ID 'mycompany', NOT 'oidc'
expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany');
});
it('should call onProviderClick with "authentik" when authentik is clicked', async () => {
const user = userEvent.setup();
const enabledProviders = ['authentik'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
const button = screen.getByText('Authentik');
await user.click(button);
expect(mockOnProviderClick).toHaveBeenCalledWith('authentik');
});
it('should call onProviderClick with "oidc" when OIDC is explicitly configured', async () => {
const user = userEvent.setup();
const enabledProviders = ['oidc'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
const button = screen.getByText('OIDC');
await user.click(button);
expect(mockOnProviderClick).toHaveBeenCalledWith('oidc');
});
it('should disable buttons when isSubmitting is true', () => {
const enabledProviders = ['google', 'github'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={true}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
const googleButton = screen.getByText('Google').closest('button') as HTMLButtonElement;
const githubButton = screen.getByText('GitHub').closest('button') as HTMLButtonElement;
expect(googleButton.disabled).toBe(true);
expect(githubButton.disabled).toBe(true);
});
it('should render nothing when no providers are enabled', () => {
const { container } = render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={[]}
/>
</TestWrapper>
);
// Should render null/nothing (excluding Mantine's style tags)
const hasContent = Array.from(container.children).some(
child => child.tagName.toLowerCase() !== 'style'
);
expect(hasContent).toBe(false);
});
it('should render multiple unknown providers with correct IDs', async () => {
const user = userEvent.setup();
const enabledProviders = ['company1', 'company2', 'company3'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// All should be capitalized
expect(screen.getByText('Company1')).toBeTruthy();
expect(screen.getByText('Company2')).toBeTruthy();
expect(screen.getByText('Company3')).toBeTruthy();
// Click each and verify correct ID is passed
await user.click(screen.getByText('Company1'));
expect(mockOnProviderClick).toHaveBeenCalledWith('company1');
await user.click(screen.getByText('Company2'));
expect(mockOnProviderClick).toHaveBeenCalledWith('company2');
await user.click(screen.getByText('Company3'));
expect(mockOnProviderClick).toHaveBeenCalledWith('company3');
});
it('should use correct icon for known providers', () => {
const enabledProviders = ['google', 'github', 'authentik', 'keycloak'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// Check that each known provider has its specific icon
const googleButton = screen.getByText('Google').closest('button');
expect(googleButton?.querySelector('img')?.src).toContain('google.svg');
const githubButton = screen.getByText('GitHub').closest('button');
expect(githubButton?.querySelector('img')?.src).toContain('github.svg');
const authentikButton = screen.getByText('Authentik').closest('button');
expect(authentikButton?.querySelector('img')?.src).toContain('authentik.svg');
const keycloakButton = screen.getByText('Keycloak').closest('button');
expect(keycloakButton?.querySelector('img')?.src).toContain('keycloak.svg');
});
it('should handle mixed known and unknown providers', async () => {
const user = userEvent.setup();
const enabledProviders = ['google', 'mycompany', 'authentik', 'custom'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// Known providers with correct labels
expect(screen.getByText('Google')).toBeTruthy();
expect(screen.getByText('Authentik')).toBeTruthy();
// Unknown providers with capitalized labels
expect(screen.getByText('Mycompany')).toBeTruthy();
expect(screen.getByText('Custom')).toBeTruthy();
// Click each and verify IDs are preserved
await user.click(screen.getByText('Google'));
expect(mockOnProviderClick).toHaveBeenCalledWith('google');
await user.click(screen.getByText('Mycompany'));
expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany');
await user.click(screen.getByText('Authentik'));
expect(mockOnProviderClick).toHaveBeenCalledWith('authentik');
await user.click(screen.getByText('Custom'));
expect(mockOnProviderClick).toHaveBeenCalledWith('custom');
});
it('should maintain provider ID consistency - critical for OAuth redirect', async () => {
const user = userEvent.setup();
// This test ensures the fix for GitHub issue #5141
// The provider ID used in the button click MUST match the backend registration ID
// Previously, unknown providers were mapped to 'oidc', breaking the OAuth flow
const enabledProviders = ['authentik', 'okta', 'auth0'];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>
);
// Each provider should use its actual ID, not 'oidc'
await user.click(screen.getByText('Authentik'));
expect(mockOnProviderClick).toHaveBeenLastCalledWith('authentik');
await user.click(screen.getByText('Okta'));
expect(mockOnProviderClick).toHaveBeenLastCalledWith('okta');
await user.click(screen.getByText('Auth0'));
expect(mockOnProviderClick).toHaveBeenLastCalledWith('auth0');
// Verify none were called with 'oidc' instead of their actual ID
expect(mockOnProviderClick).not.toHaveBeenCalledWith('oidc');
});
});

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
// Debug flag to show all providers for UI testing
// Set to true to see all SSO options regardless of backend configuration
@@ -22,10 +23,10 @@ export const oauthProviderConfig: Record<string, { label: string; file: string }
const GENERIC_PROVIDER_ICON = 'oidc.svg';
interface OAuthButtonsProps {
onProviderClick: (provider: string) => void
onProviderClick: (provider: OAuthProvider) => void
isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons'
enabledProviders?: string[] // List of enabled provider IDs from backend
enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {