Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.oauth2.sdk.ErrorObject;
import com.nimbusds.oauth2.sdk.GrantType;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.auth.Secret;
import com.nimbusds.oauth2.sdk.id.ClientID;
import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod;
Expand Down Expand Up @@ -284,8 +285,8 @@ void impersonation1(
/**
* Tests a simple impersonation scenario with the agent using its own token as the subject token,
* and no actor token. The agent swaps its token for another one, roughly equivalent. Refresh
* tokens are present, which is why the client credentials grant cannot be used for the subject
* token.
* tokens are present, which is why the client credentials or jwt-bearer grant cannot be used for
* the subject token.
*/
@CartesianTest
void impersonation2(
Expand Down Expand Up @@ -508,17 +509,92 @@ void agentCopy(Builder envBuilder) throws Exception {
}
}

/**
* Tests that requesting the offline_access scope in interactive grants returns an offline refresh
* token (one that is not bound to the SSO session lifetime).
*/
@CartesianTest
void offlineAccess(
@EnumLike(
excludes = {
"client_credentials",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:token-exchange"
})
GrantType grantType,
Builder envBuilder)
throws Exception {
try (TestEnvironment env =
envBuilder.grantType(grantType).scope(Scope.parse(SCOPE1 + " offline_access")).build();
OAuth2Agent agent = env.newAgent()) {
TokensResult tokens = agent.authenticateInternal();
JWT jwt = introspectToken(tokens.getTokens().getAccessToken(), CLIENT_ID1);
soft.assertThat(jwt.getJWTClaimsSet().getStringClaim("scope")).contains("offline_access");
soft.assertThat(tokens.getTokens().getRefreshToken()).isNotNull();
// Keycloak does not include an expiration time for
// refresh tokens with offline_access
soft.assertThat(tokens.getRefreshTokenExpirationTime()).isNull();
}
}

/**
* Tests token exchange where the subject token was obtained with offline_access scope. Requesting
* an access token should succeed, but requesting a refresh token should fail, as keycloak does
* not issue refresh tokens for offline sessions.
*/
@CartesianTest
void offlineAccessImpersonation(
@EnumLike(
excludes = {
"client_credentials",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:token-exchange"
})
GrantType subjectGrantType,
Builder envBuilder)
throws Exception {
Scope offlineScope = Scope.parse(SCOPE1 + " offline_access");
try (TestEnvironment env =
envBuilder
.grantType(TOKEN_EXCHANGE)
.requestedTokenType(ACCESS_TOKEN)
.subjectGrantType(subjectGrantType)
.subjectScope(offlineScope)
.build();
OAuth2Agent agent = env.newAgent()) {
assertAgent(agent, CLIENT_ID1, false);
}
try (TestEnvironment env =
envBuilder
.grantType(TOKEN_EXCHANGE)
.requestedTokenType(REFRESH_TOKEN)
.subjectGrantType(subjectGrantType)
.subjectScope(offlineScope)
.build();
OAuth2Agent agent = env.newAgent()) {
soft.assertThatThrownBy(agent::authenticate)
.asInstanceOf(type(OAuth2Exception.class))
.extracting(OAuth2Exception::getErrorObject)
.extracting(ErrorObject::getHTTPStatusCode, ErrorObject::getCode)
.containsExactly(400, "invalid_request");
}
}

private void assertAgent(OAuth2Agent agent, String clientId, boolean expectRefreshToken)
throws Exception {
// initial grant
TokensResult initial = agent.authenticateInternal();
introspectToken(initial.getTokens().getAccessToken(), clientId);
// token refresh
if (expectRefreshToken) {
// Refresh tokens without offline_access are bound by the user session;
// Keycloak returns the refresh token expiration time in these cases.
soft.assertThat(initial.getTokens().getRefreshToken()).isNotNull();
soft.assertThat(initial.getRefreshTokenExpirationTime()).isNotNull();
TokensResult refreshed = agent.refreshCurrentTokens(initial).toCompletableFuture().get();
introspectToken(refreshed.getTokens().getAccessToken(), clientId);
soft.assertThat(refreshed.getTokens().getRefreshToken()).isNotNull();
soft.assertThat(refreshed.getRefreshTokenExpirationTime()).isNotNull();
} else {
soft.assertThat(initial.getTokens().getRefreshToken()).isNull();
}
Expand All @@ -527,16 +603,18 @@ private void assertAgent(OAuth2Agent agent, String clientId, boolean expectRefre
introspectToken(renewed.getTokens().getAccessToken(), clientId);
if (expectRefreshToken) {
soft.assertThat(renewed.getTokens().getRefreshToken()).isNotNull();
soft.assertThat(renewed.getRefreshTokenExpirationTime()).isNotNull();
} else {
soft.assertThat(renewed.getTokens().getRefreshToken()).isNull();
}
}

private void introspectToken(AccessToken accessToken, String clientId) throws ParseException {
private JWT introspectToken(AccessToken accessToken, String clientId) throws ParseException {
soft.assertThat(accessToken).isNotNull();
JWT jwt = JWTParser.parse(accessToken.getValue());
soft.assertThat(jwt).isNotNull();
soft.assertThat(jwt.getJWTClaimsSet().getStringClaim("azp")).isEqualTo(clientId);
soft.assertThat(jwt.getJWTClaimsSet().getStringClaim("scope")).contains(SCOPE1);
return jwt;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public Instant getAccessTokenExpirationTime() {
return exp != null ? exp : getAccessTokenJwtExpirationTime();
}

@Value.Derived
@Value.Lazy
@Nullable
public Instant getRefreshTokenExpirationTime() {
Instant exp = getRefreshTokenResponseExpirationTime();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.ClientRepresentation;
Expand Down Expand Up @@ -234,7 +233,10 @@ protected void createClient(RealmResource master, ClientRepresentation client) {
client.setAttributes(attributes);
}
client.setOptionalClientScopes(
scopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()));
ImmutableList.<String>builder()
.addAll(scopes.stream().map(ClientScopeRepresentation::getName).iterator())
.add("offline_access")
.build());
try (Response response = master.clients().create(client)) {
if (response.getStatus() != 201) {
throw new IllegalStateException(
Expand Down
Loading