KEYCLOAK-16793 KEYCLOAK-16948 Cors on error responses for logoutEndpoint and tokenEndpoint
This commit is contained in:
parent
d452041d7d
commit
99c1ee7f5a
12 changed files with 232 additions and 21 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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"));
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue