Skip to content

Commit 9cbe466

Browse files
committed
oauth2: harden the generic OIDC provider from the second review
Key the one-time email cache on the resolved registration instead of the request domain. verifyOAuthCodeAndGetUser can resolve the code with no domain while the login that follows lands on a concrete domain; the old key mixed the two, so the single use authorization code was redeemed a second time and rejected. The registration is resolved up front and its uuid keys the cache, which also keeps a code scoped to its own registration. Make the verified-email requirement a domain setting (oauth2.oidc.require.verified.email, default true). An explicit email_verified=false is always rejected; an absent claim is rejected only when the setting is on, so a trusted provider that does not send it, such as Microsoft Entra ID, can be allowed per domain. Stop returning the provider's raw response body on a failed discovery or JWKS fetch; it now goes to the log and the caller gets a generic message, matching the token exchange.
1 parent 7fa52dd commit 9cbe466

4 files changed

Lines changed: 156 additions & 26 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ public interface OAuth2AuthManager extends PluggableAPIAuthenticator, PluggableS
4141
"List of OAuth plugins", true);
4242
public static final ConfigKey<String> OAuth2PluginsExclude = new ConfigKey<String>("Advanced", String.class, "oauth2.plugins.exclude", "",
4343
"List of OAuth plugins which are excluded", true);
44+
public static final ConfigKey<Boolean> OIDCRequireVerifiedEmail = new ConfigKey<Boolean>("Advanced", Boolean.class, "oauth2.oidc.require.verified.email", "true",
45+
"For the generic OIDC provider, require the id_token to assert a verified email (email_verified=true) before the address is used to identify a CloudStack account. " +
46+
"When set to false for a trusted identity provider that does not send the claim (such as Microsoft Entra ID), an id_token without the claim is accepted, " +
47+
"but one that explicitly marks the email as not verified is always rejected. This can be configured at domain level.", true, ConfigKey.Scope.Domain);
4448

