diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java index f3fa3b2fa3..c81bdd5fca 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java @@ -43,6 +43,7 @@ import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.representations.IDToken; import org.keycloak.representations.LogoutToken; import org.keycloak.representations.RefreshToken; +import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.clientpolicy.ClientPolicyException; @@ -56,6 +57,7 @@ import org.keycloak.util.TokenUtil; import javax.ws.rs.Consumes; import javax.ws.rs.GET; +import javax.ws.rs.OPTIONS; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.QueryParam; @@ -94,12 +96,20 @@ public class LogoutEndpoint { private RealmModel realm; private EventBuilder event; + private Cors cors; + public LogoutEndpoint(TokenManager tokenManager, RealmModel realm, EventBuilder event) { this.tokenManager = tokenManager; this.realm = realm; this.event = event; } + @Path("/") + @OPTIONS + public Response issueUserInfoPreflight() { + return Cors.add(this.request, Response.ok()).auth().preflight().build(); + } + /** * Logout user session. User must be logged in via a session cookie. * @@ -197,6 +207,8 @@ public class LogoutEndpoint { @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response logoutToken() { + cors = Cors.add(request).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); + MultivaluedMap form = request.getDecodedFormParameters(); checkSsl(); @@ -206,13 +218,13 @@ public class LogoutEndpoint { String refreshToken = form.getFirst(OAuth2Constants.REFRESH_TOKEN); if (refreshToken == null) { event.error(Errors.INVALID_TOKEN); - throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST); } try { session.clientPolicy().triggerOnEvent(new LogoutRequestContext(form)); } catch (ClientPolicyException cpe) { - throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); + throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); } RefreshToken token = null; @@ -238,14 +250,14 @@ public class LogoutEndpoint { // KEYCLOAK-6771 Certificate Bound Token if (MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC.equals(e.getDescription())) { event.error(Errors.NOT_ALLOWED); - throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.UNAUTHORIZED); + throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.UNAUTHORIZED); } else { event.error(Errors.INVALID_TOKEN); - throw new ErrorResponseException(e.getError(), e.getDescription(), Response.Status.BAD_REQUEST); + throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST); } } - return Cors.add(request, Response.noContent()).auth().allowedOrigins(session, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); + return cors.builder(Response.noContent()).build(); } /** @@ -416,10 +428,11 @@ public class LogoutEndpoint { } private ClientModel authorizeClient() { - ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); + ClientModel client = AuthorizeClientUtil.authorizeClient(session, event, cors).getClient(); + cors.allowedOrigins(session, client); if (client.isBearerOnly()) { - throw new ErrorResponseException(Errors.INVALID_CLIENT, "Bearer-only not allowed", Response.Status.BAD_REQUEST); + throw new CorsErrorResponseException(cors, Errors.INVALID_CLIENT, "Bearer-only not allowed", Response.Status.BAD_REQUEST); } return client; @@ -427,7 +440,7 @@ public class LogoutEndpoint { private void checkSsl() { if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) { - throw new ErrorResponseException("invalid_request", "HTTPS required", Response.Status.FORBIDDEN); + throw new CorsErrorResponseException(cors.allowAllOrigins(), "invalid_request", "HTTPS required", Response.Status.FORBIDDEN); } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 3dffa04c0b..5d92d5b200 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -263,7 +263,7 @@ public class TokenEndpoint { } private void checkClient() { - AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event); + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, cors); client = clientAuth.getClient(); clientAuthAttributes = clientAuth.getClientAuthAttributes(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java index 38b8ddeab8..f51f0636be 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenIntrospectionEndpoint.java @@ -124,7 +124,7 @@ public class TokenIntrospectionEndpoint { private void authorizeClient() { try { - ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); + ClientModel client = AuthorizeClientUtil.authorizeClient(session, event, null).getClient(); this.event.client(client); 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 index a1b13b71ba..fa450fa3e5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java @@ -140,7 +140,7 @@ public class TokenRevocationEndpoint { } private void checkClient() { - AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event); + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, cors); client = clientAuth.getClient(); event.client(client); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AuthorizeClientUtil.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AuthorizeClientUtil.java index 2d6721c48e..5d44314b2f 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/AuthorizeClientUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AuthorizeClientUtil.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc.utils; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; +import org.jboss.resteasy.spi.HttpResponse; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.ClientAuthenticator; import org.keycloak.authentication.ClientAuthenticatorFactory; @@ -29,7 +30,9 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.resources.Cors; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; @@ -42,17 +45,26 @@ public class AuthorizeClientUtil { private static final Logger logger = Logger.getLogger(AuthorizeClientUtil.class); - public static ClientAuthResult authorizeClient(KeycloakSession session, EventBuilder event) { + public static ClientAuthResult authorizeClient(KeycloakSession session, EventBuilder event, Cors cors) { AuthenticationProcessor processor = getAuthenticationProcessor(session, event); Response response = processor.authenticateClient(); if (response != null) { + if (cors != null) { + cors.allowAllOrigins(); + HttpResponse httpResponse = session.getContext().getContextObject(HttpResponse.class); + cors.build(httpResponse); + } throw new WebApplicationException(response); } ClientModel client = processor.getClient(); if (client == null) { - throw new ErrorResponseException(Errors.INVALID_CLIENT, "Client authentication ended, but client is null", Response.Status.BAD_REQUEST); + throwErrorResponseException(Errors.INVALID_CLIENT, "Client authentication ended, but client is null", Response.Status.BAD_REQUEST, cors.allowAllOrigins()); + } + + if (cors != null) { + cors.allowedOrigins(session, client); } String protocol = client.getProtocol(); @@ -63,7 +75,7 @@ public class AuthorizeClientUtil { if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) { event.error(Errors.INVALID_CLIENT); - throw new ErrorResponseException(Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST); + throwErrorResponseException(Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST, cors); } session.getContext().setClient(client); @@ -97,6 +109,15 @@ public class AuthorizeClientUtil { .orElse(null); } + private static void throwErrorResponseException(String error, String errorDescription, Response.Status status, Cors cors) { + if (cors == null) { + throw new ErrorResponseException(error, errorDescription, status); + } else { + cors.allowAllOrigins(); + throw new CorsErrorResponseException(cors, error, errorDescription, status); + } + } + public static class ClientAuthResult { private final ClientModel client; diff --git a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java index fd7aba09d9..60d1a562c0 100644 --- a/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/openshift/OpenShiftTokenReviewEndpoint.java @@ -146,7 +146,7 @@ public class OpenShiftTokenReviewEndpoint implements OIDCExtProvider, Environmen private void authorizeClient() { try { - ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); + ClientModel client = AuthorizeClientUtil.authorizeClient(session, event, null).getClient(); event.client(client); if (client == null || client.isPublicClient()) { diff --git a/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java b/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java index 3272959bca..2cb39f1423 100755 --- a/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java +++ b/services/src/main/java/org/keycloak/services/resources/ClientsManagementService.java @@ -169,7 +169,7 @@ public class ClientsManagementService { } protected ClientModel authorizeClient() { - ClientModel client = AuthorizeClientUtil.authorizeClient(session, event).getClient(); + ClientModel client = AuthorizeClientUtil.authorizeClient(session, event, null).getClient(); if (client.isPublicClient()) { OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(OAuthErrorException.INVALID_CLIENT, "Public clients not allowed"); diff --git a/services/src/main/java/org/keycloak/services/resources/Cors.java b/services/src/main/java/org/keycloak/services/resources/Cors.java index 5d91f0edbe..2403b983b1 100755 --- a/services/src/main/java/org/keycloak/services/resources/Cors.java +++ b/services/src/main/java/org/keycloak/services/resources/Cors.java @@ -194,11 +194,7 @@ public class Cors { return; } - if (allowedOrigins.contains(ACCESS_CONTROL_ALLOW_ORIGIN_WILDCARD)) { - response.getOutputHeaders().add(ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN_WILDCARD); - } else { - response.getOutputHeaders().add(ACCESS_CONTROL_ALLOW_ORIGIN, origin); - } + response.getOutputHeaders().add(ACCESS_CONTROL_ALLOW_ORIGIN, origin); if (preflight) { if (allowedMethods != null) { 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 775a34848f..48e41d981d 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 @@ -726,6 +726,9 @@ public class OAuthClient { } else if (clientId != null) { parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId)); } + if (origin != null) { + post.addHeader("Origin", origin); + } UrlEncodedFormEntity formEntity; try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutCorsTest.java new file mode 100644 index 0000000000..244305116a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutCorsTest.java @@ -0,0 +1,133 @@ +/* + * 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 java.util.List; + +import javax.ws.rs.core.Response; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RealmBuilder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; + +/** + * @author Marek Posolda + */ +public class LogoutCorsTest 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 beforeAbstractKeycloakTest() throws Exception { + super.beforeAbstractKeycloakTest(); + } + + @Before + public void clientConfiguration() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").addWebOrigins(VALID_CORS_URL); + } + + @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()); + } + + @Test + public void postLogout_validRequestWithValidOrigin() throws Exception { + OAuthClient.AccessTokenResponse tokenResponse = loginUser(); + String refreshTokenString = tokenResponse.getRefreshToken(); + oauth.origin(VALID_CORS_URL); + + try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.NO_CONTENT)); + assertCors(response); + } + } + + @Test + public void postLogout_validRequestWithInValidOriginShouldFail() throws Exception { + OAuthClient.AccessTokenResponse tokenResponse = loginUser(); + String refreshTokenString = tokenResponse.getRefreshToken(); + oauth.origin(INVALID_CORS_URL); + + try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) { + assertThat(response, Matchers.statusCodeIsHC(Response.Status.NO_CONTENT)); + assertNotCors(response); + } + } + + @Test + public void postLogout_invalidRequestWithValidOrigin() throws Exception { + OAuthClient.AccessTokenResponse tokenResponse = loginUser(); + String refreshTokenString = tokenResponse.getRefreshToken(); + oauth.origin(VALID_CORS_URL); + + // Logout with invalid refresh token + try (CloseableHttpResponse response = oauth.doLogout("invalid-refresh-token", "password")) { + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); + assertCors(response); + } + + // Logout with invalid client secret + try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "invalid-secret")) { + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatusLine().getStatusCode()); + assertCors(response); + } + } + + private OAuthClient.AccessTokenResponse loginUser() { + oauth.doLogin("test-user@localhost", "password"); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + oauth.clientSessionState("client-session"); + return oauth.doAccessTokenRequest(code, "password"); + } + + + private static void assertCors(CloseableHttpResponse response) { + assertEquals("true", response.getFirstHeader("Access-Control-Allow-Credentials").getValue()); + assertEquals(VALID_CORS_URL, response.getFirstHeader("Access-Control-Allow-Origin").getValue()); + assertEquals("Access-Control-Allow-Methods", response.getFirstHeader("Access-Control-Expose-Headers").getValue()); + } + + private static void assertNotCors(CloseableHttpResponse response) { + assertNull(response.getFirstHeader("Access-Control-Allow-Credentials")); + assertNull(response.getFirstHeader("Access-Control-Allow-Origin")); + assertNull(response.getFirstHeader("Access-Control-Expose-Headers")); + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenEndpointCorsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenEndpointCorsTest.java index b1f3acff8f..b0d5c22c30 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenEndpointCorsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenEndpointCorsTest.java @@ -114,6 +114,29 @@ public class TokenEndpointCorsTest extends AbstractKeycloakTest { assertCors(response); } + @Test + public void accessTokenWithConfidentialClientCorsRequest() throws Exception { + oauth.realm("test"); + oauth.clientId("direct-grant"); + oauth.origin(VALID_CORS_URL); + + // Successful token request with correct origin - cors should work + OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password"); + assertEquals(200, response.getStatusCode()); + assertCors(response); + + // Invalid client authentication with correct origin - cors should work + response = oauth.doGrantAccessTokenRequest("invalid", "test-user@localhost", "password"); + assertEquals(401, response.getStatusCode()); + assertCors(response); + + // Successful token request with bad origin - cors should NOT work + oauth.origin(INVALID_CORS_URL); + response = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password"); + assertEquals(200, response.getStatusCode()); + assertNotCors(response); + } + private static void assertCors(OAuthClient.AccessTokenResponse response) { assertEquals("true", response.getHeaders().get("Access-Control-Allow-Credentials")); assertEquals(VALID_CORS_URL, response.getHeaders().get("Access-Control-Allow-Origin")); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java index 7244795656..79cf4a5a80 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java @@ -162,6 +162,28 @@ public class ClientManager { clientResource.update(app); } + public ClientManagerBuilder addWebOrigins(String... webOrigins) { + ClientRepresentation app = clientResource.toRepresentation(); + if (app.getWebOrigins() == null) { + app.setWebOrigins(new LinkedList()); + } + for (String webOrigin : webOrigins) { + app.getWebOrigins().add(webOrigin); + } + clientResource.update(app); + return this; + } + + public void removeWebOrigins(String... webOrigins) { + ClientRepresentation app = clientResource.toRepresentation(); + for (String webOrigin : webOrigins) { + if (app.getWebOrigins() != null) { + app.getWebOrigins().remove(webOrigin); + } + } + clientResource.update(app); + } + // Set valid values of "request_uri" parameter public void setRequestUris(String... requestUris) { ClientRepresentation app = clientResource.toRepresentation();