mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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 <[email protected]> Co-authored-by: Keon Chen <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
Keon Chen
parent
9fd8fd89ed
commit
82dbcfbb9b
+8
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
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.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CustomOAuth2AuthenticationSuccessHandler
|
public class CustomOAuth2AuthenticationSuccessHandler
|
||||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||||
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
|
|
||||||
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
||||||
// User is not grandfathered and no paid license - block OAuth login
|
// 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(
|
response.sendRedirect(
|
||||||
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||||
// No existing user and no paid license -> block auto creation
|
// 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");
|
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
|
|||||||
keycloakClientRegistration().ifPresent(registrations::add);
|
keycloakClientRegistration().ifPresent(registrations::add);
|
||||||
|
|
||||||
if (registrations.isEmpty()) {
|
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.");
|
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);
|
return new InMemoryClientRegistrationRepository(registrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
|
|||||||
githubClient.getUseAsUsername());
|
githubClient.getUseAsUsername());
|
||||||
|
|
||||||
boolean isValid = validateProvider(github);
|
boolean isValid = validateProvider(github);
|
||||||
log.info("Initialised GitHub OAuth2 provider");
|
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
? Optional.of(
|
? Optional.of(
|
||||||
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
|
|||||||
null,
|
null,
|
||||||
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(
|
? Optional.of(
|
||||||
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
|
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
|
||||||
.registrationId(name)
|
.registrationId(name)
|
||||||
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
|
|||||||
.scope(oidcProvider.getScopes())
|
.scope(oidcProvider.getScopes())
|
||||||
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
|
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
|
||||||
.clientName(clientName)
|
.clientName(clientName)
|
||||||
.redirectUri(REDIRECT_URI_PATH + "oidc")
|
.redirectUri(REDIRECT_URI_PATH + name)
|
||||||
.authorizationGrantType(AUTHORIZATION_CODE)
|
.authorizationGrantType(AUTHORIZATION_CODE)
|
||||||
.build())
|
.build())
|
||||||
: Optional.empty();
|
: Optional.empty();
|
||||||
|
|||||||
+6
@@ -74,12 +74,18 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
|
|
||||||
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
||||||
// User is not grandfathered and no ENTERPRISE license - block SAML login
|
// 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(
|
response.sendRedirect(
|
||||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!licenseSettingsService.isSamlEligible(null)) {
|
} else if (!licenseSettingsService.isSamlEligible(null)) {
|
||||||
// No existing user and no ENTERPRISE license -> block auto creation
|
// 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(
|
response.sendRedirect(
|
||||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||||
return;
|
return;
|
||||||
|
|||||||
+61
-10
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
|
|||||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
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.repository.UserLicenseSettingsRepository;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
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:
|
* <p>A user is eligible if:
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>They are grandfathered for OAuth (existing user before policy change), OR
|
* <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>
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param user The user to check
|
* @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
|
// Grandfathered users always have OAuth access
|
||||||
if (user != null && user.isOauthGrandfathered()) {
|
if (user != null && user.isOauthGrandfathered()) {
|
||||||
log.debug("User {} is grandfathered for OAuth", user.getUsername());
|
log.debug("User {} is grandfathered for OAuth", user.getUsername());
|
||||||
return true;
|
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
|
// Users can use OAuth with SERVER or ENTERPRISE license
|
||||||
boolean hasPaid = hasPaidLicense();
|
boolean hasPaid = hasPaidLicense();
|
||||||
log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaid);
|
log.info(
|
||||||
|
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
|
||||||
|
hasPaid,
|
||||||
|
username,
|
||||||
|
hasPaid);
|
||||||
return hasPaid;
|
return hasPaid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,16 +387,32 @@ public class UserLicenseSettingsService {
|
|||||||
* @param user The user to check
|
* @param user The user to check
|
||||||
* @return true if the user can use SAML
|
* @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
|
// Grandfathered users always have SAML access
|
||||||
if (user != null && user.isOauthGrandfathered()) {
|
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;
|
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
|
// Users can use SAML only with ENTERPRISE license
|
||||||
boolean hasEnterprise = hasEnterpriseLicense();
|
boolean hasEnterprise = hasEnterpriseLicense();
|
||||||
log.debug("SAML eligibility check: hasEnterpriseLicense={}", hasEnterprise);
|
log.info(
|
||||||
|
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
|
||||||
|
hasEnterprise,
|
||||||
|
username,
|
||||||
|
hasEnterprise);
|
||||||
return hasEnterprise;
|
return hasEnterprise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,12 +555,17 @@ public class UserLicenseSettingsService {
|
|||||||
if (checker == null) {
|
if (checker == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
License license = checker.getPremiumLicenseEnabledResult();
|
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
|
* @return true if ENTERPRISE license is active
|
||||||
*/
|
*/
|
||||||
@@ -535,7 +574,19 @@ public class UserLicenseSettingsService {
|
|||||||
if (checker == null) {
|
if (checker == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
License license = checker.getPremiumLicenseEnabledResult();
|
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;
|
return license == License.ENTERPRISE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+162
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
@@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest {
|
|||||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & {});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||||
|
|
||||||
// Helper to extract error message from axios error
|
// Helper to extract error message from axios error
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
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
|
* 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: {
|
async signInWithOAuth(params: {
|
||||||
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
|
provider: OAuthProvider;
|
||||||
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
||||||
}): Promise<{ error: AuthError | null }> {
|
}): Promise<{ error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
|
|||||||
import { useAuth } from '@app/auth/UseSession';
|
import { useAuth } from '@app/auth/UseSession';
|
||||||
import { springAuth } from '@app/auth/springAuthClient';
|
import { springAuth } from '@app/auth/springAuthClient';
|
||||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
// Mock i18n to return fallback text
|
// Mock i18n to return fallback text
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
@@ -36,8 +37,13 @@ vi.mock('@app/hooks/useDocumentMeta', () => ({
|
|||||||
useDocumentMeta: vi.fn(),
|
useDocumentMeta: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock fetch for provider list
|
// Mock apiClient for provider list
|
||||||
global.fetch = vi.fn();
|
vi.mock('@app/services/apiClient', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
const mockBackendProbeState = {
|
const mockBackendProbeState = {
|
||||||
@@ -89,14 +95,13 @@ describe('Login', () => {
|
|||||||
refreshSession: vi.fn(),
|
refreshSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock fetch for login UI data
|
// Mock apiClient for login UI data
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
vi.mocked(apiClient.get).mockResolvedValue({
|
||||||
ok: true,
|
data: {
|
||||||
json: async () => ({
|
|
||||||
enableLogin: true,
|
enableLogin: true,
|
||||||
providerList: {},
|
providerList: {},
|
||||||
}),
|
},
|
||||||
} as Response);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render login form', async () => {
|
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 () => {
|
it('should show error on failed login', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const errorMessage = 'Invalid credentials';
|
const errorMessage = 'Invalid credentials';
|
||||||
@@ -359,13 +494,12 @@ describe('Login', () => {
|
|||||||
it('should redirect to home when login disabled', async () => {
|
it('should redirect to home when login disabled', async () => {
|
||||||
mockBackendProbeState.loginDisabled = true;
|
mockBackendProbeState.loginDisabled = true;
|
||||||
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
|
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
ok: true,
|
data: {
|
||||||
json: async () => ({
|
|
||||||
enableLogin: false,
|
enableLogin: false,
|
||||||
providerList: {},
|
providerList: {},
|
||||||
}),
|
},
|
||||||
} as Response);
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TestWrapper>
|
<TestWrapper>
|
||||||
@@ -381,15 +515,14 @@ describe('Login', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle OAuth provider click', async () => {
|
it('should handle OAuth provider click', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
ok: true,
|
data: {
|
||||||
json: async () => ({
|
|
||||||
enableLogin: true,
|
enableLogin: true,
|
||||||
providerList: {
|
providerList: {
|
||||||
'/oauth2/authorization/github': 'GitHub',
|
'/oauth2/authorization/github': 'GitHub',
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
} as Response);
|
});
|
||||||
|
|
||||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||||
error: null,
|
error: null,
|
||||||
@@ -416,13 +549,12 @@ describe('Login', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show email form by default when no SSO providers', async () => {
|
it('should show email form by default when no SSO providers', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||||
ok: true,
|
data: {
|
||||||
json: async () => ({
|
|
||||||
enableLogin: true,
|
enableLogin: true,
|
||||||
providerList: {}, // No providers
|
providerList: {}, // No providers
|
||||||
}),
|
},
|
||||||
} as Response);
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TestWrapper>
|
<TestWrapper>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout';
|
|||||||
import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||||
|
|
||||||
// Import login components
|
// Import login components
|
||||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||||
@@ -31,7 +32,7 @@ export default function Login() {
|
|||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
|
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [enabledProviders, setEnabledProviders] = useState<string[]>([]);
|
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
|
||||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||||
const backendProbe = useBackendProbe();
|
const backendProbe = useBackendProbe();
|
||||||
@@ -226,25 +227,17 @@ export default function Login() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known OAuth providers that have dedicated backend support
|
const signInWithProvider = async (provider: OAuthProvider) => {
|
||||||
const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'apple', 'azure', 'keycloak', 'oidc'] as const;
|
|
||||||
type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
|
|
||||||
|
|
||||||
const signInWithProvider = async (provider: string) => {
|
|
||||||
try {
|
try {
|
||||||
setIsSigningIn(true);
|
setIsSigningIn(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Map unknown providers to 'oidc' for the backend redirect
|
console.log(`[Login] Signing in with provider: ${provider}`);
|
||||||
const backendProvider: KnownOAuthProvider = KNOWN_OAUTH_PROVIDERS.includes(provider as KnownOAuthProvider)
|
|
||||||
? (provider as KnownOAuthProvider)
|
|
||||||
: 'oidc';
|
|
||||||
|
|
||||||
console.log(`[Login] Signing in with ${provider} (backend: ${backendProvider})`);
|
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
|
||||||
|
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
|
||||||
// Redirect to Spring OAuth2 endpoint
|
|
||||||
const { error } = await springAuth.signInWithOAuth({
|
const { error } = await springAuth.signInWithOAuth({
|
||||||
provider: backendProvider,
|
provider: provider,
|
||||||
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||||
|
|
||||||
// Debug flag to show all providers for UI testing
|
// Debug flag to show all providers for UI testing
|
||||||
// Set to true to see all SSO options regardless of backend configuration
|
// 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';
|
const GENERIC_PROVIDER_ICON = 'oidc.svg';
|
||||||
|
|
||||||
interface OAuthButtonsProps {
|
interface OAuthButtonsProps {
|
||||||
onProviderClick: (provider: string) => void
|
onProviderClick: (provider: OAuthProvider) => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
layout?: 'vertical' | 'grid' | 'icons'
|
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) {
|
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {
|
||||||
|
|||||||
Reference in New Issue
Block a user