Skip to content

Commit 6cc6acd

Browse files
committed
feat: forgerock oauth provider
Abstracted the shared OIDC token exchange into a new AbstractOIDCAuth2PRovider base class.
1 parent 82986f6 commit 6cc6acd

10 files changed

Lines changed: 464 additions & 385 deletions

File tree

plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.apache.cloudstack.context.CallContext;
3131
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
3232
import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse;
33+
import org.apache.cloudstack.oauth2.forgerock.ForgeRockOAuth2Provider;
3334
import org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider;
3435
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
3536
import org.apache.commons.collections.MapUtils;
@@ -59,10 +60,10 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
5960
@Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider", required = true)
6061
private String redirectUri;
6162

62-
@Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for keycloak provider)")
63+
@Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for OIDC providers)")
6364
private String authorizeUrl;
6465

65-
@Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)")
66+
@Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for OIDC providers)")
6667
private String tokenUrl;
6768

6869
@Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP,
@@ -115,12 +116,12 @@ public Map getDetails() {
115116

116117
@Override
117118
public void execute() throws ServerApiException, ConcurrentOperationException, EntityExistsException {
118-
if (StringUtils.equals(KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, getProvider())) {
119+
if (StringUtils.equalsAny(getProvider(), KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, ForgeRockOAuth2Provider.FORGEROCK_PROVIDER)) {
119120
if (StringUtils.isBlank(getAuthorizeUrl())) {
120-
throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter authorizeurl is mandatory for keycloak OAuth Provider");
121+
throw new ServerApiException(ApiErrorCode.BAD_REQUEST, String.format("Parameter authorizeurl is mandatory for %s OAuth Provider", getProvider()));
121122
}
122123
if (StringUtils.isBlank(getTokenUrl())) {
123-
throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter tokenurl is mandatory for keycloak OAuth Provider");
124+
throw new ServerApiException(ApiErrorCode.BAD_REQUEST, String.format("Parameter tokenurl is mandatory for %s OAuth Provider", getProvider()));
124125
}
125126
}
126127

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// Licensed to the Apache Software Foundation (ASF) under one
3+
// or more contributor license agreements. See the NOTICE file
4+
// distributed with this work for additional information
5+
// regarding copyright ownership. The ASF licenses this file
6+
// to you under the Apache License, Version 2.0 (the
7+
// "License"); you may not use this file except in compliance
8+
// with the License. You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing,
13+
// software distributed under the License is distributed on an
14+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
// KIND, either express or implied. See the License for the
16+
// specific language governing permissions and limitations
17+
// under the License.
18+
//
19+
package org.apache.cloudstack.oauth2.forgerock;
20+
21+
import org.apache.cloudstack.oauth2.oidc.AbstractOIDCOAuth2Provider;
22+
23+
public class ForgeRockOAuth2Provider extends AbstractOIDCOAuth2Provider {
24+
25+
public static final String FORGEROCK_PROVIDER = "forgerock";
26+
27+
@Override
28+
public String getName() {
29+
return FORGEROCK_PROVIDER;
30+
}
31+
32+
@Override
33+
public String getDescription() {
34+
return "ForgeRock OAuth2 Provider Plugin";
35+
}
36+
}

plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java

Lines changed: 2 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -18,57 +18,12 @@
1818
//
1919
package org.apache.cloudstack.oauth2.keycloak;
2020

21-
import java.io.IOException;
22-
import java.io.UnsupportedEncodingException;
23-
import java.nio.charset.StandardCharsets;
24-
import java.util.ArrayList;
25-
import java.util.Base64;
26-
import java.util.List;
21+
import org.apache.cloudstack.oauth2.oidc.AbstractOIDCOAuth2Provider;
2722

28-
import javax.inject.Inject;
29-
import javax.ws.rs.core.HttpHeaders;
30-
31-
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
32-
import org.apache.cloudstack.oauth2.dao.OauthProviderDao;
33-
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
34-
import org.apache.commons.lang3.StringUtils;
35-
import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer;
36-
import org.apache.cxf.rs.security.jose.jwt.JwtClaims;
37-
import org.apache.http.NameValuePair;
38-
import org.apache.http.client.entity.UrlEncodedFormEntity;
39-
import org.apache.http.client.methods.CloseableHttpResponse;
40-
import org.apache.http.client.methods.HttpPost;
41-
import org.apache.http.impl.client.CloseableHttpClient;
42-
import org.apache.http.impl.client.HttpClientBuilder;
43-
import org.apache.http.message.BasicNameValuePair;
44-
import org.apache.http.util.EntityUtils;
45-
46-
import com.cloud.exception.CloudAuthenticationException;
47-
import com.cloud.utils.component.AdapterBase;
48-
import com.cloud.utils.exception.CloudRuntimeException;
49-
import com.google.gson.JsonElement;
50-
import com.google.gson.JsonObject;
51-
import com.google.gson.JsonParser;
52-
53-
public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator {
23+
public class KeycloakOAuth2Provider extends AbstractOIDCOAuth2Provider {
5424

5525
public static final String KEYCLOAK_PROVIDER = "keycloak";
5626

57-
protected String idToken = null;
58-
59-
@Inject
60-
OauthProviderDao oauthProviderDao;
61-
62-
private CloseableHttpClient httpClient;
63-
64-
public KeycloakOAuth2Provider() {
65-
this(HttpClientBuilder.create().build());
66-
}
67-
68-
public KeycloakOAuth2Provider(CloseableHttpClient httpClient) {
69-
this.httpClient = httpClient;
70-
}
71-
7227
@Override
7328
public String getName() {
7429
return KEYCLOAK_PROVIDER;
@@ -78,107 +33,4 @@ public String getName() {
7833
public String getDescription() {
7934
return "Keycloak OAuth2 Provider Plugin";
8035
}
81-
82-
@Override
83-
public boolean verifyUser(String email, String secretCode) {
84-
if (StringUtils.isAnyEmpty(email, secretCode)) {
85-
throw new CloudAuthenticationException("Either email or secret code should not be null/empty");
86-
}
87-
88-
OauthProviderVO providerVO = oauthProviderDao.findByProvider(getName());
89-
if (providerVO == null) {
90-
throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified");
91-
}
92-
93-
String verifiedEmail = verifyCodeAndFetchEmail(secretCode);
94-
if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) {
95-
throw new CloudRuntimeException("Unable to verify the email address with the provided secret");
96-
}
97-
clearIdToken();
98-
99-
return true;
100-
}
101-
102-
@Override
103-
public String verifyCodeAndFetchEmail(String secretCode) {
104-
OauthProviderVO provider = oauthProviderDao.findByProvider(getName());
105-
if (provider == null) {
106-
throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified");
107-
}
108-
109-
if (StringUtils.isBlank(idToken)) {
110-
String auth = provider.getClientId() + ":" + provider.getSecretKey();
111-
String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
112-
113-
List<NameValuePair> params = new ArrayList<>();
114-
params.add(new BasicNameValuePair("grant_type", "authorization_code"));
115-
params.add(new BasicNameValuePair("code", secretCode));
116-
params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri()));
117-
118-
HttpPost post = new HttpPost(provider.getTokenUrl());
119-
post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth);
120-
121-
try {
122-
post.setEntity(new UrlEncodedFormEntity(params));
123-
} catch (UnsupportedEncodingException e) {
124-
throw new CloudRuntimeException("Unable to generate URL parameters: " + e.getMessage());
125-
}
126-
127-
try (CloseableHttpResponse response = httpClient.execute(post)) {
128-
String body = EntityUtils.toString(response.getEntity());
129-
130-
if (response.getStatusLine().getStatusCode() != 200) {
131-
throw new CloudRuntimeException("Keycloak error during token generation: " + body);
132-
}
133-
134-
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
135-
JsonElement fetchedIdToken = json.get("id_token");
136-
if (fetchedIdToken == null) {
137-
throw new CloudRuntimeException("No id_token found in token");
138-
}
139-
String idTokenAsString = fetchedIdToken.getAsString();
140-
validateIdToken(idTokenAsString , provider);
141-
142-
this.idToken = idTokenAsString ;
143-
} catch (IOException e) {
144-
throw new CloudRuntimeException("Unable to connect to Keycloak server", e);
145-
}
146-
}
147-
148-
return obtainEmail(idToken, provider);
149-
}
150-
151-
@Override
152-
public String getUserEmailAddress() throws CloudRuntimeException {
153-
return null;
154-
}
155-
156-
private void validateIdToken(String idTokenStr, OauthProviderVO provider) {
157-
JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr);
158-
JwtClaims claims = jwtConsumer.getJwtToken().getClaims();
159-
160-
if (!claims.getAudiences().contains(provider.getClientId())) {
161-
throw new CloudAuthenticationException("Audience mismatch");
162-
}
163-
}
164-
165-
private String obtainEmail(String idTokenStr, OauthProviderVO provider) {
166-
JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr);
167-
JwtClaims claims = jwtConsumer.getJwtToken().getClaims();
168-
169-
if (!claims.getAudiences().contains(provider.getClientId())) {
170-
throw new CloudAuthenticationException("Audience mismatch");
171-
}
172-
173-
return (String) claims.getClaim("email");
174-
}
175-
176-
protected void clearIdToken() {
177-
idToken = null;
178-
}
179-
180-
public void setHttpClient(CloseableHttpClient httpClient) {
181-
this.httpClient = httpClient;
182-
}
183-
18436
}

0 commit comments

Comments
 (0)