Skip to content

Commit a7dc96a

Browse files
committed
feat(tests): Add tests with offline_access scope in OAuth2AgentKeycloakIT
This change simply adds a few more tests to exercise the behavior of the agent against Keycloak, when offline tokens are requested.
1 parent 584f3ff commit a7dc96a

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

oauth2/core/src/intTest/java/com/dremio/iceberg/authmgr/oauth2/agent/OAuth2AgentKeycloakIT.java

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import com.nimbusds.jwt.JWTParser;
5757
import com.nimbusds.oauth2.sdk.ErrorObject;
5858
import com.nimbusds.oauth2.sdk.GrantType;
59+
import com.nimbusds.oauth2.sdk.Scope;
5960
import com.nimbusds.oauth2.sdk.auth.Secret;
6061
import com.nimbusds.oauth2.sdk.id.ClientID;
6162
import 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
}

oauth2/core/src/main/java/com/dremio/iceberg/authmgr/oauth2/flow/TokensResult.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public Instant getAccessTokenExpirationTime() {
9090
return exp != null ? exp : getAccessTokenJwtExpirationTime();
9191
}
9292

93-
@Value.Derived
93+
@Value.Lazy
9494
@Nullable
9595
public Instant getRefreshTokenExpirationTime() {
9696
Instant exp = getRefreshTokenResponseExpirationTime();

oauth2/tests/src/main/java/com/dremio/iceberg/authmgr/oauth2/test/container/KeycloakContainer.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
import java.util.Locale;
3333
import java.util.Map;
3434
import java.util.UUID;
35-
import java.util.stream.Collectors;
3635
import org.keycloak.admin.client.Keycloak;
3736
import org.keycloak.admin.client.resource.RealmResource;
3837
import org.keycloak.representations.idm.ClientRepresentation;
@@ -234,7 +233,10 @@ protected void createClient(RealmResource master, ClientRepresentation client) {
234233
client.setAttributes(attributes);
235234
}
236235
client.setOptionalClientScopes(
237-
scopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()));
236+
ImmutableList.<String>builder()
237+
.addAll(scopes.stream().map(ClientScopeRepresentation::getName).iterator())
238+
.add("offline_access")
239+
.build());
238240
try (Response response = master.clients().create(client)) {
239241
if (response.getStatus() != 201) {
240242
throw new IllegalStateException(

0 commit comments

Comments
 (0)