5656import com .nimbusds .jwt .JWTParser ;
5757import com .nimbusds .oauth2 .sdk .ErrorObject ;
5858import com .nimbusds .oauth2 .sdk .GrantType ;
59+ import com .nimbusds .oauth2 .sdk .Scope ;
5960import com .nimbusds .oauth2 .sdk .auth .Secret ;
6061import com .nimbusds .oauth2 .sdk .id .ClientID ;
6162import com .nimbusds .oauth2 .sdk .pkce .CodeChallengeMethod ;
@@ -284,8 +285,8 @@ void impersonation1(
284285 /**
285286 * Tests a simple impersonation scenario with the agent using its own token as the subject token,
286287 * and no actor token. The agent swaps its token for another one, roughly equivalent. Refresh
287- * tokens are present, which is why the client credentials grant cannot be used for the subject
288- * token.
288+ * tokens are present, which is why the client credentials or jwt-bearer grant cannot be used for
289+ * the subject token.
289290 */
290291 @ CartesianTest
291292 void impersonation2 (
@@ -508,17 +509,92 @@ void agentCopy(Builder envBuilder) throws Exception {
508509 }
509510 }
510511
512+ /**
513+ * Tests that requesting the offline_access scope in interactive grants returns an offline refresh
514+ * token (one that is not bound to the SSO session lifetime).
515+ */
516+ @ CartesianTest
517+ void offlineAccess (
518+ @ EnumLike (
519+ excludes = {
520+ "client_credentials" ,
521+ "urn:ietf:params:oauth:grant-type:jwt-bearer" ,
522+ "urn:ietf:params:oauth:grant-type:token-exchange"
523+ })
524+ GrantType grantType ,
525+ Builder envBuilder )
526+ throws Exception {
527+ try (TestEnvironment env =
528+ envBuilder .grantType (grantType ).scope (Scope .parse (SCOPE1 + " offline_access" )).build ();
529+ OAuth2Agent agent = env .newAgent ()) {
530+ TokensResult tokens = agent .authenticateInternal ();
531+ JWT jwt = introspectToken (tokens .getTokens ().getAccessToken (), CLIENT_ID1 );
532+ soft .assertThat (jwt .getJWTClaimsSet ().getStringClaim ("scope" )).contains ("offline_access" );
533+ soft .assertThat (tokens .getTokens ().getRefreshToken ()).isNotNull ();
534+ // Keycloak does not include an expiration time for
535+ // refresh tokens with offline_access
536+ soft .assertThat (tokens .getRefreshTokenExpirationTime ()).isNull ();
537+ }
538+ }
539+
540+ /**
541+ * Tests token exchange where the subject token was obtained with offline_access scope. Requesting
542+ * an access token should succeed, but requesting a refresh token should fail, as keycloak does
543+ * not issue refresh tokens for offline sessions.
544+ */
545+ @ CartesianTest
546+ void offlineAccessImpersonation (
547+ @ EnumLike (
548+ excludes = {
549+ "client_credentials" ,
550+ "urn:ietf:params:oauth:grant-type:jwt-bearer" ,
551+ "urn:ietf:params:oauth:grant-type:token-exchange"
552+ })
553+ GrantType subjectGrantType ,
554+ Builder envBuilder )
555+ throws Exception {
556+ Scope offlineScope = Scope .parse (SCOPE1 + " offline_access" );
557+ try (TestEnvironment env =
558+ envBuilder
559+ .grantType (TOKEN_EXCHANGE )
560+ .requestedTokenType (ACCESS_TOKEN )
561+ .subjectGrantType (subjectGrantType )
562+ .subjectScope (offlineScope )
563+ .build ();
564+ OAuth2Agent agent = env .newAgent ()) {
565+ assertAgent (agent , CLIENT_ID1 , false );
566+ }
567+ try (TestEnvironment env =
568+ envBuilder
569+ .grantType (TOKEN_EXCHANGE )
570+ .requestedTokenType (REFRESH_TOKEN )
571+ .subjectGrantType (subjectGrantType )
572+ .subjectScope (offlineScope )
573+ .build ();
574+ OAuth2Agent agent = env .newAgent ()) {
575+ soft .assertThatThrownBy (agent ::authenticate )
576+ .asInstanceOf (type (OAuth2Exception .class ))
577+ .extracting (OAuth2Exception ::getErrorObject )
578+ .extracting (ErrorObject ::getHTTPStatusCode , ErrorObject ::getCode )
579+ .containsExactly (400 , "invalid_request" );
580+ }
581+ }
582+
511583 private void assertAgent (OAuth2Agent agent , String clientId , boolean expectRefreshToken )
512584 throws Exception {
513585 // initial grant
514586 TokensResult initial = agent .authenticateInternal ();
515587 introspectToken (initial .getTokens ().getAccessToken (), clientId );
516588 // token refresh
517589 if (expectRefreshToken ) {
590+ // Refresh tokens without offline_access are bound by the user session;
591+ // Keycloak returns the refresh token expiration time in these cases.
518592 soft .assertThat (initial .getTokens ().getRefreshToken ()).isNotNull ();
593+ soft .assertThat (initial .getRefreshTokenExpirationTime ()).isNotNull ();
519594 TokensResult refreshed = agent .refreshCurrentTokens (initial ).toCompletableFuture ().get ();
520595 introspectToken (refreshed .getTokens ().getAccessToken (), clientId );
521596 soft .assertThat (refreshed .getTokens ().getRefreshToken ()).isNotNull ();
597+ soft .assertThat (refreshed .getRefreshTokenExpirationTime ()).isNotNull ();
522598 } else {
523599 soft .assertThat (initial .getTokens ().getRefreshToken ()).isNull ();
524600 }
@@ -527,16 +603,18 @@ private void assertAgent(OAuth2Agent agent, String clientId, boolean expectRefre
527603 introspectToken (renewed .getTokens ().getAccessToken (), clientId );
528604 if (expectRefreshToken ) {
529605 soft .assertThat (renewed .getTokens ().getRefreshToken ()).isNotNull ();
606+ soft .assertThat (renewed .getRefreshTokenExpirationTime ()).isNotNull ();
530607 } else {
531608 soft .assertThat (renewed .getTokens ().getRefreshToken ()).isNull ();
532609 }
533610 }
534611
535- private void introspectToken (AccessToken accessToken , String clientId ) throws ParseException {
612+ private JWT introspectToken (AccessToken accessToken , String clientId ) throws ParseException {
536613 soft .assertThat (accessToken ).isNotNull ();
537614 JWT jwt = JWTParser .parse (accessToken .getValue ());
538615 soft .assertThat (jwt ).isNotNull ();
539616 soft .assertThat (jwt .getJWTClaimsSet ().getStringClaim ("azp" )).isEqualTo (clientId );
540617 soft .assertThat (jwt .getJWTClaimsSet ().getStringClaim ("scope" )).contains (SCOPE1 );
618+ return jwt ;
541619 }
542620}
0 commit comments