Skip to content

Commit a8db372

Browse files
oauth2: address generic OIDC review findings
- require a verified email (email_verified) in the id_token before using it as the account identity, so an unverified address cannot claim a user - key the single-use verified-email cache by domain as well, so a code verified for one domain cannot be consumed for another - store the provider type lowercased so list, update and the UI agree with the case-insensitive dispatch lookup - correct the since metadata on the new type/issuerUrl parameters and response fields from 4.24.0 to 24.0.0
1 parent 229bf14 commit a8db372

7 files changed

Lines changed: 58 additions & 10 deletions

File tree

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ private OauthProviderVO saveOauthProvider(String provider, String description, S
315315
oauthProviderVO.setDomainId(domainId);
316316
oauthProviderVO.setAuthorizeUrl(authorizeUrl);
317317
oauthProviderVO.setTokenUrl(tokenUrl);
318-
oauthProviderVO.setType(type);
318+
oauthProviderVO.setType(StringUtils.lowerCase(type));
319319
oauthProviderVO.setIssuerUrl(issuerUrl);
320320
oauthProviderVO.setEnabled(true);
321321

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,12 @@ public class RegisterOAuthProviderCmd extends BaseCmd {
7979

8080
@Parameter(name = ApiConstants.TYPE, type = CommandType.STRING,
8181
description = "Type of the provider implementation serving this registration, for example oidc for any OpenID Connect compliant provider. "
82-
+ "When set, the name in provider is a label chosen by the administrator rather than a built in provider name.", since = "4.24.0")
82+
+ "When set, the name in provider is a label chosen by the administrator rather than a built in provider name.", since = "24.0.0")
8383
private String type;
8484

8585
@Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING,
8686
description = "Issuer URL of the OpenID Connect provider, required for type oidc. The token endpoint and the keys that sign "
87-
+ "its tokens are read from the issuer's discovery document", since = "4.24.0")
87+
+ "its tokens are read from the issuer's discovery document", since = "24.0.0")
8888
private String issuerUrl;
8989

9090
@Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public final class UpdateOAuthProviderCmd extends BaseCmd {
6969
private String tokenUrl;
7070

7171
@Parameter(name = ApiConstants.ISSUER_URL, type = CommandType.STRING,
72-
description = "Issuer URL of the OpenID Connect provider, used to read its discovery document", since = "4.24.0")
72+
description = "Issuer URL of the OpenID Connect provider, used to read its discovery document", since = "24.0.0")
7373
private String issuerUrl;
7474

