1919package org .apache .cloudstack .oauth2 .oidc ;
2020
2121import static org .junit .Assert .assertEquals ;
22+ import static org .junit .Assert .assertFalse ;
2223import static org .junit .Assert .assertNull ;
2324import static org .junit .Assert .assertTrue ;
25+ import static org .junit .Assert .fail ;
2426import static org .mockito .ArgumentMatchers .any ;
2527import static org .mockito .ArgumentMatchers .anyString ;
2628import static org .mockito .ArgumentMatchers .eq ;
4850import org .apache .cxf .rs .security .jose .jws .JwsJwtCompactProducer ;
4951import org .apache .cxf .rs .security .jose .jws .JwsUtils ;
5052import 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 ;
5157import org .apache .http .impl .client .CloseableHttpClient ;
5258import org .junit .After ;
5359import 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