From 4408f2cda5f37e38d215cc2954d633be8d28351e Mon Sep 17 00:00:00 2001 From: Stijn Simons Date: Thu, 25 Jun 2026 12:51:00 +0000 Subject: [PATCH] feat: forgerock oauth provider Abstracted the shared OIDC token exchange into a new AbstractOIDCAuth2PRovider base class. --- .../api/command/RegisterOAuthProviderCmd.java | 11 +- .../forgerock/ForgeRockOAuth2Provider.java | 36 ++++ .../keycloak/KeycloakOAuth2Provider.java | 162 +-------------- .../oidc/AbstractOIDCOAuth2Provider.java | 187 ++++++++++++++++++ .../oauth2/spring-oauth2-context.xml | 5 +- .../AbstractOIDCOAuth2ProviderTest.java} | 183 +++++++---------- ui/public/assets/forgerock.svg | 9 + ui/src/config/section/config.js | 2 +- ui/src/views/auth/Login.vue | 69 ++++++- 9 files changed, 384 insertions(+), 280 deletions(-) create mode 100644 plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/forgerock/ForgeRockOAuth2Provider.java create mode 100644 plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2Provider.java rename plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/{keycloak/KeycloakOAuth2ProviderTest.java => oidc/AbstractOIDCOAuth2ProviderTest.java} (51%) create mode 100644 ui/public/assets/forgerock.svg 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 79274ba904b1..d7ae3547e702 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 @@ -31,6 +31,7 @@ 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.forgerock.ForgeRockOAuth2Provider; import org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.collections.MapUtils; @@ -70,10 +71,10 @@ public class RegisterOAuthProviderCmd extends BaseCmd { description = "Domain path for domain-specific OAuth provider. Ignored when Domain ID is passed.", since = "4.23.0") private String domainPath; - @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for keycloak provider)") + @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for OIDC providers)") private String authorizeUrl; - @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)") + @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for OIDC providers)") private String tokenUrl; @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, @@ -134,12 +135,12 @@ public Map getDetails() { @Override public void execute() throws ServerApiException, ConcurrentOperationException, EntityExistsException { - if (StringUtils.equals(KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, getProvider())) { + if (StringUtils.equalsAny(getProvider(), KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, ForgeRockOAuth2Provider.FORGEROCK_PROVIDER)) { if (StringUtils.isBlank(getAuthorizeUrl())) { - throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter authorizeurl is mandatory for keycloak OAuth Provider"); + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, String.format("Parameter authorizeurl is mandatory for %s OAuth Provider", getProvider())); } if (StringUtils.isBlank(getTokenUrl())) { - throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter tokenurl is mandatory for keycloak OAuth Provider"); + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, String.format("Parameter tokenurl is mandatory for %s OAuth Provider", getProvider())); } } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/forgerock/ForgeRockOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/forgerock/ForgeRockOAuth2Provider.java new file mode 100644 index 000000000000..6d8be8edd55e --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/forgerock/ForgeRockOAuth2Provider.java @@ -0,0 +1,36 @@ +// +// 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.forgerock; + +import org.apache.cloudstack.oauth2.oidc.AbstractOIDCOAuth2Provider; + +public class ForgeRockOAuth2Provider extends AbstractOIDCOAuth2Provider { + + public static final String FORGEROCK_PROVIDER = "forgerock"; + + @Override + public String getName() { + return FORGEROCK_PROVIDER; + } + + @Override + public String getDescription() { + return "ForgeRock OAuth2 Provider Plugin"; + } +} diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java index 2a625c6f7570..85c2343a7e44 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java @@ -18,57 +18,12 @@ // 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 org.apache.cloudstack.oauth2.oidc.AbstractOIDCOAuth2Provider; -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 class KeycloakOAuth2Provider extends AbstractOIDCOAuth2Provider { 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; @@ -78,117 +33,4 @@ public String getName() { public String getDescription() { return "Keycloak OAuth2 Provider Plugin"; } - - @Override - public boolean verifyUser(String email, String secretCode) { - return verifyUser(email, secretCode, null); - } - - @Override - public boolean verifyUser(String email, String secretCode, Long domainId) { - if (StringUtils.isAnyEmpty(email, secretCode)) { - throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); - } - - OauthProviderVO providerVO = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId); - if (providerVO == null) { - throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified"); - } - - String verifiedEmail = verifySecretCodeAndFetchEmail(secretCode, domainId); - 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 verifySecretCodeAndFetchEmail(String secretCode) { - return verifySecretCodeAndFetchEmail(secretCode, null); - } - - @Override - public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) { - OauthProviderVO provider = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId); - 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 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/oidc/AbstractOIDCOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2Provider.java new file mode 100644 index 000000000000..13f4d7661a17 --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2Provider.java @@ -0,0 +1,187 @@ +// +// 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.oidc; + +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 abstract class AbstractOIDCOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + + protected String idToken = null; + + @Inject + OauthProviderDao oauthProviderDao; + + private CloseableHttpClient httpClient; + + protected AbstractOIDCOAuth2Provider() { + this(HttpClientBuilder.create().build()); + } + + protected AbstractOIDCOAuth2Provider(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public abstract String getName(); + + @Override + public abstract String getDescription(); + + @Override + public boolean verifyUser(String email, String secretCode) { + return verifyUser(email, secretCode, null); + } + + @Override + public boolean verifyUser(String email, String secretCode, Long domainId) { + if (StringUtils.isAnyEmpty(email, secretCode)) { + throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); + } + + OauthProviderVO providerVO = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId); + if (providerVO == null) { + throw new CloudAuthenticationException(String.format("%s provider is not registered, so user cannot be verified", getName())); + } + + String verifiedEmail = verifySecretCodeAndFetchEmail(secretCode, domainId); + 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 verifySecretCodeAndFetchEmail(String secretCode) { + return verifySecretCodeAndFetchEmail(secretCode, null); + } + + @Override + public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId) { + OauthProviderVO provider = oauthProviderDao.findByProviderAndDomainWithGlobalFallback(getName(), domainId); + if (provider == null) { + throw new CloudAuthenticationException(String.format("%s provider is not registered, so user cannot be verified", getName())); + } + + if (StringUtils.isBlank(idToken)) { + String auth = provider.getClientId() + ":" + provider.getSecretKey(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "authorization_code")); + params.add(new BasicNameValuePair("code", secretCode)); + params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); + + 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(String.format("%s error during token generation: %s", getName(), 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(String.format("Unable to connect to %s server", getName()), 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/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 06fe60f4c25e..edea963f525b 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 @@ -38,6 +38,9 @@ + + + @@ -48,7 +51,7 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> - + diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2ProviderTest.java similarity index 51% rename from plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java rename to plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2ProviderTest.java index cf2e48772173..b43397daf354 100644 --- 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/oidc/AbstractOIDCOAuth2ProviderTest.java @@ -14,9 +14,8 @@ //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; +package org.apache.cloudstack.oauth2.oidc; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -34,6 +33,7 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -42,7 +42,9 @@ import com.cloud.exception.CloudAuthenticationException; import com.cloud.utils.exception.CloudRuntimeException; -public class KeycloakOAuth2ProviderTest { +public class AbstractOIDCOAuth2ProviderTest { + + private static final String PROVIDER_NAME = "oidc-test"; @Mock private OauthProviderDao oauthProviderDao; @@ -50,15 +52,33 @@ public class KeycloakOAuth2ProviderTest { @Mock private CloseableHttpClient httpClient; - private KeycloakOAuth2Provider provider; + private TestOIDCProvider provider; private OauthProviderVO mockProviderVO; + private static class TestOIDCProvider extends AbstractOIDCOAuth2Provider { + TestOIDCProvider(CloseableHttpClient httpClient) { + super(httpClient); + } + + @Override + public String getName() { + return PROVIDER_NAME; + } + + @Override + public String getDescription() { + return "Test OIDC Provider Plugin"; + } + } + + private AutoCloseable closeable; + @Before public void setUp() { - MockitoAnnotations.initMocks(this); + closeable = MockitoAnnotations.openMocks(this); - provider = new KeycloakOAuth2Provider(httpClient); + provider = new TestOIDCProvider(httpClient); provider.oauthProviderDao = oauthProviderDao; mockProviderVO = new OauthProviderVO(); @@ -68,9 +88,36 @@ public void setUp() { mockProviderVO.setRedirectUri("http://localhost/redirect"); } - @Test - public void testGetName() { - assertEquals("keycloak", provider.getName()); + @After + public void tearDown() throws Exception { + closeable.close(); + } + + private CloseableHttpResponse mockTokenResponse(int statusCode, String body) throws IOException { + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(statusCode); + when(response.getStatusLine()).thenReturn(statusLine); + when(entity.getContent()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + return response; + } + + private String idTokenWith(String audience, String email) { + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"" + audience + "\"]," + + "\"email\":\"" + email + "\"," + + "\"iss\":\"http://oidc\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".not-checked-signature"; } @Test(expected = CloudAuthenticationException.class) @@ -80,24 +127,14 @@ public void testVerifyUserEmptyParams() { @Test(expected = CloudAuthenticationException.class) public void testVerifyUserProviderNotFound() { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(null); + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(null); provider.verifyUser("test@example.com", "code123"); } @Test(expected = CloudRuntimeException.class) public void testVerifyCodeAndFetchEmailHttpError() throws IOException { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).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(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(mockProviderVO); + CloseableHttpResponse response = mockTokenResponse(400, "error"); when(httpClient.execute(any(HttpPost.class))).thenReturn(response); provider.verifySecretCodeAndFetchEmail("invalid-code"); @@ -105,7 +142,7 @@ public void testVerifyCodeAndFetchEmailHttpError() throws IOException { @Test(expected = CloudRuntimeException.class) public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO); + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(mockProviderVO); when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused")); provider.verifySecretCodeAndFetchEmail("code"); @@ -113,113 +150,37 @@ public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { @Test(expected = CloudRuntimeException.class) public void testVerifyUserWithMismatchedEmail() throws IOException { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO); - - String testEmail = "anotheruser@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"test-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(mockProviderVO); + String jsonResponseBody = "{\"id_token\":\"" + idTokenWith("test-client", "anotheruser@example.com") + "\", \"access_token\":\"acc-123\"}"; + CloseableHttpResponse response = mockTokenResponse(200, jsonResponseBody); when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - provider.verifyUser("user@example.com", secretCode); + provider.verifyUser("user@example.com", "valid-auth-code"); } @Test(expected = CloudRuntimeException.class) public void testVerifyUserWithMismatchedClient() throws IOException { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO); - - String testEmail = "anotheruser@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"anothertest-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(mockProviderVO); + String jsonResponseBody = "{\"id_token\":\"" + idTokenWith("anothertest-client", "anotheruser@example.com") + "\", \"access_token\":\"acc-123\"}"; + CloseableHttpResponse response = mockTokenResponse(200, jsonResponseBody); when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - provider.verifyUser(testEmail, secretCode); + provider.verifyUser("anotheruser@example.com", "valid-auth-code"); } @Test public void testVerifyUserEmail() throws IOException { - when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("keycloak", null)).thenReturn(mockProviderVO); + when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(PROVIDER_NAME, null)).thenReturn(mockProviderVO); String testEmail = "user@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"test-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); - + String jsonResponseBody = "{\"id_token\":\"" + idTokenWith("test-client", testEmail) + "\", \"access_token\":\"acc-123\"}"; + CloseableHttpResponse response = mockTokenResponse(200, jsonResponseBody); when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - boolean result = provider.verifyUser(testEmail, secretCode); + boolean result = provider.verifyUser(testEmail, "valid-auth-code"); assertTrue("User successfully verified", result); } - - @Test - public void testGetDescription() { - assertEquals("Keycloak OAuth2 Provider Plugin", provider.getDescription()); - } } diff --git a/ui/public/assets/forgerock.svg b/ui/public/assets/forgerock.svg new file mode 100644 index 000000000000..54b9ed4ad2cd --- /dev/null +++ b/ui/public/assets/forgerock.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js index a2c12ce236ac..692a2113d407 100644 --- a/ui/src/config/section/config.js +++ b/ui/src/config/section/config.js @@ -93,7 +93,7 @@ export default { ], mapping: { provider: { - options: ['google', 'github', 'keycloak'] + options: ['google', 'github', 'keycloak', 'forgerock'] } } }, diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index 9dcc8b4f34c2..60f3f7cd838e 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -174,11 +174,11 @@
Enter your domain to see domain-specific providers
-
+
+
@@ -277,23 +289,31 @@ export default { googleprovider: false, githubprovider: false, keycloakprovider: false, + forgerockprovider: false, googleredirecturi: '', githubredirecturi: '', keycloakredirecturi: '', + forgerockredirecturi: '', googleclientid: '', githubclientid: '', keycloakclientid: '', keycloakauthorizeurl: '', + forgerockclientid: '', + forgerockauthorizeurl: '', oauthGoogleProvider: false, oauthGithubProvider: false, oauthKeycloakProvider: false, + oauthForgerockProvider: false, oauthGoogleClientId: '', oauthGithubClientId: '', oauthKeycloakClientId: '', + oauthForgerockClientId: '', oauthGoogleRedirectUri: '', oauthGithubRedirectUri: '', oauthKeycloakRedirectUri: '', oauthKeycloakAuthorizeUrl: '', + oauthForgerockRedirectUri: '', + oauthForgerockAuthorizeUrl: '', oauthLoading: false, oauthDomainQueried: false, loginType: 0, @@ -411,23 +431,34 @@ export default { this.keycloakredirecturi = item.redirecturi this.keycloakauthorizeurl = item.authorizeurl } + if (item.provider === 'forgerock') { + this.forgerockprovider = item.enabled + this.forgerockclientid = item.clientid + this.forgerockredirecturi = item.redirecturi + this.forgerockauthorizeurl = item.authorizeurl + } }) const totalCount = response.listoauthproviderresponse.count || 0 this.socialLogin = totalCount > 0 this.oauthGithubProvider = this.githubprovider this.oauthGoogleProvider = this.googleprovider this.oauthKeycloakProvider = this.keycloakprovider + this.oauthForgerockProvider = this.forgerockprovider this.oauthGithubClientId = this.githubclientid this.oauthGoogleClientId = this.googleclientid this.oauthKeycloakClientId = this.keycloakclientid + this.oauthForgerockClientId = this.forgerockclientid this.oauthGithubRedirectUri = this.githubredirecturi this.oauthGoogleRedirectUri = this.googleredirecturi this.oauthKeycloakRedirectUri = this.keycloakredirecturi this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + this.oauthForgerockRedirectUri = this.forgerockredirecturi + this.oauthForgerockAuthorizeUrl = this.forgerockauthorizeurl } else { this.oauthGithubProvider = false this.oauthGoogleProvider = false this.oauthKeycloakProvider = false + this.oauthForgerockProvider = false oauthproviders.forEach(item => { if (item.provider === 'google') { this.oauthGoogleProvider = item.enabled @@ -445,6 +476,12 @@ export default { this.oauthKeycloakRedirectUri = item.redirecturi this.oauthKeycloakAuthorizeUrl = item.authorizeurl } + if (item.provider === 'forgerock') { + this.oauthForgerockProvider = item.enabled + this.oauthForgerockClientId = item.clientid + this.oauthForgerockRedirectUri = item.redirecturi + this.oauthForgerockAuthorizeUrl = item.authorizeurl + } }) } } @@ -469,13 +506,17 @@ export default { this.oauthGithubProvider = this.githubprovider this.oauthGoogleProvider = this.googleprovider this.oauthKeycloakProvider = this.keycloakprovider + this.oauthForgerockProvider = this.forgerockprovider this.oauthGithubClientId = this.githubclientid this.oauthGoogleClientId = this.googleclientid this.oauthKeycloakClientId = this.keycloakclientid + this.oauthForgerockClientId = this.forgerockclientid this.oauthGithubRedirectUri = this.githubredirecturi this.oauthGoogleRedirectUri = this.googleredirecturi this.oauthKeycloakRedirectUri = this.keycloakredirecturi this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + this.oauthForgerockRedirectUri = this.forgerockredirecturi + this.oauthForgerockAuthorizeUrl = this.forgerockauthorizeurl } this.setRules() }, @@ -489,13 +530,17 @@ export default { this.oauthGithubProvider = this.githubprovider this.oauthGoogleProvider = this.googleprovider this.oauthKeycloakProvider = this.keycloakprovider + this.oauthForgerockProvider = this.forgerockprovider this.oauthGithubClientId = this.githubclientid this.oauthGoogleClientId = this.googleclientid this.oauthKeycloakClientId = this.keycloakclientid + this.oauthForgerockClientId = this.forgerockclientid this.oauthGithubRedirectUri = this.githubredirecturi this.oauthGoogleRedirectUri = this.googleredirecturi this.oauthKeycloakRedirectUri = this.keycloakredirecturi this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + this.oauthForgerockRedirectUri = this.forgerockredirecturi + this.oauthForgerockAuthorizeUrl = this.forgerockauthorizeurl } }, handleGithubProviderAndDomain () { @@ -510,6 +555,10 @@ export default { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'keycloak') }, + handleForgerockProviderAndDomain () { + this.handleDomain() + this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'forgerock') + }, handleDomain () { const values = toRaw(this.form) const domain = this.customActiveKey === 'oauth' ? values.oauthDomain : values.domain @@ -577,6 +626,22 @@ export default { return `${rootURl}?${qs.toString()}` }, + getForgerockUrl (from) { + const rootURl = this.customActiveKey === 'oauth' ? this.oauthForgerockAuthorizeUrl : this.forgerockauthorizeurl + const redirectUri = this.customActiveKey === 'oauth' ? this.oauthForgerockRedirectUri : this.forgerockredirecturi + const clientId = this.customActiveKey === 'oauth' ? this.oauthForgerockClientId : this.forgerockclientid + const options = { + redirect_uri: redirectUri, + client_id: clientId, + 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