4549
/**
4650
* Lists user OAuth2 provider plugins

‎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
@@ -395,6 +395,6 @@ public String getConfigComponentName() {
395395

396396
@Override
397397
public ConfigKey<?>[] getConfigKeys() {
398-
return new ConfigKey<?>[] {OAuth2IsPluginEnabled, OAuth2Plugins, OAuth2PluginsExclude};
398+
return new ConfigKey<?>[] {OAuth2IsPluginEnabled, OAuth2Plugins, OAuth2PluginsExclude, OIDCRequireVerifiedEmail};
399399
}
400400
}

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

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import javax.ws.rs.core.HttpHeaders;
3131

3232
import org.apache.cloudstack.auth.UserOAuth2Authenticator;
33+
import org.apache.cloudstack.framework.config.ConfigKey;
34+
import org.apache.cloudstack.oauth2.OAuth2AuthManager;
3335
import org.apache.cloudstack.oauth2.dao.OauthProviderDao;
3436
import org.apache.cloudstack.oauth2.vo.OauthProviderVO;
3537
import org.apache.commons.codec.digest.DigestUtils;
@@ -153,9 +155,10 @@ public boolean verifyUser(String email, String secretCode, Long domainId, String
153155
throw new CloudAuthenticationException("Either email or secret code should not be null/empty");
154156
}
155157

156-
String verifiedEmail = verifiedEmailCache.asMap().remove(verifiedEmailKey(providerName, secretCode, domainId));
158+
OauthProviderVO registration = findRegistration(providerName, domainId);
159+
String verifiedEmail = verifiedEmailCache.asMap().remove(verifiedEmailKey(registration, secretCode));
157160
if (verifiedEmail == null) {
158-
verifiedEmail = resolveEmail(secretCode, domainId, providerName);
161+
verifiedEmail = resolveEmail(registration, secretCode);
159162
}
160163
if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) {
161164
throw new CloudRuntimeException("Unable to verify the email address with the provided secret");
@@ -166,26 +169,26 @@ public boolean verifyUser(String email, String secretCode, Long domainId, String
166169

167170
@Override
168171
public String verifySecretCodeAndFetchEmail(String secretCode, Long domainId, String providerName) {
169-
String email = resolveEmail(secretCode, domainId, providerName);
170-
verifiedEmailCache.put(verifiedEmailKey(providerName, secretCode, domainId), email);
172+
OauthProviderVO registration = findRegistration(providerName, domainId);
173+
String email = resolveEmail(registration, secretCode);
174+
verifiedEmailCache.put(verifiedEmailKey(registration, secretCode), email);
171175
return email;
172176
}
173177

174-
protected String resolveEmail(String secretCode, Long domainId, String providerName) {
175-
OauthProviderVO provider = findRegistration(providerName, domainId);
176-
OIDCMetadata metadata = getMetadata(provider);
177-
String idToken = exchangeAuthorizationCode(secretCode, provider, metadata);
178+
protected String resolveEmail(OauthProviderVO registration, String secretCode) {
179+
OIDCMetadata metadata = getMetadata(registration);
180+
String idToken = exchangeAuthorizationCode(secretCode, registration, metadata);
178181

179-
return validateAndExtractEmail(idToken, provider, metadata);
182+
return validateAndExtractEmail(idToken, registration, metadata);
180183
}
181184

182185
@Override
183186
public String getUserEmailAddress() throws CloudRuntimeException {
184187
return null;
185188
}
186189

187-
private String verifiedEmailKey(String providerName, String secretCode, Long domainId) {
188-
return DigestUtils.sha256Hex(providerName + ":" + domainId + ":" + secretCode);
190+
private String verifiedEmailKey(OauthProviderVO registration, String secretCode) {
191+
return DigestUtils.sha256Hex(registration.getUuid() + ":" + secretCode);
189192
}
190193

191194
protected OauthProviderVO findRegistration(String providerName, Long domainId) {
@@ -279,18 +282,38 @@ protected String validateAndExtractEmail(String idToken, OauthProviderVO provide
279282
if (StringUtils.isBlank(email)) {
280283
throw new CloudAuthenticationException("The id_token carries no email claim");
281284
}
282-
if (!isEmailVerified(claims)) {
283-
throw new CloudAuthenticationException("The identity provider has not verified the email address in the id_token");
285+
Boolean emailVerified = emailVerifiedClaim(claims);
286+
if (Boolean.FALSE.equals(emailVerified)) {
287+
throw new CloudAuthenticationException("The identity provider has marked the email address in the id_token as not verified");
288+
}
289+
if (emailVerified == null && isVerifiedEmailRequired(provider)) {
290+
throw new CloudAuthenticationException(String.format(
291+
"The identity provider did not assert that the email address is verified; set %s to false for this domain to accept it",
292+
OAuth2AuthManager.OIDCRequireVerifiedEmail.key()));
284293
}
285294
return email;
286295
}
287296

288-
private boolean isEmailVerified(JwtClaims claims) {
297+
private Boolean emailVerifiedClaim(JwtClaims claims) {
289298
Object verified = claims.getClaim("email_verified");
290299
if (verified instanceof Boolean) {
291300
return (Boolean) verified;
292301
}
293-
return verified instanceof String && Boolean.parseBoolean((String) verified);
302+
if (verified instanceof String) {
303+
String value = ((String) verified).trim();
304+
if ("true".equalsIgnoreCase(value)) {
305+
return Boolean.TRUE;
306+
}
307+
if ("false".equalsIgnoreCase(value)) {
308+
return Boolean.FALSE;
309+
}
310+
}
311+
return null;
312+
}
313+
314+
protected boolean isVerifiedEmailRequired(OauthProviderVO provider) {
315+
return Boolean.TRUE.equals(
316+
OAuth2AuthManager.OIDCRequireVerifiedEmail.valueInScope(ConfigKey.Scope.Domain, provider.getDomainId(), false));
294317
}
295318

296319
protected void verifySignature(JwsJwtCompactConsumer consumer, OIDCMetadata metadata, OauthProviderVO provider) {
@@ -344,7 +367,8 @@ protected String httpGet(String url, String failureMessage) {
344367
try (CloseableHttpResponse response = httpClient.execute(new HttpGet(url))) {
345368
String body = EntityUtils.toString(response.getEntity());
346369
if (response.getStatusLine().getStatusCode() != 200) {
347-
throw new CloudRuntimeException(String.format("%s: %s", failureMessage, body));
370+
logger.warn("{} (HTTP {}): {}", failureMessage, response.getStatusLine().getStatusCode(), body);
371+
throw new CloudRuntimeException(failureMessage);
348372
}
349373
return body;
350374
} catch (IOException e) {

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

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
package org.apache.cloudstack.oauth2.oidc;
2020

2121
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertFalse;
2223
import static org.junit.Assert.assertNull;
2324
import static org.junit.Assert.assertTrue;
25+
import static org.junit.Assert.fail;
2426
import static org.mockito.ArgumentMatchers.any;
2527
import static org.mockito.ArgumentMatchers.anyString;
2628
import static org.mockito.ArgumentMatchers.eq;
@@ -48,6 +50,10 @@
4850
import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactProducer;
4951
import org.apache.cxf.rs.security.jose.jws.JwsUtils;
5052
import org.apache.cxf.rs.security.jose.jwt.JwtClaims;
53+
import org.apache.http.StatusLine;
54+
import org.apache.http.client.methods.CloseableHttpResponse;
55+
import org.apache.http.client.methods.HttpGet;
56+
import org.apache.http.entity.StringEntity;
5157
import org.apache.http.impl.client.CloseableHttpClient;
5258
import org.junit.After;
5359
import org.junit.Before;
@@ -360,7 +366,7 @@ public void testEveryCallExchangesItsOwnAuthorizationCode() {
360366
@Test(expected = CloudRuntimeException.class)
361367
public void testVerifyUserRejectsAnEmailThatDoesNotMatchTheToken() {
362368
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
363-
doReturn("someone-else@example.com").when(provider).resolveEmail("code", null, REGISTRATION);
369+
doReturn("someone-else@example.com").when(provider).resolveEmail(registration, "code");
364370

365371
provider.verifyUser("user@example.com", "code", null, REGISTRATION);
366372
}
@@ -371,38 +377,64 @@ public void testVerifyUserRejectsAnEmailThatDoesNotMatchTheToken() {
371377
*/
372378
@Test
373379
public void testLoginAfterVerificationDoesNotRedeemTheCodeAgain() {
374-
doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION);
380+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
381+
doReturn("user@example.com").when(provider).resolveEmail(registration, "code");
375382