7575
@Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value")

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ public class OauthProviderResponse extends BaseResponse {
7171
private String domainPath;
7272

7373
@SerializedName(ApiConstants.TYPE)
74-
@Param(description = "Type of the provider, for example oidc for a generic OpenID Connect provider. Empty for the built in providers", since = "4.24.0")
74+
@Param(description = "Type of the provider, for example oidc for a generic OpenID Connect provider. Empty for the built in providers", since = "24.0.0")
7575
private String type;
7676

7777
@SerializedName(ApiConstants.ISSUER_URL)
78-
@Param(description = "Issuer URL of the OpenID Connect provider, used for discovery", since = "4.24.0")
78+
@Param(description = "Issuer URL of the OpenID Connect provider, used for discovery", since = "24.0.0")
7979
private String issuerUrl;
8080

8181
@SerializedName(ApiConstants.AUTHORIZE_URL)

‎plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2Provider.java‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public boolean verifyUser(String email, String secretCode, Long domainId, String
153153
throw new CloudAuthenticationException("Either email or secret code should not be null/empty");
154154
}
155155

156-
String verifiedEmail = verifiedEmailCache.asMap().remove(verifiedEmailKey(providerName, secretCode));
156+
String verifiedEmail = verifiedEmailCache.asMap().remove(verifiedEmailKey(providerName, secretCode, domainId));
157157
if (verifiedEmail == null) {
158158
verifiedEmail = resolveEmail(secretCode, domainId, providerName);
159159
}
@@ -167,7 +167,7 @@ public boolean verifyUser(String email, String secretCode, Long domainId, String
167167
@Override
168168
public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId, String providerName) {
169169
String email = resolveEmail(secretCode, domainId, providerName);
170-
verifiedEmailCache.put(verifiedEmailKey(providerName, secretCode), email);
170+
verifiedEmailCache.put(verifiedEmailKey(providerName, secretCode, domainId), email);
171171
return email;
172172
}
173173

@@ -184,8 +184,8 @@ public String getUserEmailAddress() throws CloudRuntimeException {
184184
return null;
185185
}
186186

187-
private String verifiedEmailKey(String providerName, String secretCode) {
188-
return DigestUtils.sha256Hex(providerName + ":" + secretCode);
187+
private String verifiedEmailKey(String providerName, String secretCode, Long domainId) {
188+
return DigestUtils.sha256Hex(providerName + ":" + domainId + ":" + secretCode);
189189
}
190190

191191
protected OauthProviderVO findRegistration(String providerName, Long domainId) {
@@ -279,9 +279,20 @@ protected String validateAndExtractEmail(String idToken, OauthProviderVO provide
279279
if (StringUtils.isBlank(email)) {
280280
throw new CloudAuthenticationException("The id_token carries no email claim");
281281
}
282+
if (!isEmailVerified(claims)) {
283+
throw new CloudAuthenticationException("The identity provider has not verified the email address in the id_token");
284+
}
282285
return email;
283286
}
284287

288+
private boolean isEmailVerified(JwtClaims claims) {
289+
Object verified = claims.getClaim("email_verified");
290+
if (verified instanceof Boolean) {
291+
return (Boolean) verified;
292+
}
293+
return verified instanceof String && Boolean.parseBoolean((String) verified);
294+
}
295+
285296
protected void verifySignature(JwsJwtCompactConsumer consumer, OIDCMetadata metadata, OauthProviderVO provider) {
286297
if (StringUtils.isBlank(metadata.getJwksUri())) {
287298
throw new CloudAuthenticationException(String.format(

‎plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImplTest.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,23 @@ public void testRegisterRejectsBuiltinNameWithType() {
137137
}
138138
}
139139

140+
@Test
141+
public void testRegisterNormalizesTypeCasing() {
142+
OAuth2AuthManagerImpl.userOAuth2AuthenticationProvidersMap.put("oidc", Mockito.mock(org.apache.cloudstack.auth.UserOAuth2Authenticator.class));
143+
when(_authManager.isOAuthPluginEnabled(Mockito.nullable(Long.class))).thenReturn(true);
144+
RegisterOAuthProviderCmd cmd = Mockito.mock(RegisterOAuthProviderCmd.class);
145+
when(cmd.getProvider()).thenReturn("corp-idp");
146+
when(cmd.getType()).thenReturn("OIDC");
147+
when(cmd.getDomainId()).thenReturn(null);
148+
when(_authManager._oauthProviderDao.findByProviderAndDomain(Mockito.anyString(), Mockito.isNull())).thenReturn(null);
149+
org.mockito.ArgumentCaptor<OauthProviderVO> captor = org.mockito.ArgumentCaptor.forClass(OauthProviderVO.class);
150+
when(_authManager._oauthProviderDao.persist(captor.capture())).thenReturn(new OauthProviderVO());
151+
152+
_authManager.registerOauthProvider(cmd);
153+
154+
assertEquals("oidc", captor.getValue().getType());
155+
}
156+
140157
@Test
141158
public void testUpdateOauthProvider() {
142159
Long id = 1L;

‎plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/oidc/GenericOIDCOAuth2ProviderTest.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ private String signedIdToken(KeyPair keys, String keyId, String email) {
200200
claims.setSubject("12345");
201201
claims.setExpiryTime(System.currentTimeMillis() / 1000L + 3600);
202202
claims.setClaim("email", email);
203+
claims.setClaim("email_verified", true);
203204
JwsHeaders headers = new JwsHeaders(SignatureAlgorithm.RS256);
204205
headers.setKeyId(keyId);
205206
return new JwsJwtCompactProducer(headers, claims)
@@ -244,6 +245,25 @@ public void testSigningKeysAreCachedAcrossVerifications() throws Exception {
244245
verify(provider, times(1)).httpGet(eq(ISSUER + "/jwks"), anyString());
245246
}
246247

248+
@Test(expected = CloudAuthenticationException.class)
249+
public void testUnverifiedEmailIsRejected() throws Exception {
250+
KeyPair keys = rsaKeyPair();
251+
publishKey(keys, "key-1");
252+
JwtClaims claims = new JwtClaims();
253+
claims.setIssuer(ISSUER);
254+
claims.setAudiences(Collections.singletonList(CLIENT_ID));
255+
claims.setSubject("12345");
256+
claims.setExpiryTime(System.currentTimeMillis() / 1000L + 3600);
257+
claims.setClaim("email", "user@example.com");
258+
claims.setClaim("email_verified", false);
259+
JwsHeaders headers = new JwsHeaders(SignatureAlgorithm.RS256);
260+
headers.setKeyId("key-1");
261+
String token = new JwsJwtCompactProducer(headers, claims)
262+
.signWith(JwsUtils.getPrivateKeySignatureProvider(keys.getPrivate(), SignatureAlgorithm.RS256));
263+
264+
provider.validateAndExtractEmail(token, registration, metadata());
265+
}
266+
247267
@Test(expected = CloudAuthenticationException.class)
248268
public void testTokenWithAlteredClaimsIsRejected() throws Exception {
249269
KeyPair keys = rsaKeyPair();

0 commit comments

Comments
 (0)