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