From b40c12c71272732964ec5ab159f4385b1f59a04b Mon Sep 17 00:00:00 2001 From: Yoshiyuki Tabata Date: Thu, 30 Jan 2020 11:28:48 +0900 Subject: [PATCH] KEYCLOAK-5325 Provide OAuth token revocation capability --- .../org/keycloak/OAuthErrorException.java | 1 + .../oidc/OIDCLoginProtocolService.java | 13 + .../endpoints/TokenRevocationEndpoint.java | 211 ++++++++++++++++ .../keycloak/testsuite/util/OAuthClient.java | 47 ++++ .../oauth/TokenRevocationCorsTest.java | 110 ++++++++ .../testsuite/oauth/TokenRevocationTest.java | 235 ++++++++++++++++++ 6 files changed, 617 insertions(+) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationCorsTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java index 940d434c20..ec8f690c6e 100755 --- a/core/src/main/java/org/keycloak/OAuthErrorException.java +++ b/core/src/main/java/org/keycloak/OAuthErrorException.java @@ -49,6 +49,7 @@ public class OAuthErrorException extends Exception { public static final String INVALID_CLIENT = "invalid_client"; public static final String INVALID_GRANT = "invalid_grant"; public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; + public static final String UNSUPPORTED_TOKEN_TYPE = "unsupported_token_type"; public OAuthErrorException(String error, String description, String message, Throwable cause) { super(message, cause); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index 757f4b7a1c..b9c7cf8d37 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -36,6 +36,7 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint; import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; +import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint; import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint; import org.keycloak.protocol.oidc.ext.OIDCExtProvider; import org.keycloak.services.managers.AuthenticationManager; @@ -138,6 +139,11 @@ public class OIDCLoginProtocolService { return uriBuilder.path(OIDCLoginProtocolService.class, "logout"); } + public static UriBuilder tokenRevocationUrl(UriBuilder baseUriBuilder) { + UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder); + return uriBuilder.path(OIDCLoginProtocolService.class, "revoke"); + } + /** * Authorization endpoint */ @@ -233,6 +239,13 @@ public class OIDCLoginProtocolService { return endpoint; } + @Path("revoke") + public Object revoke() { + TokenRevocationEndpoint endpoint = new TokenRevocationEndpoint(realm, event); + ResteasyProviderFactory.getInstance().injectProperties(endpoint); + return endpoint; + } + @Path("oauth/oob") @GET public Response installedAppUrnCallback(final @QueryParam("code") String code, final @QueryParam("error") String error, final @QueryParam("error_description") String errorDescription) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java new file mode 100644 index 0000000000..67c045af15 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java @@ -0,0 +1,211 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oidc.endpoints; + +import java.util.List; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.RefreshToken; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.managers.UserSessionCrossDCManager; +import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.services.resources.Cors; +import org.keycloak.util.TokenUtil; + +/** + * @author Yoshiyuki Tabata + */ +public class TokenRevocationEndpoint { + private static final String PARAM_TOKEN = "token"; + + @Context + private KeycloakSession session; + + @Context + private HttpRequest request; + + @Context + private HttpHeaders headers; + + @Context + private ClientConnection clientConnection; + + private MultivaluedMap formParams; + private ClientModel client; + private RealmModel realm; + private EventBuilder event; + private Cors cors; + private RefreshToken token; + private UserModel user; + + public TokenRevocationEndpoint(RealmModel realm, EventBuilder event) { + this.realm = realm; + this.event = event; + } + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response revoke() { + event.event(EventType.REVOKE_GRANT); + + cors = Cors.add(request).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); + + checkSsl(); + checkRealm(); + checkClient(); + + formParams = request.getDecodedFormParameters(); + + checkToken(); + checkIssuedFor(); + + checkUser(); + revokeClient(); + + event.detail(Details.REVOKED_CLIENT, client.getClientId()).success(); + + return cors.builder(Response.ok()).build(); + } + + private void checkSsl() { + if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") + && realm.getSslRequired().isRequired(clientConnection)) { + throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.INVALID_REQUEST, "HTTPS required", + Response.Status.FORBIDDEN); + } + } + + private void checkRealm() { + if (!realm.isEnabled()) { + throw new CorsErrorResponseException(cors.allowAllOrigins(), "access_denied", "Realm not enabled", + Response.Status.FORBIDDEN); + } + } + + private void checkClient() { + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event); + client = clientAuth.getClient(); + + event.client(client); + + cors.allowedOrigins(session, client); + + if (client.isBearerOnly()) { + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Bearer-only not allowed", + Response.Status.BAD_REQUEST); + } + } + + private void checkToken() { + String encodedToken = formParams.getFirst(PARAM_TOKEN); + + if (encodedToken == null) { + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Token not provided", + Response.Status.BAD_REQUEST); + } + + token = session.tokens().decode(encodedToken, RefreshToken.class); + + if (token == null) { + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.OK); + } + + if (!(TokenUtil.TOKEN_TYPE_REFRESH.equals(token.getType()) || TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType()))) { + event.error(Errors.INVALID_TOKEN_TYPE); + throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_TOKEN_TYPE, "Unsupported token type", + Response.Status.BAD_REQUEST); + } + } + + private void checkIssuedFor() { + String issuedFor = token.getIssuedFor(); + if (issuedFor == null) { + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.OK); + } + + if (!client.getClientId().equals(issuedFor)) { + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Unmatching clients", + Response.Status.BAD_REQUEST); + } + } + + private void checkUser() { + UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, + token.getSessionState(), false, client.getId()); + + if (userSession == null) { + userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, + client.getId()); + + if (userSession == null) { + event.error(Errors.USER_SESSION_NOT_FOUND); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", + Response.Status.OK); + } + } + + user = userSession.getUser(); + + if (user == null) { + event.error(Errors.USER_NOT_FOUND); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.OK); + } + + event.user(user); + } + + private void revokeClient() { + session.users().revokeConsentForClient(realm, user.getId(), client.getId()); + if (TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType())) { + new UserSessionManager(session).revokeOfflineToken(user, client); + } + + List userSessions = session.sessions().getUserSessions(realm, user); + for (UserSessionModel userSession : userSessions) { + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); + if (clientSession != null) { + org.keycloak.protocol.oidc.TokenManager.dettachClientSession(session.sessions(), realm, clientSession); + } + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index f95c5f649d..29ee006fb2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -653,6 +653,48 @@ public class OAuthClient { return client.execute(post); } + public CloseableHttpResponse doTokenRevoke(String token, String tokenTypeHint, String clientSecret) { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + return doTokenRevoke(token, tokenTypeHint, clientSecret, client); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + public CloseableHttpResponse doTokenRevoke(String token, String tokenTypeHint, String clientSecret, + CloseableHttpClient client) throws IOException { + HttpPost post = new HttpPost(getTokenRevocationUrl()); + + List parameters = new LinkedList<>(); + if (token != null) { + parameters.add(new BasicNameValuePair("token", token)); + } + if (tokenTypeHint != null) { + parameters.add(new BasicNameValuePair("token_type_hint", tokenTypeHint)); + } + + if (origin != null) { + post.addHeader("Origin", origin); + } + + if (clientId != null && clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + } else if (clientId != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return client.execute(post); + } + // KEYCLOAK-6771 Certificate Bound Token public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) { @@ -933,6 +975,11 @@ public class OAuthClient { return new LogoutUrlBuilder(); } + public String getTokenRevocationUrl() { + UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + public String getResourceOwnerPasswordCredentialGrantUrl() { UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl)); return b.build(realm).toString(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationCorsTest.java new file mode 100644 index 0000000000..571655b462 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationCorsTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oauth; + +import static org.junit.Assert.*; +import static org.keycloak.testsuite.admin.AbstractAdminTest.*; + +import java.io.IOException; +import java.util.List; + +import javax.ws.rs.core.Response.Status; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.junit.Test; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; +import org.keycloak.util.JsonSerialization; + +/** + * @author Yoshiyuki Tabata + */ +public class TokenRevocationCorsTest extends AbstractKeycloakTest { + + private static final String VALID_CORS_URL = "http://localtest.me:8180"; + private static final String INVALID_CORS_URL = "http://invalid.localtest.me:8180"; + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + realm.getClients().add(ClientBuilder.create().redirectUris(VALID_CORS_URL + "/realms/master/app") + .addWebOrigin(VALID_CORS_URL).id("test-app2").clientId("test-app2").publicClient().directAccessGrants().build()); + testRealms.add(realm); + } + + @Test + public void testTokenRevocationCorsRequestWithValidUrl() throws Exception { + oauth.realm("test"); + oauth.clientId("test-app2"); + oauth.redirectUri(VALID_CORS_URL + "/realms/master/app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", + "password"); + + oauth.origin(VALID_CORS_URL); + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + assertCors(response); + + isTokenDisabled(tokenResponse, "test-app2"); + } + + @Test + public void userTokenRevocationCorsRequestWithInvalidUrlShouldFail() throws Exception { + oauth.realm("test"); + oauth.clientId("test-app2"); + oauth.redirectUri(VALID_CORS_URL + "/realms/master/app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", + "password"); + + oauth.origin(INVALID_CORS_URL); + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + assertNotCors(response); + + isTokenDisabled(tokenResponse, "test-app2"); + } + + private static void assertCors(CloseableHttpResponse response) { + assertEquals("true", response.getHeaders("Access-Control-Allow-Credentials")[0].getValue()); + assertEquals(VALID_CORS_URL, response.getHeaders("Access-Control-Allow-Origin")[0].getValue()); + assertEquals("Access-Control-Allow-Methods", response.getHeaders("Access-Control-Expose-Headers")[0].getValue()); + } + + private static void assertNotCors(CloseableHttpResponse response) { + assertEquals(0, response.getHeaders("Access-Control-Allow-Credentials").length); + assertEquals(0, response.getHeaders("Access-Control-Allow-Origin").length); + assertEquals(0, response.getHeaders("Access-Control-Expose-Headers").length); + } + + private void isTokenDisabled(AccessTokenResponse tokenResponse, String clientId) throws IOException { + String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, "password", + tokenResponse.getAccessToken()); + TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertFalse(rep.isActive()); + + oauth.clientId(clientId); + OAuthClient.AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), + "password"); + assertEquals(Status.BAD_REQUEST.getStatusCode(), tokenRefreshResponse.getStatusCode()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java new file mode 100644 index 0000000000..180ee0bfd7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java @@ -0,0 +1,235 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oauth; + +import static org.junit.Assert.*; +import static org.keycloak.testsuite.admin.AbstractAdminTest.*; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.core.Response.Status; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserSessionRepresentation; +import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.util.JsonSerialization; + +/** + * @author Yoshiyuki Tabata + */ +public class TokenRevocationTest extends AbstractKeycloakTest { + + private RealmResource realm; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + public void beforeAbstractKeycloakTest() throws Exception { + super.beforeAbstractKeycloakTest(); + } + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), + RealmRepresentation.class); + RealmBuilder realm = RealmBuilder.edit(realmRepresentation).testEventListener(); + + testRealms.add(realm.build()); + } + + @Before + public void clientConfiguration() { + realm = adminClient.realm("test"); + ClientManager.realm(realm).clientId("test-app").directAccessGrant(true); + ClientManager.realm(realm).clientId("test-app-scope").directAccessGrant(true); + } + + @Page + protected LoginPage loginPage; + + @Test + public void testRevokeToken() throws Exception { + oauth.clientSessionState("client-session"); + OAuthClient.AccessTokenResponse tokenResponse1 = login("test-app", "test-user@localhost", "password"); + OAuthClient.AccessTokenResponse tokenResponse2 = login("test-app-scope", "test-user@localhost", "password"); + + UserResource testUser = realm.users().get(realm.users().search("test-user@localhost").get(0).getId()); + List userSessions = testUser.getUserSessions(); + assertEquals(1, userSessions.size()); + Map clients = userSessions.get(0).getClients(); + assertEquals("test-app", clients.get(realm.clients().findByClientId("test-app").get(0).getId())); + assertEquals("test-app-scope", clients.get(realm.clients().findByClientId("test-app-scope").get(0).getId())); + + isTokenEnabled(tokenResponse1, "test-app"); + isTokenEnabled(tokenResponse2, "test-app-scope"); + + oauth.clientId("test-app"); + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse1.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + + userSessions = testUser.getUserSessions(); + assertEquals(1, userSessions.size()); + clients = userSessions.get(0).getClients(); + assertNull(clients.get(realm.clients().findByClientId("test-app").get(0).getId())); + assertEquals("test-app-scope", clients.get(realm.clients().findByClientId("test-app-scope").get(0).getId())); + + isTokenDisabled(tokenResponse1, "test-app"); + isTokenEnabled(tokenResponse2, "test-app-scope"); + } + + @Test + public void testRevokeAccessToken() throws Exception { + oauth.clientId("test-app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", + "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getAccessToken(), "access_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.BAD_REQUEST)); + + isTokenEnabled(tokenResponse, "test-app"); + } + + @Test + public void testRevokeOfflineToken() throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.clientId("test-app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", + "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + + isTokenDisabled(tokenResponse, "test-app"); + } + + @Test + public void testTokenTypeHint() throws Exception { + // different token_type_hint + oauth.clientId("test-app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", + "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "access_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + + isTokenDisabled(tokenResponse, "test-app"); + + // invalid token_type_hint + oauth.clientId("test-app"); + tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "invalid_token_type_hint", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + + isTokenDisabled(tokenResponse, "test-app"); + } + + @Test + public void testRevokeTokenFromDifferentClient() throws Exception { + oauth.clientId("test-app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", + "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + oauth.clientId("test-app-scope"); + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.BAD_REQUEST)); + + isTokenEnabled(tokenResponse, "test-app"); + } + + @Test + public void testRevokeAlreadyRevokedToken() throws Exception { + oauth.clientId("test-app"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", + "password"); + + isTokenEnabled(tokenResponse, "test-app"); + + oauth.doLogout(tokenResponse.getRefreshToken(), "password"); + + isTokenDisabled(tokenResponse, "test-app"); + + CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getRefreshToken(), "refresh_token", "password"); + assertThat(response, Matchers.statusCodeIsHC(Status.OK)); + + isTokenDisabled(tokenResponse, "test-app"); + } + + private AccessTokenResponse login(String clientId, String username, String password) { + oauth.clientId(clientId); + oauth.openLoginForm(); + if (loginPage.isCurrent()) { + loginPage.login(username, password); + } + String code = new OAuthClient.AuthorizationEndpointResponse(oauth).getCode(); + return oauth.doAccessTokenRequest(code, "password"); + } + + private void isTokenEnabled(AccessTokenResponse tokenResponse, String clientId) throws IOException { + String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, "password", + tokenResponse.getAccessToken()); + TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertTrue(rep.isActive()); + + oauth.clientId(clientId); + OAuthClient.AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), + "password"); + assertEquals(Status.OK.getStatusCode(), tokenRefreshResponse.getStatusCode()); + } + + private void isTokenDisabled(AccessTokenResponse tokenResponse, String clientId) throws IOException { + String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, "password", + tokenResponse.getAccessToken()); + TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertFalse(rep.isActive()); + + oauth.clientId(clientId); + OAuthClient.AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), + "password"); + assertEquals(Status.BAD_REQUEST.getStatusCode(), tokenRefreshResponse.getStatusCode()); + } +}