KEYCLOAK-16793 KEYCLOAK-16948 Cors on error responses for logoutEndpoint and tokenEndpoint

This commit is contained in:
mposolda 2021-03-04 11:19:05 +01:00 committed by Marek Posolda
parent d452041d7d
commit 99c1ee7f5a
12 changed files with 232 additions and 21 deletions

View file

@ -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<String, String> 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);
}
}

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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()) {

View file

@ -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");

View file

@ -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) {

View file

@ -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 {

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<RealmRepresentation> 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"));
}
}

View file

@ -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"));

View file

@ -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<String>());
}
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();