diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/Throwables.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/Throwables.java index ab7e70f3ab..016daf2878 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/Throwables.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/Throwables.java @@ -76,7 +76,7 @@ public final class Throwables { .response().json(TokenIntrospectionResponse.class).execute(); if (!response.getActive()) { - token.clearToken(); + token.clearTokens(); try { return callable.call(); } catch (Exception e) { diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java index 997182df5d..342b9b3114 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java @@ -36,7 +36,7 @@ public class TokenCallable implements Callable { private final Http http; private final Configuration configuration; private final ServerConfiguration serverConfiguration; - private AccessTokenResponse clientToken; + private AccessTokenResponse tokenResponse; public TokenCallable(String userName, String password, Http http, Configuration configuration, ServerConfiguration serverConfiguration) { this.userName = userName; @@ -52,55 +52,49 @@ public class TokenCallable implements Callable { @Override public String call() { - if (clientToken == null || clientToken.getRefreshToken() == null) { - if (userName == null || password == null) { - clientToken = obtainAccessToken(); - } else { - clientToken = obtainAccessToken(userName, password); - } - } else { - String refreshTokenValue = clientToken.getRefreshToken(); - try { - RefreshToken refreshToken = JsonSerialization.readValue(new JWSInput(refreshTokenValue).getContent(), RefreshToken.class); - if (!refreshToken.isActive() || !isTokenTimeToLiveSufficient(refreshToken)) { - log.debug("Refresh token is expired."); - if (userName == null || password == null) { - clientToken = obtainAccessToken(); - } else { - clientToken = obtainAccessToken(userName, password); - } - } - } catch (Exception e) { - clientToken = null; - throw new RuntimeException(e); - } + if (tokenResponse == null) { + tokenResponse = obtainTokens(); } - String token = clientToken.getToken(); - try { - AccessToken accessToken = JsonSerialization.readValue(new JWSInput(token).getContent(), AccessToken.class); + String rawAccessToken = tokenResponse.getToken(); + AccessToken accessToken = JsonSerialization.readValue(new JWSInput(rawAccessToken).getContent(), AccessToken.class); if (accessToken.isActive() && this.isTokenTimeToLiveSufficient(accessToken)) { - return token; + return rawAccessToken; } else { log.debug("Access token is expired."); } - - clientToken = http.post(serverConfiguration.getTokenEndpoint()) - .authentication().client() - .form() - .param("grant_type", "refresh_token") - .param("refresh_token", clientToken.getRefreshToken()) - .response() - .json(AccessTokenResponse.class) - .execute(); - } catch (Exception e) { - clientToken = null; - throw new RuntimeException(e); + } catch (Exception cause) { + clearTokens(); + throw new RuntimeException("Failed to parse access token", cause); } - return clientToken.getToken(); + tokenResponse = tryRefreshToken(); + + return tokenResponse.getToken(); + } + + private AccessTokenResponse tryRefreshToken() { + String rawRefreshToken = tokenResponse.getRefreshToken(); + + if (rawRefreshToken == null) { + log.debug("Refresh token not found, obtaining new tokens"); + return obtainTokens(); + } + + try { + RefreshToken refreshToken = JsonSerialization.readValue(new JWSInput(rawRefreshToken).getContent(), RefreshToken.class); + if (!refreshToken.isActive() || !isTokenTimeToLiveSufficient(refreshToken)) { + log.debug("Refresh token is expired."); + return obtainTokens(); + } + } catch (Exception cause) { + clearTokens(); + throw new RuntimeException("Failed to parse refresh token", cause); + } + + return refreshToken(rawRefreshToken); } public boolean isTokenTimeToLiveSufficient(AccessToken token) { @@ -112,7 +106,7 @@ public class TokenCallable implements Callable { * * @return an {@link AccessTokenResponse} */ - AccessTokenResponse obtainAccessToken() { + AccessTokenResponse clientCredentialsGrant() { return this.http.post(this.serverConfiguration.getTokenEndpoint()) .authentication() .client() @@ -126,7 +120,7 @@ public class TokenCallable implements Callable { * * @return an {@link AccessTokenResponse} */ - AccessTokenResponse obtainAccessToken(String userName, String password) { + AccessTokenResponse resourceOwnerPasswordGrant(String userName, String password) { return this.http.post(this.serverConfiguration.getTokenEndpoint()) .authentication() .oauth2ResourceOwnerPassword(userName, password) @@ -135,6 +129,26 @@ public class TokenCallable implements Callable { .execute(); } + private AccessTokenResponse refreshToken(String rawRefreshToken) { + log.debug("Refreshing tokens"); + return http.post(serverConfiguration.getTokenEndpoint()) + .authentication().client() + .form() + .param("grant_type", "refresh_token") + .param("refresh_token", rawRefreshToken) + .response() + .json(AccessTokenResponse.class) + .execute(); + } + + private AccessTokenResponse obtainTokens() { + if (userName == null || password == null) { + return clientCredentialsGrant(); + } else { + return resourceOwnerPasswordGrant(userName, password); + } + } + Http getHttp() { return http; } @@ -151,7 +165,7 @@ public class TokenCallable implements Callable { return serverConfiguration; } - void clearToken() { - clientToken = null; + void clearTokens() { + tokenResponse = null; } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java index cd93a37670..99f3149a57 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java @@ -47,6 +47,7 @@ import org.keycloak.authorization.client.ClientAuthenticator; import org.keycloak.authorization.client.Configuration; import org.keycloak.authorization.client.resource.ProtectionResource; import org.keycloak.authorization.client.util.HttpResponseException; +import org.keycloak.common.util.Time; import org.keycloak.jose.jws.JWSInput; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; @@ -84,6 +85,7 @@ public class AuthzClientCredentialsTest extends AbstractAuthzTest { .build()); testRealms.add(configureRealm(RealmBuilder.create().name("authz-test"), ClientBuilder.create().secret("secret")).build()); testRealms.add(configureRealm(RealmBuilder.create().name("authz-test-session").accessTokenLifespan(1), ClientBuilder.create().secret("secret")).build()); + testRealms.add(configureRealm(RealmBuilder.create().name("authz-test-no-rt").accessTokenLifespan(1), ClientBuilder.create().secret("secret").attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "false")).build()); } @Before @@ -253,6 +255,32 @@ public class AuthzClientCredentialsTest extends AbstractAuthzTest { assertEquals(1, userSessions.size()); } + @Test + public void testNoRefreshToken() throws Exception { + ClientsResource clients = getAdminClient().realm("authz-test-no-rt").clients(); + AuthzClient authzClient = getAuthzClient("default-session-keycloak-no-rt.json"); + org.keycloak.authorization.client.resource.AuthorizationResource authorization = authzClient.authorization(); + AuthorizationResponse response = authorization.authorize(); + AccessToken accessToken = toAccessToken(response.getToken()); + + assertEquals(1, accessToken.getAuthorization().getPermissions().size()); + assertEquals("Default Resource", accessToken.getAuthorization().getPermissions().iterator().next().getResourceName()); + + ProtectionResource protection = authzClient.protection(); + + assertEquals(1, protection.resource().findAll().length); + + try { + // force token expiration on the client side + Time.setOffset(1000); + + // should refresh tokens by doing client credentials again + assertEquals(1, protection.resource().findAll().length); + } finally { + Time.setOffset(0); + } + } + @Test public void testFindByName() { AuthzClient authzClient = getAuthzClient("default-session-keycloak.json"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-session-keycloak-no-rt.json b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-session-keycloak-no-rt.json new file mode 100644 index 0000000000..2a02e22b48 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/authorization-test/default-session-keycloak-no-rt.json @@ -0,0 +1,8 @@ +{ + "realm": "authz-test-no-rt", + "auth-server-url" : "http://localhost:8180/auth", + "resource" : "resource-server-test", + "credentials": { + "secret": "secret" + } +} \ No newline at end of file