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 8eb4493d76d8..74963d69e329 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 @@ -30,6 +30,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; @@ -59,10 +60,10 @@ 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)") + @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, @@ -115,12 +116,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 3f537b1984d0..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,107 +33,4 @@ public String getName() { 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 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..77b26de6c48c --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2Provider.java @@ -0,0 +1,177 @@ +// +// 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) { + 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(String.format("%s provider is not registered, so user cannot be verified", getName())); + } + + 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(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/keycloak/KeycloakOAuth2ProviderTest.java deleted file mode 100644 index df390f449cab..000000000000 --- a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java +++ /dev/null @@ -1,225 +0,0 @@ -//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("test@example.com", "code123"); - } - - @Test(expected = CloudRuntimeException.class) - public void testVerifyCodeAndFetchEmailHttpError() throws IOException { - when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - - when(statusLine.getStatusCode()).thenReturn(400); - when(response.getStatusLine()).thenReturn(statusLine); - - HttpEntity entity = mock(HttpEntity.class); - when(entity.getContent()).thenReturn(new ByteArrayInputStream("error".getBytes())); - when(response.getEntity()).thenReturn(entity); - - when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - - provider.verifyCodeAndFetchEmail("invalid-code"); - } - - @Test(expected = CloudRuntimeException.class) - public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { - when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); - when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused")); - - provider.verifyCodeAndFetchEmail("code"); - } - - @Test(expected = CloudRuntimeException.class) - public void testVerifyUserWithMismatchedEmail() throws IOException { - when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); - - String testEmail = "anotheruser@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"test-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); - - when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - - provider.verifyUser("user@example.com", secretCode); - } - - @Test(expected = CloudRuntimeException.class) - public void testVerifyUserWithMismatchedClient() throws IOException { - when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); - - String testEmail = "anotheruser@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"anothertest-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); - - when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - - provider.verifyUser(testEmail, secretCode); - } - - @Test - public void testVerifyUserEmail() throws IOException { - when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); - - String testEmail = "user@example.com"; - String secretCode = "valid-auth-code"; - - String header = "{\"alg\":\"none\"}"; - String payload = "{" + - "\"aud\":[\"test-client\"]," + - "\"email\":\"" + testEmail + "\"," + - "\"iss\":\"http://keycloak\"," + - "\"sub\":\"12345\"" + - "}"; - - String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); - String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); - String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; - - CloseableHttpResponse response = mock(CloseableHttpResponse.class); - StatusLine statusLine = mock(StatusLine.class); - HttpEntity entity = mock(HttpEntity.class); - - when(statusLine.getStatusCode()).thenReturn(200); - when(response.getStatusLine()).thenReturn(statusLine); - - String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; - when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); - when(response.getEntity()).thenReturn(entity); - - when(httpClient.execute(any(HttpPost.class))).thenReturn(response); - - boolean result = provider.verifyUser(testEmail, secretCode); - - assertTrue("User successfully verified", result); - } - - @Test - public void testGetDescription() { - assertEquals("Keycloak OAuth2 Provider Plugin", provider.getDescription()); - } -} diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2ProviderTest.java new file mode 100644 index 000000000000..be2d63e98d4a --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/AbstractOIDCOAuth2ProviderTest.java @@ -0,0 +1,186 @@ +//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.oidc; + +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.After; +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 AbstractOIDCOAuth2ProviderTest { + + private static final String PROVIDER_NAME = "oidc-test"; + + @Mock + private OauthProviderDao oauthProviderDao; + + @Mock + private CloseableHttpClient httpClient; + + 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() { + closeable = MockitoAnnotations.openMocks(this); + + provider = new TestOIDCProvider(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"); + } + + @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) + public void testVerifyUserEmptyParams() { + provider.verifyUser("", ""); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserProviderNotFound() { + when(oauthProviderDao.findByProvider(PROVIDER_NAME)).thenReturn(null); + provider.verifyUser("test@example.com", "code123"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailHttpError() throws IOException { + when(oauthProviderDao.findByProvider(PROVIDER_NAME)).thenReturn(mockProviderVO); + CloseableHttpResponse response = mockTokenResponse(400, "error"); + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + provider.verifyCodeAndFetchEmail("invalid-code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { + when(oauthProviderDao.findByProvider(PROVIDER_NAME)).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(PROVIDER_NAME)).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", "valid-auth-code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedClient() throws IOException { + when(oauthProviderDao.findByProvider(PROVIDER_NAME)).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("anotheruser@example.com", "valid-auth-code"); + } + + @Test + public void testVerifyUserEmail() throws IOException { + when(oauthProviderDao.findByProvider(PROVIDER_NAME)).thenReturn(mockProviderVO); + + String testEmail = "user@example.com"; + 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, "valid-auth-code"); + + assertTrue("User successfully verified", result); + } +} 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 2a83b25c002f..548cd51d7bd2 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 acb874dc75be..48381ad2e211 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -214,6 +214,18 @@ Sign in with Keycloak + @@ -244,13 +256,17 @@ export default { googleprovider: false, githubprovider: false, keycloakprovider: false, + forgerockprovider: false, googleredirecturi: '', githubredirecturi: '', keycloakredirecturi: '', + forgerockredirecturi: '', googleclientid: '', githubclientid: '', keycloakclientid: '', keycloakauthorizeurl: '', + forgerockclientid: '', + forgerockauthorizeurl: '', loginType: 0, state: { time: 60, @@ -347,8 +363,14 @@ 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 + } }) - this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider + this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider || this.forgerockprovider } }) postAPI('forgotPassword', {}).then(response => { @@ -388,6 +410,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) if (!values.domain) { @@ -441,6 +467,20 @@ export default { return `${rootURl}?${qs.toString()}` }, + getForgerockUrl (from) { + const rootURl = this.forgerockauthorizeurl + const options = { + redirect_uri: this.forgerockredirecturi, + client_id: this.forgerockclientid, + 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