376383
assertEquals("user@example.com", provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION));
377384
assertTrue(provider.verifyUser("user@example.com", "code", null, REGISTRATION));
378385

379-
verify(provider, times(1)).resolveEmail("code", null, REGISTRATION);
386+
verify(provider, times(1)).resolveEmail(registration, "code");
387+
}
388+
389+
/**
390+
* verifyOAuthCodeAndGetUser can resolve the code with no domain while the login that follows lands on a
391+
* concrete domain. The cached email therefore has to key on the resolved registration, not the request
392+
* domain, or the single use authorization code would be redeemed a second time and rejected.
393+
*/
394+
@Test
395+
public void testCachedEmailIsReusedWhenTheLoginResolvesToADifferentDomainScope() {
396+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
397+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, 1L)).thenReturn(registration);
398+
doReturn("user@example.com").when(provider).resolveEmail(registration, "code");
399+
400+
assertEquals("user@example.com", provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION));
401+
assertTrue(provider.verifyUser("user@example.com", "code", 1L, REGISTRATION));
402+
403+
verify(provider, times(1)).resolveEmail(registration, "code");
380404
}
381405

382406
@Test
383407
public void testVerifiedCodeIsServedFromTheCacheOnlyOnce() {
384-
doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION);
408+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
409+
doReturn("user@example.com").when(provider).resolveEmail(registration, "code");
385410

386411
provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION);
387412
provider.verifyUser("user@example.com", "code", null, REGISTRATION);
388413
provider.verifyUser("user@example.com", "code", null, REGISTRATION);
389414

390-
verify(provider, times(2)).resolveEmail("code", null, REGISTRATION);
415+
verify(provider, times(2)).resolveEmail(registration, "code");
391416
}
392417

393418
@Test(expected = CloudRuntimeException.class)
394419
public void testAnotherCodeIsNeverAnsweredFromTheCache() {
395-
doReturn("user@example.com").when(provider).resolveEmail("user-code", null, REGISTRATION);
396-
doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail("unrelated-code", null, REGISTRATION);
420+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
421+
doReturn("user@example.com").when(provider).resolveEmail(registration, "user-code");
422+
doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail(registration, "unrelated-code");
397423

398424
provider.verifySecretCodeAndFetchEmail("user-code", null, REGISTRATION);
399425
provider.verifyUser("user@example.com", "unrelated-code", null, REGISTRATION);
400426
}
401427

402428
@Test(expected = CloudRuntimeException.class)
403429
public void testCachedCodeIsScopedToItsRegistration() {
404-
doReturn("user@example.com").when(provider).resolveEmail("code", null, REGISTRATION);
405-
doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail("code", null, "other-idp");
430+
OauthProviderVO other = new OauthProviderVO();
431+
other.setProvider("other-idp");
432+
other.setType(GenericOIDCOAuth2Provider.OIDC_PROVIDER_TYPE);
433+
other.setIssuerUrl(ISSUER);
434+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback(REGISTRATION, null)).thenReturn(registration);
435+
when(oauthProviderDao.findByProviderAndDomainWithGlobalFallback("other-idp", null)).thenReturn(other);
436+
doReturn("user@example.com").when(provider).resolveEmail(registration, "code");
437+
doThrow(new CloudRuntimeException("invalid_grant")).when(provider).resolveEmail(other, "code");
406438

