KEYCLOAK-5325 Provide OAuth token revocation capability
This commit is contained in:
parent
06d8a0a4c4
commit
b40c12c712
6 changed files with 617 additions and 0 deletions
|
@ -49,6 +49,7 @@ public class OAuthErrorException extends Exception {
|
||||||
public static final String INVALID_CLIENT = "invalid_client";
|
public static final String INVALID_CLIENT = "invalid_client";
|
||||||
public static final String INVALID_GRANT = "invalid_grant";
|
public static final String INVALID_GRANT = "invalid_grant";
|
||||||
public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
|
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) {
|
public OAuthErrorException(String error, String description, String message, Throwable cause) {
|
||||||
super(message, cause);
|
super(message, cause);
|
||||||
|
|
|
@ -36,6 +36,7 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
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.endpoints.UserInfoEndpoint;
|
||||||
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
|
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
|
@ -138,6 +139,11 @@ public class OIDCLoginProtocolService {
|
||||||
return uriBuilder.path(OIDCLoginProtocolService.class, "logout");
|
return uriBuilder.path(OIDCLoginProtocolService.class, "logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static UriBuilder tokenRevocationUrl(UriBuilder baseUriBuilder) {
|
||||||
|
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
|
||||||
|
return uriBuilder.path(OIDCLoginProtocolService.class, "revoke");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorization endpoint
|
* Authorization endpoint
|
||||||
*/
|
*/
|
||||||
|
@ -233,6 +239,13 @@ public class OIDCLoginProtocolService {
|
||||||
return endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Path("revoke")
|
||||||
|
public Object revoke() {
|
||||||
|
TokenRevocationEndpoint endpoint = new TokenRevocationEndpoint(realm, event);
|
||||||
|
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
@Path("oauth/oob")
|
@Path("oauth/oob")
|
||||||
@GET
|
@GET
|
||||||
public Response installedAppUrnCallback(final @QueryParam("code") String code, final @QueryParam("error") String error, final @QueryParam("error_description") String errorDescription) {
|
public Response installedAppUrnCallback(final @QueryParam("code") String code, final @QueryParam("error") String error, final @QueryParam("error_description") String errorDescription) {
|
||||||
|
|
|
@ -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 <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
|
||||||
|
*/
|
||||||
|
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<String, String> 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<UserSessionModel> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -653,6 +653,48 @@ public class OAuthClient {
|
||||||
return client.execute(post);
|
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<NameValuePair> 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
|
// KEYCLOAK-6771 Certificate Bound Token
|
||||||
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {
|
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {
|
||||||
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
||||||
|
@ -933,6 +975,11 @@ public class OAuthClient {
|
||||||
return new LogoutUrlBuilder();
|
return new LogoutUrlBuilder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTokenRevocationUrl() {
|
||||||
|
UriBuilder b = OIDCLoginProtocolService.tokenRevocationUrl(UriBuilder.fromUri(baseUrl));
|
||||||
|
return b.build(realm).toString();
|
||||||
|
}
|
||||||
|
|
||||||
public String getResourceOwnerPasswordCredentialGrantUrl() {
|
public String getResourceOwnerPasswordCredentialGrantUrl() {
|
||||||
UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl));
|
UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl));
|
||||||
return b.build(realm).toString();
|
return b.build(realm).toString();
|
||||||
|
|
|
@ -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 <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
|
||||||
|
*/
|
||||||
|
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<RealmRepresentation> 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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <a href="mailto:yoshiyuki.tabata.jy@hitachi.com">Yoshiyuki Tabata</a>
|
||||||
|
*/
|
||||||
|
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<RealmRepresentation> 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<UserSessionRepresentation> userSessions = testUser.getUserSessions();
|
||||||
|
assertEquals(1, userSessions.size());
|
||||||
|
Map<String, String> 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());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue