From 66d0344b8021262f5b1115f77061ce2b82ff8afd Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 07:45:36 +0200 Subject: [PATCH 1/6] Add Keycloak OAuth Provider: first commit --- .../apache/cloudstack/api/ApiConstants.java | 2 + .../META-INF/db/schema-42210to42300.sql | 4 + plugins/user-authenticators/oauth2/pom.xml | 5 + .../oauth2/OAuth2AuthManagerImpl.java | 47 ++-- .../api/command/ListOAuthProvidersCmd.java | 13 +- .../api/command/RegisterOAuthProviderCmd.java | 44 +++- .../api/command/UpdateOAuthProviderCmd.java | 35 ++- .../api/response/OauthProviderResponse.java | 35 ++- .../oauth2/github/GithubOAuth2Provider.java | 26 +-- .../oauth2/google/GoogleOAuth2Provider.java | 30 +-- .../keycloak/KeycloakOAuth2Provider.java | 153 +++++++++++++ .../cloudstack/oauth2/vo/OauthProviderVO.java | 34 ++- .../oauth2/spring-oauth2-context.xml | 5 +- .../keycloak/KeycloakOAuth2ProviderTest.java | 213 ++++++++++++++++++ ui/public/assets/keycloak.svg | 1 + ui/src/config/section/config.js | 8 +- ui/src/views/auth/Login.vue | 40 +++- 17 files changed, 609 insertions(+), 86 deletions(-) create mode 100644 plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java create mode 100644 plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java create mode 100644 ui/public/assets/keycloak.svg diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 05c6098bc726..8433f45947fd 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1322,8 +1322,10 @@ public class ApiConstants { public static final String VNF_CONFIGURE_MANAGEMENT = "vnfconfiguremanagement"; public static final String VNF_CIDR_LIST = "vnfcidrlist"; + public static final String AUTHORIZE_URL = "authorizeurl"; public static final String CLIENT_ID = "clientid"; public static final String REDIRECT_URI = "redirecturi"; + public static final String TOKEN_URL = "tokenurl"; public static final String IS_TAG_A_RULE = "istagarule"; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 4cb9eb7cb2c4..02f2aaabd10e 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -117,3 +117,7 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tin --- Disable/enable NICs CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nics','enabled', 'TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''Indicates whether the NIC is enabled or not'' '); + +--- Add URLs for OAuth provider +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider','authorize_url', 'VARCHAR(255) DEFAULT NULL COMMENT ''Authorize URL for OAuth initialization'' '); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider','token_url', 'VARCHAR(255) DEFAULT NULL COMMENT ''Token URL for OAuth finalization'' '); diff --git a/plugins/user-authenticators/oauth2/pom.xml b/plugins/user-authenticators/oauth2/pom.xml index 6ab7b9f5faba..89694440591c 100644 --- a/plugins/user-authenticators/oauth2/pom.xml +++ b/plugins/user-authenticators/oauth2/pom.xml @@ -38,6 +38,11 @@ cloud-framework-config ${project.version} + + org.apache.cxf + cxf-rt-rs-security-jose + ${cs.cxf.version} + com.google.apis google-api-services-docs diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java index b65027d6a249..b1bb8292f24a 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java @@ -18,10 +18,14 @@ // package org.apache.cloudstack.oauth2; -import com.cloud.user.dao.UserDao; -import com.cloud.utils.component.Manager; -import com.cloud.utils.component.ManagerBase; -import com.cloud.utils.exception.CloudRuntimeException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + import org.apache.cloudstack.auth.UserOAuth2Authenticator; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -35,16 +39,11 @@ import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.lang3.StringUtils; -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import com.cloud.utils.component.Manager; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.exception.CloudRuntimeException; public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthManager, Manager, Configurable { - @Inject - private UserDao _userDao; @Inject protected OauthProviderDao _oauthProviderDao; @@ -55,7 +54,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana @Override public List> getAuthCommands() { - List> cmdList = new ArrayList>(); + List> cmdList = new ArrayList<>(); cmdList.add(OauthLoginAPIAuthenticatorCmd.class); cmdList.add(ListOAuthProvidersCmd.class); cmdList.add(VerifyOAuthCodeAndGetUserCmd.class); @@ -84,7 +83,7 @@ public boolean stop() { @Override public List> getCommands() { - List> cmdList = new ArrayList>(); + List> cmdList = new ArrayList<>(); cmdList.add(RegisterOAuthProviderCmd.class); cmdList.add(DeleteOAuthProviderCmd.class); cmdList.add(UpdateOAuthProviderCmd.class); @@ -127,9 +126,7 @@ protected void initializeUserOAuth2AuthenticationProvidersMap() { @Override public String verifyCodeAndFetchEmail(String code, String provider) { UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider); - String email = authenticator.verifyCodeAndFetchEmail(code); - - return email; + return authenticator.verifyCodeAndFetchEmail(code); } @Override @@ -139,6 +136,8 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) { String clientId = StringUtils.trim(cmd.getClientId()); String redirectUri = StringUtils.trim(cmd.getRedirectUri()); String secretKey = StringUtils.trim(cmd.getSecretKey()); + String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); + String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); if (!isOAuthPluginEnabled()) { throw new CloudRuntimeException("OAuth is not enabled, please enable to register"); @@ -148,7 +147,7 @@ public OauthProviderVO registerOauthProvider(RegisterOAuthProviderCmd cmd) { throw new CloudRuntimeException(String.format("Provider with the name %s is already registered", provider)); } - return saveOauthProvider(provider, description, clientId, secretKey, redirectUri); + return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl); } @Override @@ -171,6 +170,8 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { String clientId = StringUtils.trim(cmd.getClientId()); String redirectUri = StringUtils.trim(cmd.getRedirectUri()); String secretKey = StringUtils.trim(cmd.getSecretKey()); + String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); + String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); Boolean enabled = cmd.getEnabled(); OauthProviderVO providerVO = _oauthProviderDao.findById(id); @@ -190,6 +191,12 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { if (StringUtils.isNotEmpty(secretKey)) { providerVO.setSecretKey(secretKey); } + if (StringUtils.isNotEmpty(authorizeUrl)) { + providerVO.setAuthorizeUrl(authorizeUrl); + } + if (StringUtils.isNotEmpty(tokenUrl)) { + providerVO.setTokenUrl(tokenUrl); + } if (enabled != null) { providerVO.setEnabled(enabled); } @@ -199,7 +206,7 @@ public OauthProviderVO updateOauthProvider(UpdateOAuthProviderCmd cmd) { return _oauthProviderDao.findById(id); } - private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri) { + private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) { final OauthProviderVO oauthProviderVO = new OauthProviderVO(); oauthProviderVO.setProvider(provider); @@ -207,6 +214,8 @@ private OauthProviderVO saveOauthProvider(String provider, String description, S oauthProviderVO.setClientId(clientId); oauthProviderVO.setSecretKey(secretKey); oauthProviderVO.setRedirectUri(redirectUri); + oauthProviderVO.setAuthorizeUrl(authorizeUrl); + oauthProviderVO.setTokenUrl(tokenUrl); oauthProviderVO.setEnabled(true); _oauthProviderDao.persist(oauthProviderVO); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java index abdbf65dbb42..9b91a1d879c2 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java @@ -21,8 +21,10 @@ import java.util.List; import java.util.Map; -import com.cloud.api.response.ApiResponseSerializer; -import com.cloud.user.Account; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -40,9 +42,8 @@ import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.lang.ArrayUtils; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.user.Account; @APICommand(name = "listOauthProvider", description = "List OAuth providers registered", responseObject = OauthProviderResponse.class, entityType = {}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, @@ -108,7 +109,7 @@ public String authenticate(String command, Map params, HttpSes List responses = new ArrayList<>(); for (OauthProviderVO result : resultList) { OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), - result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri()); + result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl()); if (OAuth2AuthManager.OAuth2IsPluginEnabled.value() && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) { r.setEnabled(true); } else { diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java index b31cbde97c52..534d6f7ca2a5 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java @@ -14,26 +14,28 @@ // limitations under the License. package org.apache.cloudstack.oauth2.api.command; +import java.util.Collection; +import java.util.Map; + import javax.inject.Inject; import javax.persistence.EntityExistsException; -import org.apache.cloudstack.api.response.SuccessResponse; -import org.apache.cloudstack.oauth2.OAuth2AuthManager; -import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.collections.MapUtils; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.oauth2.OAuth2AuthManager; +import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.codec.binary.StringUtils; +import org.apache.commons.collections.MapUtils; import com.cloud.exception.ConcurrentOperationException; -import java.util.Collection; -import java.util.Map; - @APICommand(name = "registerOauthProvider", responseObject = SuccessResponse.class, description = "Register the OAuth2 provider in CloudStack", since = "4.19.0") public class RegisterOAuthProviderCmd extends BaseCmd { @@ -56,6 +58,12 @@ public class RegisterOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider", required = true) private String redirectUri; + @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for keyloack provider)") + private String authorizeUrl; + + @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)") + private String tokenUrl; + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Any OAuth provider details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].clientsecret=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") protected Map details; @@ -85,6 +93,14 @@ public String getRedirectUri() { return redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + public Map getDetails() { if (MapUtils.isEmpty(details)) { return null; @@ -98,10 +114,20 @@ public Map getDetails() { @Override public void execute() throws ServerApiException, ConcurrentOperationException, EntityExistsException { + if (StringUtils.equals("keycloak", getProvider())) { + if (getAuthorizeUrl() == null || "".equals(getAuthorizeUrl())) { + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "authorizationurl parameter is mandatory for custom OAuth Provider"); + } + if (getTokenUrl() == null || "".equals(getTokenUrl())) { + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "tokenurl parameter is mandatory for custom OAuth Provider"); + } + } + OauthProviderVO provider = _oauth2mgr.registerOauthProvider(this); OauthProviderResponse response = new OauthProviderResponse(provider.getUuid(), provider.getProvider(), - provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri()); + provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri(), + provider.getAuthorizeUrl(), provider.getTokenUrl()); response.setResponseName(getCommandName()); response.setObjectName(ApiConstants.OAUTH_PROVIDER); setResponseObject(response); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java index 1c79b7b144c8..a8b0604a9bba 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java @@ -16,23 +16,23 @@ // under the License. package org.apache.cloudstack.oauth2.api.command; -import org.apache.cloudstack.api.ApiCommandResourceType; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.OAuth2AuthManager; -import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.auth.UserOAuth2Authenticator; import org.apache.cloudstack.context.CallContext; - -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.List; +import org.apache.cloudstack.oauth2.OAuth2AuthManager; +import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; @APICommand(name = "updateOauthProvider", description = "Updates the registered OAuth provider details", responseObject = OauthProviderResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0") @@ -57,6 +57,12 @@ public final class UpdateOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider") private String redirectUri; + @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL pre-registered in the specific OAuth provider") + private String authorizeUrl; + + @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL pre-registered in the specific OAuth provider") + private String tokenUrl; + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value") private Boolean enabled; @@ -87,6 +93,14 @@ public String getRedirectUri() { return redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + public Boolean getEnabled() { return enabled; } @@ -115,7 +129,8 @@ public void execute() { OauthProviderVO result = _oauthMgr.updateOauthProvider(this); if (result != null) { OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), - result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri()); + result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), + result.getAuthorizeUrl(), result.getTokenUrl()); List userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders(); List authenticatorPluginNames = new ArrayList<>(); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java index e0c40bef9b4d..289dc6650137 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java @@ -16,13 +16,14 @@ // under the License. package org.apache.cloudstack.oauth2.api.response; -import com.cloud.serializer.Param; -import com.google.gson.annotations.SerializedName; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseResponse; import org.apache.cloudstack.api.EntityReference; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + @EntityReference(value = OauthProviderVO.class) public class OauthProviderResponse extends BaseResponse { @@ -54,18 +55,28 @@ public class OauthProviderResponse extends BaseResponse { @Param(description = "Redirect URI registered in the OAuth provider") private String redirectUri; + @SerializedName(ApiConstants.AUTHORIZE_URL) + @Param(description = "Authorize URL registered in the OAuth provider") + private String authorizeUrl; + + @SerializedName(ApiConstants.TOKEN_URL) + @Param(description = "Token URL registered in the OAuth provider") + private String tokenUrl; + @SerializedName(ApiConstants.ENABLED) @Param(description = "Whether the OAuth provider is enabled or not") private boolean enabled; - public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri) { + public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) { this.id = id; this.provider = provider; this.name = provider; this.description = description; this.clientId = clientId; this.secretKey = secretKey; - this.redirectUri = redirectUri; + this.redirectUri = redirectUri; + this.authorizeUrl = authorizeUrl; + this.tokenUrl = tokenUrl; } public String getId() { @@ -117,6 +128,22 @@ public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public void setAuthorizeUrl(String authorizeUrl) { + this.authorizeUrl = authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + public String getSecretKey() { return secretKey; } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java index e4a7fae101f0..8722a0b219ce 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java @@ -16,17 +16,6 @@ //under the License. package org.apache.cloudstack.oauth2.github; -import com.cloud.utils.component.AdapterBase; -import com.cloud.utils.exception.CloudRuntimeException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.dao.OauthProviderDao; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.lang3.StringUtils; - -import javax.inject.Inject; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -36,6 +25,18 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.inject.Inject; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + public class GithubOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { @Inject @@ -85,10 +86,9 @@ public String verifyCodeAndFetchEmail(String secretCode) { protected String getAccessToken(String secretCode) throws CloudRuntimeException { OauthProviderVO githubProvider = _oauthProviderDao.findByProvider(getName()); - String tokenUrl = "https://github.com/login/oauth/access_token"; String generatedAccessToken = null; try { - URL url = new URL(tokenUrl); + URL url = new URL(githubProvider.getTokenUrl()); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java index 42ed1451ccd5..885930181c91 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java @@ -16,6 +16,17 @@ //under the License. package org.apache.cloudstack.oauth2.google; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import javax.inject.Inject; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; + import com.cloud.exception.CloudAuthenticationException; import com.cloud.utils.component.AdapterBase; import com.cloud.utils.exception.CloudRuntimeException; @@ -28,15 +39,6 @@ import com.google.api.client.json.jackson2.JacksonFactory; import com.google.api.services.oauth2.Oauth2; import com.google.api.services.oauth2.model.Userinfo; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.dao.OauthProviderDao; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.lang3.StringUtils; - -import javax.inject.Inject; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { @@ -78,10 +80,10 @@ public boolean verifyUser(String email, String secretCode) { @Override public String verifyCodeAndFetchEmail(String secretCode) { - OauthProviderVO githubProvider = _oauthProviderDao.findByProvider(getName()); - String clientId = githubProvider.getClientId(); - String secret = githubProvider.getSecretKey(); - String redirectURI = githubProvider.getRedirectUri(); + OauthProviderVO googleProvider = _oauthProviderDao.findByProvider(getName()); + String clientId = googleProvider.getClientId(); + String secret = googleProvider.getSecretKey(); + String redirectURI = googleProvider.getRedirectUri(); GoogleClientSecrets clientSecrets = new GoogleClientSecrets() .setWeb(new GoogleClientSecrets.Details() .setClientId(clientId) @@ -122,7 +124,7 @@ public String verifyCodeAndFetchEmail(String secretCode) { try { userinfo = oauth2.userinfo().get().execute(); } catch (IOException e) { - throw new CloudRuntimeException(String.format("Failed to fetch the email address with the provided secret: %s" + e.getMessage())); + throw new CloudRuntimeException(String.format("Failed to fetch the email address with the provided secret: %s", e.getMessage())); } return userinfo.getEmail(); } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java new file mode 100644 index 000000000000..f6c9ff563c17 --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -0,0 +1,153 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// +package org.apache.cloudstack.oauth2.keycloak; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.inject.Inject; +import javax.ws.rs.core.HttpHeaders; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + + @Inject + OauthProviderDao oauthProviderDao; + + private CloseableHttpClient httpClient; + + public KeycloakOAuth2Provider() { + this(HttpClientBuilder.create().build()); + } + + public KeycloakOAuth2Provider(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public String getName() { + return "keycloak"; + } + + @Override + public String getDescription() { + return "Keycloak OAuth2 Provider Plugin"; + } + + @Override + public boolean verifyUser(String email, String secretCode) { + if (StringUtils.isAnyEmpty(email, secretCode)) { + throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); + } + + OauthProviderVO providerVO = oauthProviderDao.findByProvider(getName()); + if (providerVO == null) { + throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified"); + } + + String verifiedEmail = verifyCodeAndFetchEmail(secretCode); + if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) { + throw new CloudRuntimeException("Unable to verify the email address with the provided secret"); + } + + return true; + } + + @Override + public String verifyCodeAndFetchEmail(String secretCode) { + OauthProviderVO provider = oauthProviderDao.findByProvider(getName()); + + String auth = provider.getClientId() + ":" + provider.getSecretKey(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "authorization_code")); + params.add(new BasicNameValuePair("code", secretCode)); + params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); + + HttpPost post = new HttpPost(provider.getTokenUrl()); + post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + + try { + post.setEntity(new UrlEncodedFormEntity(params)); + } catch (UnsupportedEncodingException e) { + throw new CloudRuntimeException("Unable to generating URL parameters: " + e.getMessage()); + } + + try (CloseableHttpResponse response = httpClient.execute(post)) { + String body = EntityUtils.toString(response.getEntity()); + + if (response.getStatusLine().getStatusCode() != 200) { + throw new CloudRuntimeException("Keycloak error during token generation: " + body); + } + + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + String idToken = json.get("id_token").getAsString(); + + return validateIdTokenAndGetEmail(idToken, provider); + } catch (IOException e) { + throw new CloudRuntimeException("Unable to connect to Keycloak server", e); + } + } + + @Override + public String getUserEmailAddress() throws CloudRuntimeException { + return null; + } + + private String validateIdTokenAndGetEmail(String idTokenStr, OauthProviderVO provider) { + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr); + JwtClaims claims = jwtConsumer.getJwtToken().getClaims(); + + if (!claims.getAudiences().contains(provider.getClientId())) { + throw new CloudAuthenticationException("Audience mismatch"); + } + + return (String) claims.getClaim("email"); + } + + public void setHttpClient(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + +} diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java index efd6004e8f97..54d667bc9143 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java @@ -16,9 +16,8 @@ // under the License. package org.apache.cloudstack.oauth2.vo; -import com.cloud.utils.db.GenericDao; -import org.apache.cloudstack.api.Identity; -import org.apache.cloudstack.api.InternalIdentity; +import java.util.Date; +import java.util.UUID; import javax.persistence.Column; import javax.persistence.Entity; @@ -26,8 +25,11 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; -import java.util.Date; -import java.util.UUID; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import com.cloud.utils.db.GenericDao; @Entity @Table(name = "oauth_provider") @@ -55,6 +57,12 @@ public class OauthProviderVO implements Identity, InternalIdentity { @Column(name = "redirect_uri") private String redirectUri; + @Column(name = "authorize_url") + private String authorizeUrl; + + @Column(name = "token_url") + private String tokenUrl; + @Column(name = GenericDao.CREATED_COLUMN) private Date created; @@ -110,6 +118,22 @@ public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public void setAuthorizeUrl(String authorizeUrl) { + this.authorizeUrl = authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + public String getSecretKey() { return secretKey; } diff --git a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml index 04a6c8dabfe7..06fe60f4c25e 100644 --- a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml +++ b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml @@ -35,6 +35,9 @@ + + + @@ -45,7 +48,7 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> - + diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java new file mode 100644 index 000000000000..aa85f52d9250 --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java @@ -0,0 +1,213 @@ +package org.apache.cloudstack.oauth2.keycloak; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.http.HttpEntity; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.exception.CloudRuntimeException; + +public class KeycloakOAuth2ProviderTest { + + @Mock + private OauthProviderDao oauthProviderDao; + + @Mock + private CloseableHttpClient httpClient; + + private KeycloakOAuth2Provider provider; + + private OauthProviderVO mockProviderVO; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + provider = new KeycloakOAuth2Provider(httpClient); + provider.oauthProviderDao = oauthProviderDao; + + mockProviderVO = new OauthProviderVO(); + mockProviderVO.setClientId("test-client"); + mockProviderVO.setSecretKey("test-secret"); + mockProviderVO.setTokenUrl("http://localhost/token"); + mockProviderVO.setRedirectUri("http://localhost/redirect"); + } + + @Test + public void testGetName() { + assertEquals("keycloak", provider.getName()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserEmptyParams() { + provider.verifyUser("", ""); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserProviderNotFound() { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(null); + provider.verifyUser("test@example.com", "code123"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailHttpError() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(400); + when(response.getStatusLine()).thenReturn(statusLine); + + HttpEntity entity = mock(HttpEntity.class); + when(entity.getContent()).thenReturn(new ByteArrayInputStream("error".getBytes())); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + provider.verifyCodeAndFetchEmail("invalid-code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connexion refusée")); + + provider.verifyCodeAndFetchEmail("code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedEmail() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "anotheruser@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"test-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + boolean result = provider.verifyUser("user@example.com", secretCode); + + assertTrue("L'utilisateur devrait être vérifié avec succès", result); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedClient() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "anotheruser@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"anothertest-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + boolean result = provider.verifyUser(testEmail, secretCode); + + assertTrue("L'utilisateur devrait être vérifié avec succès", result); + } + + @Test + public void testVerifyUserEmail() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "user@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"test-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + boolean result = provider.verifyUser(testEmail, secretCode); + + assertTrue("L'utilisateur devrait être vérifié avec succès", result); + } + + @Test + public void testGetDescription() { + assertEquals("Keycloak OAuth2 Provider Plugin", provider.getDescription()); + } +} \ No newline at end of file diff --git a/ui/public/assets/keycloak.svg b/ui/public/assets/keycloak.svg new file mode 100644 index 000000000000..3e8115efc160 --- /dev/null +++ b/ui/public/assets/keycloak.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js index e190515855e6..2a83b25c002f 100644 --- a/ui/src/config/section/config.js +++ b/ui/src/config/section/config.js @@ -80,7 +80,7 @@ export default { docHelp: 'adminguide/accounts.html#using-an-ldap-server-for-user-authentication', permission: ['listOauthProvider'], columns: ['provider', 'enabled', 'description', 'clientid', 'secretkey', 'redirecturi'], - details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi'], + details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl'], actions: [ { api: 'registerOauthProvider', @@ -89,11 +89,11 @@ export default { listView: true, dataView: false, args: [ - 'provider', 'description', 'clientid', 'redirecturi', 'secretkey' + 'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl' ], mapping: { provider: { - options: ['google', 'github'] + options: ['google', 'github', 'keycloak'] } } }, @@ -103,7 +103,7 @@ export default { label: 'label.edit', dataView: true, popup: true, - args: ['description', 'clientid', 'redirecturi', 'secretkey'] + args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl'] }, { api: 'updateOauthProvider', diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index 24065f47b1aa..f5e63424fff4 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -202,6 +202,18 @@ Sign in with Google + @@ -231,10 +243,14 @@ export default { socialLogin: false, googleprovider: false, githubprovider: false, + keycloakprovider: false, googleredirecturi: '', githubredirecturi: '', + keycloakredirecturi: '', googleclientid: '', githubclientid: '', + keycloakclientid: '', + keycloakauthorizeurl: '', loginType: 0, state: { time: 60, @@ -325,8 +341,14 @@ export default { this.githubclientid = item.clientid this.githubredirecturi = item.redirecturi } + if (item.provider === 'keycloak') { + this.keycloakprovider = item.enabled + this.keycloakclientid = item.clientid + this.keycloakredirecturi = item.redirecturi + this.keycloakauthorizeurl = item.authorizeurl + } }) - this.socialLogin = this.googleprovider || this.githubprovider + this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider } }) postAPI('forgotPassword', {}).then(response => { @@ -362,6 +384,10 @@ export default { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'google') }, + handleKeycloakProviderAndDomain () { + this.handleDomain() + this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'keycloak') + }, handleDomain () { const values = toRaw(this.form) if (!values.domain) { @@ -401,6 +427,18 @@ export default { return `${rootUrl}?${qs.toString()}` }, + getKeycloakUrl (from) { + const rootURl = keycloakauthorizeurl + const options = { + client_id: this.keycloakclientid, + scope: 'openid email', + state: from + } + + const qs = new URLSearchParams(options) + + return `${rootURl}?${qs.toString()}` + }, handleSubmit (e) { e.preventDefault() if (this.state.loginBtn) return From c7c729775255ffa76bcc97b3d369aaadfa780998 Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 16:21:29 +0200 Subject: [PATCH 2/6] Fix issue --- ui/src/views/auth/Login.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index f5e63424fff4..bded0820462d 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -428,7 +428,7 @@ export default { return `${rootUrl}?${qs.toString()}` }, getKeycloakUrl (from) { - const rootURl = keycloakauthorizeurl + const rootURl = this.keycloakauthorizeurl const options = { client_id: this.keycloakclientid, scope: 'openid email', From 506f664bea65ba4d4d811606bbf647f3359151c2 Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 19:14:34 +0200 Subject: [PATCH 3/6] Cache idToken --- .../keycloak/KeycloakOAuth2Provider.java | 69 ++++++++++++------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java index f6c9ff563c17..0d028cfc4a1b 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -51,6 +51,8 @@ public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + protected String idToken = null; + @Inject OauthProviderDao oauthProviderDao; @@ -89,6 +91,7 @@ public boolean verifyUser(String email, String secretCode) { if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) { throw new CloudRuntimeException("Unable to verify the email address with the provided secret"); } + clearIdToken(); return true; } @@ -97,37 +100,42 @@ public boolean verifyUser(String email, String secretCode) { public String verifyCodeAndFetchEmail(String secretCode) { OauthProviderVO provider = oauthProviderDao.findByProvider(getName()); - String auth = provider.getClientId() + ":" + provider.getSecretKey(); - String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + if (StringUtils.isBlank(idToken)) { + String auth = provider.getClientId() + ":" + provider.getSecretKey(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); - List params = new ArrayList<>(); - params.add(new BasicNameValuePair("grant_type", "authorization_code")); - params.add(new BasicNameValuePair("code", secretCode)); - params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "authorization_code")); + params.add(new BasicNameValuePair("code", secretCode)); + params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); - HttpPost post = new HttpPost(provider.getTokenUrl()); - post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + HttpPost post = new HttpPost(provider.getTokenUrl()); + post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); - try { - post.setEntity(new UrlEncodedFormEntity(params)); - } catch (UnsupportedEncodingException e) { - throw new CloudRuntimeException("Unable to generating URL parameters: " + e.getMessage()); - } + try { + post.setEntity(new UrlEncodedFormEntity(params)); + } catch (UnsupportedEncodingException e) { + throw new CloudRuntimeException("Unable to generating URL parameters: " + e.getMessage()); + } - try (CloseableHttpResponse response = httpClient.execute(post)) { - String body = EntityUtils.toString(response.getEntity()); + try (CloseableHttpResponse response = httpClient.execute(post)) { + String body = EntityUtils.toString(response.getEntity()); - if (response.getStatusLine().getStatusCode() != 200) { - throw new CloudRuntimeException("Keycloak error during token generation: " + body); - } + if (response.getStatusLine().getStatusCode() != 200) { + throw new CloudRuntimeException("Keycloak error during token generation: " + body); + } - JsonObject json = JsonParser.parseString(body).getAsJsonObject(); - String idToken = json.get("id_token").getAsString(); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + String idToken = json.get("id_token").getAsString(); + validateIdToken(idToken, provider); - return validateIdTokenAndGetEmail(idToken, provider); - } catch (IOException e) { - throw new CloudRuntimeException("Unable to connect to Keycloak server", e); + this.idToken = idToken; + } catch (IOException e) { + throw new CloudRuntimeException("Unable to connect to Keycloak server", e); + } } + + return obtainEmail(idToken, provider); } @Override @@ -135,7 +143,16 @@ public String getUserEmailAddress() throws CloudRuntimeException { return null; } - private String validateIdTokenAndGetEmail(String idTokenStr, OauthProviderVO provider) { + private void validateIdToken(String idTokenStr, OauthProviderVO provider) { + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr); + JwtClaims claims = jwtConsumer.getJwtToken().getClaims(); + + if (!claims.getAudiences().contains(provider.getClientId())) { + throw new CloudAuthenticationException("Audience mismatch"); + } + } + + private String obtainEmail(String idTokenStr, OauthProviderVO provider) { JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr); JwtClaims claims = jwtConsumer.getJwtToken().getClaims(); @@ -146,6 +163,10 @@ private String validateIdTokenAndGetEmail(String idTokenStr, OauthProviderVO pro return (String) claims.getClaim("email"); } + protected void clearIdToken() { + idToken = null; + } + public void setHttpClient(CloseableHttpClient httpClient) { this.httpClient = httpClient; } From 28ea821fa8574c3429245abd72506813bd4e541e Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 19:14:56 +0200 Subject: [PATCH 4/6] Fix variables --- ui/src/views/auth/Login.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index bded0820462d..691fd75cf34e 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -430,9 +430,11 @@ export default { getKeycloakUrl (from) { const rootURl = this.keycloakauthorizeurl const options = { + redirect_uri: this.keycloakredirecturi, client_id: this.keycloakclientid, + response_type: 'code', scope: 'openid email', - state: from + state: 'cloudstack' } const qs = new URLSearchParams(options) From 3d037abc6cfe7f122d792d1b496d45438ebe0b70 Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 20:19:43 +0200 Subject: [PATCH 5/6] Add labels --- ui/public/locales/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 6f3a623b8092..b9af10ee3499 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -438,6 +438,7 @@ "label.attaching": "Attaching", "label.authentication.method": "Authentication Method", "label.authentication.sshkey": "System SSH Key", +"label.authorizeurl": "Authorize URL", "label.use.existing.vcenter.credentials.from.zone": "Use existing vCenter credentials from the Zone", "label.autoscale": "AutoScale", "label.autoscalevmgroupname": "AutoScaling Group", @@ -2576,6 +2577,7 @@ "label.to": "to", "label.token": "Token", "label.token.for.dashboard.login": "Token for dashboard login can be retrieved using following command", +"label.tokenurl": "Token URL", "label.tools": "Tools", "label.total": "Total", "label.total.network": "Total Networks", From a0270e141ba145cc6298b276daa6ff284111dcd4 Mon Sep 17 00:00:00 2001 From: "joel.tazzari" Date: Wed, 15 Apr 2026 20:28:34 +0200 Subject: [PATCH 6/6] Change error message --- .../oauth2/api/command/RegisterOAuthProviderCmd.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java index 534d6f7ca2a5..96fce48b7a92 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java @@ -116,10 +116,10 @@ public Map getDetails() { public void execute() throws ServerApiException, ConcurrentOperationException, EntityExistsException { if (StringUtils.equals("keycloak", getProvider())) { if (getAuthorizeUrl() == null || "".equals(getAuthorizeUrl())) { - throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "authorizationurl parameter is mandatory for custom OAuth Provider"); + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter authorizationurl is mandatory for keycloak OAuth Provider"); } if (getTokenUrl() == null || "".equals(getTokenUrl())) { - throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "tokenurl parameter is mandatory for custom OAuth Provider"); + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter tokenurl is mandatory for keycloak OAuth Provider"); } }