407439
provider.verifySecretCodeAndFetchEmail("code", null, REGISTRATION);
408440
provider.verifyUser("user@example.com", "code", null, "other-idp");
@@ -412,4 +444,74 @@ public void testCachedCodeIsScopedToItsRegistration() {
412444
public void testVerifyUserRejectsEmptyArguments() {
413445
provider.verifyUser("", "", null, REGISTRATION);
414446
}
447+
448+
/**
449+
* Some conformant providers, such as Microsoft Entra ID, never send the email_verified claim. The domain
450+
* can opt out of requiring it, and then a token without the claim is accepted.
451+
*/
452+
@Test
453+
public void testAbsentEmailVerifiedIsAcceptedWhenTheDomainDoesNotRequireIt() throws Exception {
454+
KeyPair keys = rsaKeyPair();
455+
publishKey(keys, "key-1");
456+
doReturn(false).when(provider).isVerifiedEmailRequired(registration);
457+
458+
assertEquals("user@example.com",
459+
provider.validateAndExtractEmail(signedIdToken(keys, "key-1", "user@example.com", null), registration, metadata()));
460+
}
461+
462+
@Test(expected = CloudAuthenticationException.class)
463+
public void testAbsentEmailVerifiedIsRejectedWhenTheDomainRequiresIt() throws Exception {
464+
KeyPair keys = rsaKeyPair();
465+
publishKey(keys, "key-1");
466+
doReturn(true).when(provider).isVerifiedEmailRequired(registration);
467+
468+
provider.validateAndExtractEmail(signedIdToken(keys, "key-1", "user@example.com", null), registration, metadata());
469+
}
470+
471+
/**
472+
* An explicit email_verified=false is the provider telling us the address is not verified, so it is
473+
* rejected regardless of the domain policy.
474+
*/
475+
@Test(expected = CloudAuthenticationException.class)
476+
public void testExplicitlyUnverifiedEmailIsRejectedEvenWhenTheDomainDoesNotRequireVerification() throws Exception {
477+
KeyPair keys = rsaKeyPair();
478+
publishKey(keys, "key-1");
479+
doReturn(false).when(provider).isVerifiedEmailRequired(registration);
480+
481+
provider.validateAndExtractEmail(signedIdToken(keys, "key-1", "user@example.com", false), registration, metadata());
482+
}
483+
484+
@Test
485+
public void testHttpGetDoesNotLeakTheProviderBodyOnFailure() throws Exception {
486+
CloseableHttpResponse response = Mockito.mock(CloseableHttpResponse.class);
487+
StatusLine statusLine = Mockito.mock(StatusLine.class);
488+
when(statusLine.getStatusCode()).thenReturn(500);
489+
when(response.getStatusLine()).thenReturn(statusLine);
490+
when(response.getEntity()).thenReturn(new StringEntity("sensitive idp body"));
491+
when(httpClient.execute(any(HttpGet.class))).thenReturn(response);
492+
493+
try {
494+
provider.httpGet(ISSUER + "/jwks", "Unable to read the signing keys from " + ISSUER + "/jwks");
495+
fail("expected the fetch to fail");
496+
} catch (CloudRuntimeException e) {
497+
assertEquals("Unable to read the signing keys from " + ISSUER + "/jwks", e.getMessage());
498+
assertFalse(e.getMessage().contains("sensitive idp body"));
499+
}
500+
}
501+
502+
private String signedIdToken(KeyPair keys, String keyId, String email, Boolean emailVerified) {
503+
JwtClaims claims = new JwtClaims();
504+
claims.setIssuer(ISSUER);
505+
claims.setAudiences(Collections.singletonList(CLIENT_ID));
506+
claims.setSubject("12345");
507+
claims.setExpiryTime(System.currentTimeMillis() / 1000L + 3600);
508+
claims.setClaim("email", email);
509+
if (emailVerified != null) {
510+
claims.setClaim("email_verified", emailVerified);
511+
}
512+
JwsHeaders headers = new JwsHeaders(SignatureAlgorithm.RS256);
513+
headers.setKeyId(keyId);
514+
return new JwsJwtCompactProducer(headers, claims)
515+
.signWith(JwsUtils.getPrivateKeySignatureProvider(keys.getPrivate(), SignatureAlgorithm.RS256));
516+
}
415517
}

0 commit comments

Comments
 (0)