This is an automated email from the ASF dual-hosted git repository.

weizhouapache pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new c2c855b9b18 Add keycloak OAuth provider (#13033)
c2c855b9b18 is described below

commit c2c855b9b18cf238e8fa22a9c010691f6a867f3b
Author: Joël <[email protected]>
AuthorDate: Tue Jun 23 14:02:00 2026 +0200

    Add keycloak OAuth provider (#13033)
---
 .../org/apache/cloudstack/api/ApiConstants.java    |   2 +
 .../resources/META-INF/db/schema-42210to42300.sql  |   4 +
 plugins/user-authenticators/oauth2/pom.xml         |   5 +
 .../cloudstack/oauth2/OAuth2AuthManagerImpl.java   |  47 +++--
 .../oauth2/api/command/ListOAuthProvidersCmd.java  |  13 +-
 .../api/command/RegisterOAuthProviderCmd.java      |  45 ++++-
 .../oauth2/api/command/UpdateOAuthProviderCmd.java |  35 +++-
 .../oauth2/api/response/OauthProviderResponse.java |  35 +++-
 .../oauth2/github/GithubOAuth2Provider.java        |  23 ++-
 .../oauth2/google/GoogleOAuth2Provider.java        |  30 +--
 .../oauth2/keycloak/KeycloakOAuth2Provider.java    | 184 +++++++++++++++++
 .../cloudstack/oauth2/vo/OauthProviderVO.java      |  34 +++-
 .../cloudstack/oauth2/spring-oauth2-context.xml    |   5 +-
 .../keycloak/KeycloakOAuth2ProviderTest.java       | 225 +++++++++++++++++++++
 ui/public/assets/keycloak.svg                      |   1 +
 ui/public/locales/en.json                          |   2 +
 ui/src/config/section/config.js                    |   8 +-
 ui/src/views/auth/Login.vue                        |  48 ++++-
 18 files changed, 659 insertions(+), 87 deletions(-)

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 17416f08690..2150cfb2200 100644
--- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
+++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
@@ -1343,8 +1343,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 4f4d37fa8c2..bd5ecbab21c 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
@@ -122,6 +122,10 @@ 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'' ');
+
 --- Quota tariff/usage mapping
 CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_tariff_usage` (
     `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/plugins/user-authenticators/oauth2/pom.xml 
b/plugins/user-authenticators/oauth2/pom.xml
index 6ab7b9f5fab..89694440591 100644
--- a/plugins/user-authenticators/oauth2/pom.xml
+++ b/plugins/user-authenticators/oauth2/pom.xml
@@ -38,6 +38,11 @@
             <artifactId>cloud-framework-config</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.cxf</groupId>
+            <artifactId>cxf-rt-rs-security-jose</artifactId>
+            <version>${cs.cxf.version}</version>
+        </dependency>
         <dependency>
             <groupId>com.google.apis</groupId>
             <artifactId>google-api-services-docs</artifactId>
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 b65027d6a24..b1bb8292f24 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.dao.OauthProviderDao;
 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<Class<?>> getAuthCommands() {
-        List<Class<?>> cmdList = new ArrayList<Class<?>>();
+        List<Class<?>> cmdList = new ArrayList<>();
         cmdList.add(OauthLoginAPIAuthenticatorCmd.class);
         cmdList.add(ListOAuthProvidersCmd.class);
         cmdList.add(VerifyOAuthCodeAndGetUserCmd.class);
@@ -84,7 +83,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
 
     @Override
     public List<Class<?>> getCommands() {
-        List<Class<?>> cmdList = new ArrayList<Class<?>>();
+        List<Class<?>> cmdList = new ArrayList<>();
         cmdList.add(RegisterOAuthProviderCmd.class);
         cmdList.add(DeleteOAuthProviderCmd.class);
         cmdList.add(UpdateOAuthProviderCmd.class);
@@ -127,9 +126,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
     @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 class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
         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 class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
             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 class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
         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 class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
         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 class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
         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 @@ public class OAuth2AuthManagerImpl extends ManagerBase 
implements OAuth2AuthMana
         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 abdbf65dbb4..9b91a1d879c 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.ArrayList;
 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.api.response.OauthProviderResponse;
 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 class ListOAuthProvidersCmd extends BaseListCmd 
implements APIAuthenticat
         List<OauthProviderResponse> 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 b31cbde97c5..8eb4493d76d 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,29 @@
 // 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.keycloak.KeycloakOAuth2Provider;
+import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
+import org.apache.commons.collections.MapUtils;
+import org.apache.commons.lang3.StringUtils;
 
 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 +59,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 
keycloak 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 +94,14 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
         return redirectUri;
     }
 
+    public String getAuthorizeUrl() {
+        return authorizeUrl;
+    }
+
+    public String getTokenUrl() {
+        return tokenUrl;
+    }
+
     public Map getDetails() {
         if (MapUtils.isEmpty(details)) {
             return null;
@@ -98,10 +115,20 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
 
     @Override
     public void execute() throws ServerApiException, 
ConcurrentOperationException, EntityExistsException {
+        if (StringUtils.equals(KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, 
getProvider())) {
+            if (StringUtils.isBlank(getAuthorizeUrl())) {
+                throw new ServerApiException(ApiErrorCode.BAD_REQUEST, 
"Parameter authorizeurl is mandatory for keycloak OAuth Provider");
+            }
+            if (StringUtils.isBlank(getTokenUrl())) {
+                throw new ServerApiException(ApiErrorCode.BAD_REQUEST, 
"Parameter tokenurl is mandatory for keycloak 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 1c79b7b144c..a8b0604a9bb 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 final class UpdateOAuthProviderCmd extends BaseCmd {
         return redirectUri;
     }
 
+    public String getAuthorizeUrl() {
+        return authorizeUrl;
+    }
+
+    public String getTokenUrl() {
+        return tokenUrl;
+    }
+
     public Boolean getEnabled() {
         return enabled;
     }
@@ -115,7 +129,8 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
         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<UserOAuth2Authenticator> userOAuth2AuthenticatorPlugins = 
_oauthMgr.listUserOAuth2AuthenticationProviders();
             List<String> 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 e0c40bef9b4..289dc665013 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 class OauthProviderResponse extends BaseResponse {
         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 e4a7fae101f..4d426181a94 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.net.URL;
 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
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 42ed1451ccd..885930181c9 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.JsonFactory;
 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 class GoogleOAuth2Provider extends AdapterBase 
implements UserOAuth2Authe
 
     @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 class GoogleOAuth2Provider extends AdapterBase 
implements UserOAuth2Authe
         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 00000000000..3f537b1984d
--- /dev/null
+++ 
b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java
@@ -0,0 +1,184 @@
+//
+// 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.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+public class KeycloakOAuth2Provider extends AdapterBase implements 
UserOAuth2Authenticator {
+
+    public static final String KEYCLOAK_PROVIDER = "keycloak";
+
+    protected String idToken = null;
+
+    @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_PROVIDER;
+    }
+
+    @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");
+        }
+        clearIdToken();
+
+        return true;
+    }
+
+    @Override
+    public String verifyCodeAndFetchEmail(String secretCode) {
+        OauthProviderVO provider = oauthProviderDao.findByProvider(getName());
+        if (provider == null) {
+            throw new CloudAuthenticationException("Keycloak provider is not 
registered, so user cannot be verified");
+        }
+
+        if (StringUtils.isBlank(idToken)) {
+            String auth = provider.getClientId() + ":" + 
provider.getSecretKey();
+            String encodedAuth = 
Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
+
+            List<NameValuePair> 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 generate 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();
+                JsonElement fetchedIdToken = json.get("id_token");
+                if (fetchedIdToken == null) {
+                    throw new CloudRuntimeException("No id_token found in 
token");
+                }
+                String idTokenAsString = fetchedIdToken.getAsString();
+                validateIdToken(idTokenAsString , provider);
+
+                this.idToken = idTokenAsString ;
+            } catch (IOException e) {
+                throw new CloudRuntimeException("Unable to connect to Keycloak 
server", e);
+            }
+        }
+
+        return obtainEmail(idToken, provider);
+    }
+
+    @Override
+    public String getUserEmailAddress() throws CloudRuntimeException {
+        return null;
+    }
+
+    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();
+
+        if (!claims.getAudiences().contains(provider.getClientId())) {
+            throw new CloudAuthenticationException("Audience mismatch");
+        }
+
+        return (String) claims.getClaim("email");
+    }
+
+    protected void clearIdToken() {
+        idToken = null;
+    }
+
+    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 efd6004e8f9..54d667bc914 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.GeneratedValue;
 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 class OauthProviderVO implements Identity, 
InternalIdentity {
         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 04a6c8dabfe..06fe60f4c25 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 @@
     <bean id="GithubOAuth2Provider" 
class="org.apache.cloudstack.oauth2.github.GithubOAuth2Provider">
         <property name="name" value="github" />
     </bean>
+    <bean id="KeycloakOAuth2Provider" 
class="org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider">
+        <property name="name" value="keycloak" />
+    </bean>
 
     <bean id="OAuth2AuthManager" 
class="org.apache.cloudstack.oauth2.OAuth2AuthManagerImpl">
         <property name="name" value="OAUTH2Auth" />
@@ -45,7 +48,7 @@
           
class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry">
         <property name="orderConfigKey" value="user.oauth2.providers.order" />
         <property name="excludeKey" value="oauth2.plugins.exclude" />
-        <property name="orderConfigDefault" value="google,github" />
+        <property name="orderConfigDefault" value="google,github,keycloak" />
     </bean>
 
     <bean 
class="org.apache.cloudstack.spring.lifecycle.registry.RegistryLifecycle">
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 00000000000..df390f449ca
--- /dev/null
+++ 
b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java
@@ -0,0 +1,225 @@
+//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
+//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 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("[email protected]", "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("Connection refused"));
+
+        provider.verifyCodeAndFetchEmail("code");
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testVerifyUserWithMismatchedEmail() throws IOException {
+        
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
+
+        String testEmail = "[email protected]";
+        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);
+
+        provider.verifyUser("[email protected]", secretCode);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testVerifyUserWithMismatchedClient() throws IOException {
+        
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
+
+        String testEmail = "[email protected]";
+        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);
+
+        provider.verifyUser(testEmail, secretCode);
+    }
+
+    @Test
+    public void testVerifyUserEmail() throws IOException {
+        
when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO);
+
+        String testEmail = "[email protected]";
+        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("User successfully verified", result);
+    }
+
+    @Test
+    public void testGetDescription() {
+        assertEquals("Keycloak OAuth2 Provider Plugin", 
provider.getDescription());
+    }
+}
diff --git a/ui/public/assets/keycloak.svg b/ui/public/assets/keycloak.svg
new file mode 100644
index 00000000000..3e8115efc16
--- /dev/null
+++ b/ui/public/assets/keycloak.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"; xml:space="preserve" id="Layer_1" 
x="0" y="0" version="1.1" viewBox="0 0 512 
512"><style>.st9{fill:#d0d0d0}.st11{fill:#d9d9d9}.st13{fill:#d8d8d8}.st14{fill:#e2e2e2}.st16{fill:#dedede}.st21{fill:#00b8e3}.st22{fill:#33c6e9}.st23{fill:#008aaa}</style><g
 id="g2460" transform="translate(.714 .07)"><path id="path1588" d="M432.9 
149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 
2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58. [...]
\ No newline at end of file
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 2eeffc405e7..14f2fe6597f 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -446,6 +446,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",
@@ -2603,6 +2604,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",
diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js
index e190515855e..2a83b25c002 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 24065f47b1a..acb874dc75b 100644
--- a/ui/src/views/auth/Login.vue
+++ b/ui/src/views/auth/Login.vue
@@ -186,8 +186,8 @@
           :href="getGitHubUrl(from)"
           class="auth-btn github-auth"
           style="height: 38px; width: 185px; padding: 0; margin-bottom: 5px;" >
-          <img src="/assets/github.svg" style="width: 32px; padding: 5px" />
-          <a-typography-text>Sign in with Github</a-typography-text>
+          <img src="/assets/github.svg" alt="GitHub" style="width: 32px; 
padding: 5px" />
+          <a-typography-text>Sign in with GitHub</a-typography-text>
         </a-button>
       </div>
       <div class="social-auth" v-if="googleprovider">
@@ -198,10 +198,22 @@
           :href="getGoogleUrl(from)"
           class="auth-btn google-auth"
           style="height: 38px; width: 185px; padding: 0" >
-          <img src="/assets/google.svg" style="width: 32px; padding: 5px" />
+          <img src="/assets/google.svg" alt="Google" style="width: 32px; 
padding: 5px" />
           <a-typography-text>Sign in with Google</a-typography-text>
         </a-button>
       </div>
+      <div class="social-auth" v-if="keycloakprovider">
+        <a-button
+          @click="handleKeycloakProviderAndDomain"
+          tag="a"
+          color="primary"
+          :href="getKeycloakUrl(from)"
+          class="auth-btn keycloak-auth"
+          style="height: 38px; width: 185px; padding: 0" >
+          <img src="/assets/keycloak.svg" alt="Keycloak" style="width: 32px; 
padding: 5px" />
+          <a-typography-text>Sign in with Keycloak</a-typography-text>
+        </a-button>
+      </div>
     </div>
   </a-form>
 </template>
@@ -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,20 @@ export default {
 
       return `${rootUrl}?${qs.toString()}`
     },
+    getKeycloakUrl (from) {
+      const rootURl = this.keycloakauthorizeurl
+      const options = {
+        redirect_uri: this.keycloakredirecturi,
+        client_id: this.keycloakclientid,
+        response_type: 'code',
+        scope: 'openid email',
+        state: 'cloudstack'
+      }
+
+      const qs = new URLSearchParams(options)
+
+      return `${rootURl}?${qs.toString()}`
+    },
     handleSubmit (e) {
       e.preventDefault()
       if (this.state.loginBtn) return

Reply via email to