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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 961 additions and 55 deletions

View File

@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
log.warn(
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
log.warn(
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
username);
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}

View File

@ -67,10 +67,15 @@ public class OAuth2Configuration {
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("No OAuth2 provider registered");
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
}
log.info(
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
registrations.size(),
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
return new InMemoryClientRegistrationRepository(registrations);
}
@ -165,7 +170,6 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(
@ -208,7 +212,19 @@ public class OAuth2Configuration {
null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
boolean isValid =
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
if (isValid) {
log.info(
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
@ -217,7 +233,7 @@ public class OAuth2Configuration {
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + "oidc")
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();

View File

@ -74,12 +74,18 @@ public class CustomSaml2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;

View File

@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@ -331,28 +332,45 @@ public class UserLicenseSettingsService {
}
/**
* Checks if a user is eligible to use OAuth authentication.
* Checks if a user is eligible to use OAuth/SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has a paid license (SERVER or ENTERPRISE)
* <li>The system has an ENTERPRISE license (SSO is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use OAuth
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
public boolean isOAuthEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// todo: remove
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
}
// Users can use OAuth with SERVER or ENTERPRISE license
boolean hasPaid = hasPaidLicense();
log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaid);
log.info(
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
}
@ -369,16 +387,32 @@ public class UserLicenseSettingsService {
* @param user The user to check
* @return true if the user can use SAML
*/
public boolean isSamlEligible(stirling.software.proprietary.security.model.User user) {
public boolean isSamlEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for SAML", user.getUsername());
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
return true;
}
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
log.debug("SAML eligibility check: hasEnterpriseLicense={}", hasEnterprise);
log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
hasEnterprise,
username,
hasEnterprise);
return hasEnterprise;
}
@ -521,12 +555,17 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
return hasPaid;
}
/**
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SAML.
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
* (OAuth/SAML).
*
* @return true if ENTERPRISE license is active
*/
@ -535,7 +574,19 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
log.info(
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
license,
(license == License.ENTERPRISE));
if (license != License.ENTERPRISE) {
log.warn(
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
license);
}
return license == License.ENTERPRISE;
}
}

View File

@ -0,0 +1,162 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/**
* Unit tests for OAuth2Configuration redirect URI logic.
*
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
* InvalidClientRegistrationIdException when custom provider names were used.
*
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
* Valid OIDC configuration responses 3. Network mocking infrastructure
*/
class OAuth2ConfigurationTest {
/**
* Tests the redirect URI pattern for OIDC provider configurations.
*
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
* registration ID. For example: - Provider name: "authentik" Redirect URI:
* "/login/oauth2/code/authentik" - Provider name: "mycompany" Redirect URI:
* "/login/oauth2/code/mycompany" - Provider name: "oidc" Redirect URI:
* "/login/oauth2/code/oidc"
*
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
* a registration with ID 'oidc' when the provider redirected back. This caused
* InvalidClientRegistrationIdException when custom provider names were used.
*/
@Test
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
// Verify the redirect URI pattern constant
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
// Test cases: provider name expected redirect URI
String[][] testCases = {
{"authentik", redirectUriBase + "authentik"},
{"mycompany", redirectUriBase + "mycompany"},
{"oidc", redirectUriBase + "oidc"},
{"okta", redirectUriBase + "okta"},
{"auth0", redirectUriBase + "auth0"}
};
for (String[] testCase : testCases) {
String providerName = testCase[0];
String expectedRedirectUri = testCase[1];
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
String actualRedirectUri = redirectUriBase + providerName;
assertEquals(
expectedRedirectUri,
actualRedirectUri,
String.format(
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
providerName));
}
}
/**
* Documents the critical fix for OAuth2 redirect URI mismatch.
*
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
*
* <pre>
* // BEFORE (bug):
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
*
* // AFTER (fix):
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
* </pre>
*/
@Test
void testCriticalFix_redirectUriMatchesRegistrationId() {
// The redirect URI path segment extraction by Spring Security
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
// Spring extracts the path segment between "code/" and "?"
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
// The extracted ID MUST match an actual registration ID
assertEquals("authentik", extractedRegistrationId);
// If we had used hardcoded "oidc", the callback would be:
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
// This would look for registration with ID "oidc" but we registered "authentik"
assertEquals("oidc", buggyExtractedId);
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
// Result: InvalidClientRegistrationIdException
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
}
/** Helper method simulating Spring's extraction of registration ID from callback URL */
private String extractRegistrationIdFromCallback(String callbackUrl) {
// Simplified version of what Spring Security does
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
String path = callbackUrl.split("\\?")[0];
String[] parts = path.split("/");
return parts[parts.length - 1]; // Last path segment
}
/**
* Validates the frontend-backend flow for custom provider names.
*
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
* "authentik" SUCCESS
*
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
* InvalidClientRegistrationIdException
*/
@Test
void testEndToEndFlow_registrationIdConsistency() {
String providerName = "authentik";
// Step 2: Registration ID
String registrationId = providerName;
assertEquals("authentik", registrationId);
// Step 3: Redirect URI (MUST use same name)
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
// Step 4: Provider list endpoint
String authorizationPath = "/oauth2/authorization/" + providerName;
assertEquals("/oauth2/authorization/authentik", authorizationPath);
// Step 5: Frontend extracts provider ID
String frontendProviderId =
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
assertEquals("authentik", frontendProviderId);
// Step 6-8: OAuth flow (external)
// Step 9: Callback URL from provider
String callbackUrl =
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
// Step 10: Registration lookup
assertEquals(
registrationId,
extractedId,
"Registration ID from callback MUST match original registration ID");
}
}

View File

@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest {
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
// ===== OAuth Eligibility Tests =====
@Test
void isOAuthEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
// Non-grandfathered user with SERVER license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isOAuthEligible(user);
assertEquals(
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without paid license should NOT be eligible");
}
@Test
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
// New user (null) with SERVER license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(null);
assertEquals(
true, result, "New user with SERVER license should be eligible for auto-creation");
}
@Test
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
// New user (null) without license should NOT be eligible
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(null);
assertEquals(
false,
result,
"New user without paid license should NOT be eligible for auto-creation");
}
@Test
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, OAuth should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isOAuthEligible(user);
assertEquals(
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
}
// ===== SAML Eligibility Tests =====
@Test
void isSamlEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible for SAML regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(user);
assertEquals(
true,
result,
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
// New user (null) with ENTERPRISE license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(null);
assertEquals(
true,
result,
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
}
@Test
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
// New user (null) with SERVER license should NOT be eligible for SAML
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(null);
assertEquals(
false,
result,
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
}
@Test
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, SAML should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isSamlEligible(user);
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
}
}

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) {