KEYCLOAK-15719 CORS headers missing on userinfo error response
This commit is contained in:
parent
cb12fed96e
commit
456cdc51f2
2 changed files with 79 additions and 27 deletions
|
@ -44,7 +44,6 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.services.CorsErrorResponseException;
|
import org.keycloak.services.CorsErrorResponseException;
|
||||||
import org.keycloak.services.ErrorResponseException;
|
|
||||||
import org.keycloak.services.Urls;
|
import org.keycloak.services.Urls;
|
||||||
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
||||||
import org.keycloak.services.clientpolicy.UserInfoRequestContext;
|
import org.keycloak.services.clientpolicy.UserInfoRequestContext;
|
||||||
|
@ -89,6 +88,7 @@ public class UserInfoEndpoint {
|
||||||
private final org.keycloak.protocol.oidc.TokenManager tokenManager;
|
private final org.keycloak.protocol.oidc.TokenManager tokenManager;
|
||||||
private final AppAuthManager appAuthManager;
|
private final AppAuthManager appAuthManager;
|
||||||
private final RealmModel realm;
|
private final RealmModel realm;
|
||||||
|
private Cors cors;
|
||||||
|
|
||||||
public UserInfoEndpoint(org.keycloak.protocol.oidc.TokenManager tokenManager, RealmModel realm) {
|
public UserInfoEndpoint(org.keycloak.protocol.oidc.TokenManager tokenManager, RealmModel realm) {
|
||||||
this.realm = realm;
|
this.realm = realm;
|
||||||
|
@ -126,18 +126,20 @@ public class UserInfoEndpoint {
|
||||||
return issueUserInfo(accessToken);
|
return issueUserInfo(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ErrorResponseException newUnauthorizedErrorResponseException(String oauthError, String errorMessage) {
|
// This method won't add allowedOrigins to the cors. Assumption is that allowedOrigins are already set to the "cors" object when this method is called
|
||||||
|
private CorsErrorResponseException newUnauthorizedErrorResponseException(String oauthError, String errorMessage) {
|
||||||
// See: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoError
|
// See: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoError
|
||||||
response.getOutputHeaders().put(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(String.format("Bearer realm=\"%s\", error=\"%s\", error_description=\"%s\"", realm.getName(), oauthError, errorMessage)));
|
response.getOutputHeaders().put(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(String.format("Bearer realm=\"%s\", error=\"%s\", error_description=\"%s\"", realm.getName(), oauthError, errorMessage)));
|
||||||
return new ErrorResponseException(oauthError, errorMessage, Response.Status.UNAUTHORIZED);
|
return new CorsErrorResponseException(cors, oauthError, errorMessage, Response.Status.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response issueUserInfo(String tokenString) {
|
private Response issueUserInfo(String tokenString) {
|
||||||
|
cors = Cors.add(request).auth().allowedMethods(request.getHttpMethod()).auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session.clientPolicy().triggerOnEvent(new UserInfoRequestContext(tokenString));
|
session.clientPolicy().triggerOnEvent(new UserInfoRequestContext(tokenString));
|
||||||
} catch (ClientPolicyException cpe) {
|
} catch (ClientPolicyException cpe) {
|
||||||
throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
|
throw new CorsErrorResponseException(cors.allowAllOrigins(), cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBuilder event = new EventBuilder(realm, session, clientConnection)
|
EventBuilder event = new EventBuilder(realm, session, clientConnection)
|
||||||
|
@ -146,11 +148,11 @@ public class UserInfoEndpoint {
|
||||||
|
|
||||||
if (tokenString == null) {
|
if (tokenString == null) {
|
||||||
event.error(Errors.INVALID_TOKEN);
|
event.error(Errors.INVALID_TOKEN);
|
||||||
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Token not provided", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.INVALID_REQUEST, "Token not provided", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessToken token;
|
AccessToken token;
|
||||||
ClientModel clientModel;
|
ClientModel clientModel = null;
|
||||||
try {
|
try {
|
||||||
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class).withDefaultChecks()
|
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class).withDefaultChecks()
|
||||||
.realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
.realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
||||||
|
@ -163,20 +165,25 @@ public class UserInfoEndpoint {
|
||||||
clientModel = realm.getClientByClientId(token.getIssuedFor());
|
clientModel = realm.getClientByClientId(token.getIssuedFor());
|
||||||
if (clientModel == null) {
|
if (clientModel == null) {
|
||||||
event.error(Errors.CLIENT_NOT_FOUND);
|
event.error(Errors.CLIENT_NOT_FOUND);
|
||||||
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cors.allowedOrigins(session, clientModel);
|
||||||
|
|
||||||
TokenVerifier.createWithoutSignature(token)
|
TokenVerifier.createWithoutSignature(token)
|
||||||
.withChecks(NotBeforeCheck.forModel(clientModel))
|
.withChecks(NotBeforeCheck.forModel(clientModel))
|
||||||
.verify();
|
.verify();
|
||||||
} catch (VerificationException e) {
|
} catch (VerificationException e) {
|
||||||
|
if (clientModel == null) {
|
||||||
|
cors.allowAllOrigins();
|
||||||
|
}
|
||||||
event.error(Errors.INVALID_TOKEN);
|
event.error(Errors.INVALID_TOKEN);
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Token verification failed");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Token verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientModel.getProtocol().equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
if (!clientModel.getProtocol().equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
||||||
event.error(Errors.INVALID_CLIENT);
|
event.error(Errors.INVALID_CLIENT);
|
||||||
throw new ErrorResponseException(Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors, Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.getContext().setClient(clientModel);
|
session.getContext().setClient(clientModel);
|
||||||
|
@ -185,7 +192,7 @@ public class UserInfoEndpoint {
|
||||||
|
|
||||||
if (!clientModel.isEnabled()) {
|
if (!clientModel.isEnabled()) {
|
||||||
event.error(Errors.CLIENT_DISABLED);
|
event.error(Errors.CLIENT_DISABLED);
|
||||||
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client disabled", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client disabled", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
UserSessionModel userSession = findValidSession(token, event, clientModel);
|
UserSessionModel userSession = findValidSession(token, event, clientModel);
|
||||||
|
@ -193,7 +200,7 @@ public class UserInfoEndpoint {
|
||||||
UserModel userModel = userSession.getUser();
|
UserModel userModel = userSession.getUser();
|
||||||
if (userModel == null) {
|
if (userModel == null) {
|
||||||
event.error(Errors.USER_NOT_FOUND);
|
event.error(Errors.USER_NOT_FOUND);
|
||||||
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User not found", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "User not found", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.user(userModel)
|
event.user(userModel)
|
||||||
|
@ -268,7 +275,7 @@ public class UserInfoEndpoint {
|
||||||
|
|
||||||
event.success();
|
event.success();
|
||||||
|
|
||||||
return Cors.add(request, responseBuilder).auth().allowedOrigins(session, clientModel).build();
|
return cors.builder(responseBuilder).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -303,7 +310,7 @@ public class UserInfoEndpoint {
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws ErrorResponseException {
|
private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws CorsErrorResponseException {
|
||||||
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
|
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
|
||||||
event.error(Errors.INVALID_TOKEN);
|
event.error(Errors.INVALID_TOKEN);
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token");
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.keycloak.testsuite.oauth;
|
package org.keycloak.testsuite.oauth;
|
||||||
|
|
||||||
|
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||||
|
@ -30,7 +31,7 @@ public class UserInfoEndpointCorsTest extends AbstractKeycloakTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void userInfoCorsRequestWithValidUrl() throws Exception {
|
public void userInfoCorsValidRequestWithValidUrl() throws Exception {
|
||||||
|
|
||||||
oauth.realm("test");
|
oauth.realm("test");
|
||||||
oauth.clientId("test-app2");
|
oauth.clientId("test-app2");
|
||||||
|
@ -38,7 +39,9 @@ public class UserInfoEndpointCorsTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", "password");
|
OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", "password");
|
||||||
|
|
||||||
WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(AdminClientUtil.createResteasyClient());
|
ResteasyClient resteasyClient = AdminClientUtil.createResteasyClient();
|
||||||
|
try {
|
||||||
|
WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(resteasyClient);
|
||||||
Response userInfoResponse = userInfoTarget.request()
|
Response userInfoResponse = userInfoTarget.request()
|
||||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getAccessToken())
|
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getAccessToken())
|
||||||
.header("Origin", VALID_CORS_URL) // manually trigger CORS handling
|
.header("Origin", VALID_CORS_URL) // manually trigger CORS handling
|
||||||
|
@ -47,10 +50,14 @@ public class UserInfoEndpointCorsTest extends AbstractKeycloakTest {
|
||||||
UserInfoClientUtil.testSuccessfulUserInfoResponse(userInfoResponse, "test-user@localhost", "test-user@localhost");
|
UserInfoClientUtil.testSuccessfulUserInfoResponse(userInfoResponse, "test-user@localhost", "test-user@localhost");
|
||||||
|
|
||||||
assertCors(userInfoResponse);
|
assertCors(userInfoResponse);
|
||||||
|
} finally {
|
||||||
|
resteasyClient.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KEYCLOAK-15719 error response should still contain CORS headers
|
||||||
@Test
|
@Test
|
||||||
public void userInfoCorsRequestWithInvalidUrlShouldFail() throws Exception {
|
public void userInfoCorsInvalidRequestWithValidUrl() throws Exception {
|
||||||
|
|
||||||
oauth.realm("test");
|
oauth.realm("test");
|
||||||
oauth.clientId("test-app2");
|
oauth.clientId("test-app2");
|
||||||
|
@ -58,23 +65,61 @@ public class UserInfoEndpointCorsTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", "password");
|
OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", "password");
|
||||||
|
|
||||||
WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(AdminClientUtil.createResteasyClient());
|
// Set time offset to make sure that userInfo request will be invalid due the expired token
|
||||||
|
setTimeOffset(600);
|
||||||
|
|
||||||
|
ResteasyClient resteasyClient = AdminClientUtil.createResteasyClient();
|
||||||
|
try {
|
||||||
|
WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(resteasyClient);
|
||||||
|
Response userInfoResponse = userInfoTarget.request()
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getAccessToken())
|
||||||
|
.header("Origin", VALID_CORS_URL) // manually trigger CORS handling
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// We should have errorResponse, but CORS headers should be there as origin was valid
|
||||||
|
assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), userInfoResponse.getStatus());
|
||||||
|
|
||||||
|
assertCors(userInfoResponse);
|
||||||
|
} finally {
|
||||||
|
resteasyClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void userInfoCorsValidRequestWithInvalidUrlShouldFail() throws Exception {
|
||||||
|
|
||||||
|
oauth.realm("test");
|
||||||
|
oauth.clientId("test-app2");
|
||||||
|
oauth.redirectUri(VALID_CORS_URL + "/realms/master/app");
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse accessTokenResponse = oauth.doGrantAccessTokenRequest(null, "test-user@localhost", "password");
|
||||||
|
|
||||||
|
ResteasyClient resteasyClient = AdminClientUtil.createResteasyClient();
|
||||||
|
try {
|
||||||
|
WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(resteasyClient);
|
||||||
Response userInfoResponse = userInfoTarget.request()
|
Response userInfoResponse = userInfoTarget.request()
|
||||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getAccessToken())
|
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getAccessToken())
|
||||||
.header("Origin", INVALID_CORS_URL) // manually trigger CORS handling
|
.header("Origin", INVALID_CORS_URL) // manually trigger CORS handling
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
|
UserInfoClientUtil.testSuccessfulUserInfoResponse(userInfoResponse, "test-user@localhost", "test-user@localhost");
|
||||||
|
|
||||||
assertNotCors(userInfoResponse);
|
assertNotCors(userInfoResponse);
|
||||||
|
} finally {
|
||||||
|
resteasyClient.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertCors(Response response) {
|
private static void assertCors(Response response) {
|
||||||
assertEquals("true", response.getHeaders().getFirst("Access-Control-Allow-Credentials"));
|
assertEquals("true", response.getHeaders().getFirst("Access-Control-Allow-Credentials"));
|
||||||
assertEquals(VALID_CORS_URL, response.getHeaders().getFirst("Access-Control-Allow-Origin"));
|
assertEquals(VALID_CORS_URL, response.getHeaders().getFirst("Access-Control-Allow-Origin"));
|
||||||
|
response.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertNotCors(Response response) {
|
private static void assertNotCors(Response response) {
|
||||||
assertNull(response.getHeaders().get("Access-Control-Allow-Credentials"));
|
assertNull(response.getHeaders().get("Access-Control-Allow-Credentials"));
|
||||||
assertNull(response.getHeaders().get("Access-Control-Allow-Origin"));
|
assertNull(response.getHeaders().get("Access-Control-Allow-Origin"));
|
||||||
assertNull(response.getHeaders().get("Access-Control-Expose-Headers"));
|
assertNull(response.getHeaders().get("Access-Control-Expose-Headers"));
|
||||||
|
response.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue