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:
Dario Ghunney Ware
2025-12-05 23:19:41 +00:00
committed by GitHub
co-authored by Anthony Stirling Keon Chen
parent 9fd8fd89ed
commit 82dbcfbb9b
12 changed files with 961 additions and 55 deletions
@@ -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;
}
@@ -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();
@@ -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;
@@ -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;
}
}