From 6596811d5d6ff29bdbecb8c716dedcf783b01668 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Tue, 15 Sep 2020 14:24:38 +0900 Subject: [PATCH] KEYCLOAK-14204 FAPI-RW Client Policy - Executor : Enforce Request Object satisfying high security level --- .../oidc/endpoints/AuthorizationEndpoint.java | 2 +- ...izationEndpointRequestParserProcessor.java | 1 - .../AuthzEndpointRequestObjectParser.java | 3 +- .../request/AuthzEndpointRequestParser.java | 6 +- .../AuthorizationRequestContext.java | 10 +- .../executor/SecureRequestObjectExecutor.java | 185 +++++++++++++++ .../SecureRequestObjectExecutorFactory.java | 65 ++++++ ...ecutor.ClientPolicyExecutorProviderFactory | 3 +- ...stingOIDCEndpointsApplicationResource.java | 219 +++++++++++++++++- .../TestOIDCEndpointsApplicationResource.java | 5 + .../client/ClientPolicyBasicsTest.java | 173 +++++++++++++- 11 files changed, 662 insertions(+), 10 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutorFactory.java diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index e67a5a9926..2f82266312 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -158,7 +158,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { } try { - session.clientPolicy().triggerOnEvent(new AuthorizationRequestContext(parsedResponseType, request, redirectUri)); + session.clientPolicy().triggerOnEvent(new AuthorizationRequestContext(parsedResponseType, request, redirectUri, params)); } catch (ClientPolicyException cpe) { return redirectErrorToClient(parsedResponseMode, cpe.getError(), cpe.getErrorDetail()); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java index 66441b066e..7f5befabba 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java @@ -77,7 +77,6 @@ public class AuthorizationEndpointRequestParserProcessor { } else if (requestUriParam != null) { try (InputStream is = session.getProvider(HttpClientProvider.class).get(requestUriParam)) { String retrievedRequest = StreamUtil.readString(is); - new AuthzEndpointRequestObjectParser(session, retrievedRequest, client).parseRequest(request); } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestObjectParser.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestObjectParser.java index 8b0ce56bdd..cdb0d11f1c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestObjectParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestObjectParser.java @@ -57,9 +57,10 @@ class AuthzEndpointRequestObjectParser extends AuthzEndpointRequestParser { } else { this.requestParams = session.tokens().decodeClientJWT(requestObject, client, JsonNode.class); if (this.requestParams == null) { - throw new RuntimeException("Failed to verify signature on 'request' object"); + throw new RuntimeException("Failed to verify signature on 'request' object"); } } + session.setAttribute(AuthzEndpointRequestParser.AUTHZ_REQUEST_OBJECT, requestParams); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java index 92b61e7c08..0ae53658e5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthzEndpointRequestParser.java @@ -30,7 +30,7 @@ import java.util.Set; /** * @author Marek Posolda */ -abstract class AuthzEndpointRequestParser { +public abstract class AuthzEndpointRequestParser { private static final Logger logger = Logger.getLogger(AuthzEndpointRequestParser.class); @@ -46,8 +46,10 @@ abstract class AuthzEndpointRequestParser { */ public static final int ADDITIONAL_REQ_PARAMS_MAX_SIZE = 200; + public static final String AUTHZ_REQUEST_OBJECT = "ParsedRequestObject"; + /** Set of known protocol GET params not to be stored into additionalReqParams} */ - private static final Set KNOWN_REQ_PARAMS = new HashSet<>(); + public static final Set KNOWN_REQ_PARAMS = new HashSet<>(); static { KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.CLIENT_ID_PARAM); KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/AuthorizationRequestContext.java b/services/src/main/java/org/keycloak/services/clientpolicy/AuthorizationRequestContext.java index 8986515857..75e46d3647 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/AuthorizationRequestContext.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/AuthorizationRequestContext.java @@ -17,6 +17,8 @@ package org.keycloak.services.clientpolicy; +import javax.ws.rs.core.MultivaluedMap; + import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.services.clientpolicy.ClientPolicyContext; @@ -27,13 +29,16 @@ public class AuthorizationRequestContext implements ClientPolicyContext { private final OIDCResponseType parsedResponseType; private final AuthorizationEndpointRequest request; private final String redirectUri; + private final MultivaluedMap requestParameters; public AuthorizationRequestContext(OIDCResponseType parsedResponseType, AuthorizationEndpointRequest request, - String redirectUri) { + String redirectUri, + MultivaluedMap requestParameters) { this.parsedResponseType = parsedResponseType; this.request = request; this.redirectUri = redirectUri; + this.requestParameters = requestParameters; } @Override @@ -53,4 +58,7 @@ public class AuthorizationRequestContext implements ClientPolicyContext { return redirectUri; } + public MultivaluedMap getRequestParameters() { + return requestParameters; + } } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutor.java new file mode 100644 index 0000000000..e6360d4530 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutor.java @@ -0,0 +1,185 @@ +/* + * 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.services.clientpolicy.executor; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.ws.rs.core.MultivaluedMap; + +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.util.StreamUtil; +import org.keycloak.common.util.Time; +import org.keycloak.component.ComponentModel; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.constants.AdapterConstants; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; +import org.keycloak.protocol.oidc.endpoints.request.AuthzEndpointRequestParser; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.Urls; +import org.keycloak.services.clientpolicy.AuthorizationRequestContext; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.ClientPolicyLogger; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; + +public class SecureRequestObjectExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(SecureRequestObjectExecutor.class); + + private final KeycloakSession session; + private final ComponentModel componentModel; + + public static final String INVALID_REQUEST_OBJECT = "invalid_request_object"; + + public SecureRequestObjectExecutor(KeycloakSession session, ComponentModel componentModel) { + this.session = session; + this.componentModel = componentModel; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case AUTHORIZATION_REQUEST: + AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext)context; + executeOnAuthorizationRequest(authorizationRequestContext.getparsedResponseType(), + authorizationRequestContext.getAuthorizationEndpointRequest(), + authorizationRequestContext.getRedirectUri(), + authorizationRequestContext.getRequestParameters()); + break; + default: + return; + } + } + + private void executeOnAuthorizationRequest( + OIDCResponseType parsedResponseType, + AuthorizationEndpointRequest request, + String redirectUri, + MultivaluedMap params) throws ClientPolicyException { + ClientPolicyLogger.log(logger, "Authz Endpoint - authz request"); + + if (params == null) { + ClientPolicyLogger.log(logger, "request parameter not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameters"); + } + + String requestParam = params.getFirst(OIDCLoginProtocol.REQUEST_PARAM); + String requestUriParam = params.getFirst(OIDCLoginProtocol.REQUEST_URI_PARAM); + + // check whether whether request object exists + if (requestParam == null && requestUriParam == null) { + ClientPolicyLogger.log(logger, "request object not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid parameter"); + } + + JsonNode requestObject = (JsonNode)session.getAttribute(AuthzEndpointRequestParser.AUTHZ_REQUEST_OBJECT); + + // check whether request object exists + if (requestObject == null || requestObject.isEmpty()) { + ClientPolicyLogger.log(logger, "request object not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid parameter"); + } + + // check whether scope exists in both query parameter and request object + if (params.getFirst(OIDCLoginProtocol.SCOPE_PARAM) == null || requestObject.get(OIDCLoginProtocol.SCOPE_PARAM) == null) { + ClientPolicyLogger.log(logger, "scope does not exists."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter : scope"); + } + + // check whether "exp" claim exists + if (requestObject.get("exp") == null) { + ClientPolicyLogger.log(logger, "exp claim not incuded."); + throw new ClientPolicyException(INVALID_REQUEST_OBJECT, "Missing parameter : exp"); + } + + // check whether request object not expired + long exp = requestObject.get("exp").asLong(); + if (Time.currentTime() > exp) { // TODO: Time.currentTime() is int while exp is long... + ClientPolicyLogger.log(logger, "request object expired."); + throw new ClientPolicyException(INVALID_REQUEST_OBJECT, "Request Expired"); + } + + // check whether "aud" claim exists + List aud = new ArrayList(); + JsonNode audience = requestObject.get("aud"); + if (audience == null) { + ClientPolicyLogger.log(logger, "aud claim not incuded."); + throw new ClientPolicyException(INVALID_REQUEST_OBJECT, "Missing parameter : aud"); + } + if (audience.isArray()) { + for (JsonNode node : audience) aud.add(node.asText()); + } else { + aud.add(audience.asText()); + } + if (aud.isEmpty()) { + ClientPolicyLogger.log(logger, "aud claim not incuded."); + throw new ClientPolicyException(INVALID_REQUEST_OBJECT, "Missing parameter : aud"); + } + + // check whether "aud" claim points to this keycloak as authz server + String iss = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), session.getContext().getRealm().getName()); + if (!aud.contains(iss)) { + ClientPolicyLogger.log(logger, "aud not points to the intended realm."); + throw new ClientPolicyException(INVALID_REQUEST_OBJECT, "Invalid parameter : aud"); + } + + // confirm whether all parameters in query string are included in the request object, and have the same values + // argument "request" are parameters overridden by parameters in request object + if (AuthzEndpointRequestParser.KNOWN_REQ_PARAMS.stream().filter(s->params.containsKey(s)).anyMatch(s->!isSameParameterIncluded(s, params.getFirst(s), requestObject))) { + ClientPolicyLogger.log(logger, "not all parameters in query string are included in the request object, and have the same values."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid parameter"); + } + + ClientPolicyLogger.log(logger, "Passed."); + } + + private boolean isSameParameterIncluded(String param, String value, JsonNode requestObject) { + if (param.equals(OIDCLoginProtocol.REQUEST_PARAM) || param.equals(OIDCLoginProtocol.REQUEST_URI_PARAM)) return true; + if (requestObject.hasNonNull(param)) return requestObject.get(param).asText().equals(value); + return false; + } + + @Override + public String getName() { + return componentModel.getName(); + } + + @Override + public String getProviderId() { + return componentModel.getProviderId(); + } + +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutorFactory.java new file mode 100644 index 0000000000..616ef46aaf --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRequestObjectExecutorFactory.java @@ -0,0 +1,65 @@ +/* + * 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.services.clientpolicy.executor; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config.Scope; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class SecureRequestObjectExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "secure-reqobj-executor"; + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session, ComponentModel model) { + return new SecureRequestObjectExecutor(session, model); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "The executor checks whether the client treats the request object in its authorization request by following Financial-grade API Security Profile : Read and Write API Security Profile."; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index c2e6d38b25..58147e7d4b 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -1 +1,2 @@ -org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory \ No newline at end of file +org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory +org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java index 1049fb6597..b3cd9888fd 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java @@ -21,8 +21,10 @@ import org.jboss.resteasy.annotations.cache.NoCache; import javax.ws.rs.BadRequestException; import org.keycloak.OAuth2Constants; import org.keycloak.common.util.Base64; +import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.PemUtils; +import org.keycloak.constants.AdapterConstants; import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.AsymmetricSignatureSignerContext; import org.keycloak.crypto.KeyType; @@ -35,8 +37,13 @@ import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.models.Constants; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.JsonWebToken; import org.keycloak.testsuite.rest.TestApplicationResourceProviderFactory; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.annotation.JsonProperty; import javax.ws.rs.GET; import javax.ws.rs.Path; @@ -44,6 +51,7 @@ import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; +import java.io.IOException; import java.security.InvalidAlgorithmParameterException; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -203,6 +211,26 @@ public class TestingOIDCEndpointsApplicationResource { oidcRequest.put(OIDCLoginProtocol.MAX_AGE_PARAM, Integer.parseInt(maxAge)); } + setOidcRequest(oidcRequest, jwaAlgorithm); + } + + @GET + @Path("/register-oidc-request") + @Produces(org.keycloak.utils.MediaType.APPLICATION_JWT) + @NoCache + public void registerOIDCRequest(@QueryParam("requestObject") String encodedRequestObject, @QueryParam("jwaAlgorithm") String jwaAlgorithm) { + byte[] serializedRequestObject = Base64Url.decode(encodedRequestObject); + AuthorizationEndpointRequestObject oidcRequest = null; + try { + oidcRequest = JsonSerialization.readValue(serializedRequestObject, AuthorizationEndpointRequestObject.class); + } catch (IOException e) { + throw new BadRequestException("deserialize request object failed : " + e.getMessage()); + } + + setOidcRequest(oidcRequest, jwaAlgorithm); + } + + private void setOidcRequest(Object oidcRequest, String jwaAlgorithm) { if (!isSupportedAlgorithm(jwaAlgorithm)) throw new BadRequestException("Unknown argument: " + jwaAlgorithm); if ("none".equals(jwaAlgorithm)) { @@ -252,7 +280,6 @@ public class TestingOIDCEndpointsApplicationResource { return ret; } - @GET @Path("/get-oidc-request") @Produces(org.keycloak.utils.MediaType.APPLICATION_JWT) @@ -275,4 +302,194 @@ public class TestingOIDCEndpointsApplicationResource { public List getSectorIdentifierRedirectUris() { return clientData.getSectorIdentifierRedirectUris(); } + + public static class AuthorizationEndpointRequestObject extends JsonWebToken { + + @JsonProperty(OIDCLoginProtocol.CLIENT_ID_PARAM) + String clientId; + + @JsonProperty(OIDCLoginProtocol.RESPONSE_TYPE_PARAM) + String responseType; + + @JsonProperty(OIDCLoginProtocol.RESPONSE_MODE_PARAM) + String responseMode; + + @JsonProperty(OIDCLoginProtocol.REDIRECT_URI_PARAM) + String redirectUriParam; + + @JsonProperty(OIDCLoginProtocol.STATE_PARAM) + String state; + + @JsonProperty(OIDCLoginProtocol.SCOPE_PARAM) + String scope; + + @JsonProperty(OIDCLoginProtocol.LOGIN_HINT_PARAM) + String loginHint; + + @JsonProperty(OIDCLoginProtocol.PROMPT_PARAM) + String prompt; + + @JsonProperty(OIDCLoginProtocol.NONCE_PARAM) + String nonce; + + Integer max_age; + + @JsonProperty(OIDCLoginProtocol.UI_LOCALES_PARAM) + String uiLocales; + + @JsonProperty(OIDCLoginProtocol.ACR_PARAM) + String acr; + + @JsonProperty(OAuth2Constants.DISPLAY) + String display; + + @JsonProperty(OIDCLoginProtocol.CODE_CHALLENGE_PARAM) + String codeChallenge; + + @JsonProperty(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM) + String codeChallengeMethod; + + @JsonProperty(AdapterConstants.KC_IDP_HINT) + String idpHint; + + @JsonProperty(Constants.KC_ACTION) + String action; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getResponseType() { + return responseType; + } + + public void setResponseType(String responseType) { + this.responseType = responseType; + } + + public String getResponseMode() { + return responseMode; + } + + public void setResponseMode(String responseMode) { + this.responseMode = responseMode; + } + + public String getRedirectUriParam() { + return redirectUriParam; + } + + public void setRedirectUriParam(String redirectUriParam) { + this.redirectUriParam = redirectUriParam; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getLoginHint() { + return loginHint; + } + + public void setLoginHint(String loginHint) { + this.loginHint = loginHint; + } + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public String getNonce() { + return nonce; + } + + public void getNonce(String nonce) { + this.nonce = nonce; + } + + public Integer getMax_age() { + return max_age; + } + + public void setMax_age(Integer max_age) { + this.max_age = max_age; + } + + public String getUiLocales() { + return uiLocales; + } + + public void setUiLocales(String uiLocales) { + this.uiLocales = uiLocales; + } + + public String getAcr() { + return acr; + } + + public void setAcr(String acr) { + this.acr = acr; + } + + public String getCodeChallenge() { + return codeChallenge; + } + + public void setCodeChallenge(String codeChallenge) { + this.codeChallenge = codeChallenge; + } + + public String getCodeChallengeMethod() { + return codeChallengeMethod; + } + + public void setCodeChallengeMethod(String codeChallengeMethod) { + this.codeChallengeMethod = codeChallengeMethod; + } + + public String getDisplay() { + return display; + } + + public void setDisplay(String display) { + this.display = display; + } + + public String getIdpHint() { + return idpHint; + } + + public void setIdpHint(String idpHint) { + this.idpHint = idpHint; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java index abca35c1a9..e0f4dcbfa2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java @@ -60,6 +60,11 @@ public interface TestOIDCEndpointsApplicationResource { @QueryParam("redirectUri") String redirectUri, @QueryParam("maxAge") String maxAge, @QueryParam("jwaAlgorithm") String jwaAlgorithm); + @GET + @Path("/register-oidc-request") + @Produces(org.keycloak.utils.MediaType.APPLICATION_JWT) + void registerOIDCRequest(@QueryParam("requestObject") String encodedRequestObject, @QueryParam("jwaAlgorithm") String jwaAlgorithm); + @GET @Path("/get-oidc-request") @Produces(org.keycloak.utils.MediaType.APPLICATION_JWT) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPolicyBasicsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPolicyBasicsTest.java index 90b8a47979..1a67800340 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPolicyBasicsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPolicyBasicsTest.java @@ -23,6 +23,8 @@ import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Arrays; @@ -53,9 +55,12 @@ import org.keycloak.client.registration.ClientRegistrationException; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.Time; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; +import org.keycloak.jose.jws.Algorithm; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.OIDCResponseType; @@ -69,6 +74,7 @@ import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.services.Urls; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.ClientPolicyProvider; import org.keycloak.services.clientpolicy.DefaultClientPolicyProviderFactory; @@ -76,11 +82,16 @@ import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvide import org.keycloak.services.clientpolicy.condition.ClientUpdateContextConditionFactory; import org.keycloak.services.clientpolicy.condition.ClientRolesConditionFactory; import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; +import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; +import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutorFactory; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutorFactory; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls; +import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; +import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource.AuthorizationEndpointRequestObject; import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExeptionConditionFactory; import org.keycloak.testsuite.services.clientpolicy.executor.TestClientAuthenticationExecutorFactory; import org.keycloak.testsuite.services.clientpolicy.executor.TestPKCEEnforceExecutorFactory; @@ -90,6 +101,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.keycloak.testsuite.util.ServerURLs; +import org.keycloak.util.JsonSerialization; @EnableFeature(value = Profile.Feature.CLIENT_POLICIES, skipRestart = true) public class ClientPolicyBasicsTest extends AbstractKeycloakTest { @@ -107,8 +119,9 @@ public class ClientPolicyBasicsTest extends AbstractKeycloakTest { static final String CLIENTROLES_CONDITION_BETA_NAME = "ClientRolesCondition-beta"; static final String SECURERESPONSETYPE_EXECUTOR_NAME = "SecureResponseTypeExecutor"; - - + + static final String SECUREREQUESTOBJECT_EXECUTOR_NAME = "SecureRequestObjectExecutor"; + ClientRegistration reg; @Rule @@ -656,6 +669,162 @@ public class ClientPolicyBasicsTest extends AbstractKeycloakTest { } } + @Test + public void testSecureRequestObjectExecutor() throws ClientRegistrationException, ClientPolicyException, URISyntaxException, IOException { + String policyName = "MyPolicy"; + createPolicy(policyName, DefaultClientPolicyProviderFactory.PROVIDER_ID, null, null, null); + logger.info("... Created Policy : " + policyName); + + createCondition(CLIENTROLES_CONDITION_NAME, ClientRolesConditionFactory.PROVIDER_ID, null, (ComponentRepresentation provider) -> { + setConditionClientRoles(provider, new ArrayList<>(Arrays.asList("sample-client-role"))); + }); + registerCondition(CLIENTROLES_CONDITION_NAME, policyName); + logger.info("... Registered Condition : " + CLIENTROLES_CONDITION_NAME); + + createExecutor(SECUREREQUESTOBJECT_EXECUTOR_NAME, SecureRequestObjectExecutorFactory.PROVIDER_ID, null, (ComponentRepresentation provider) -> { + }); + registerExecutor(SECUREREQUESTOBJECT_EXECUTOR_NAME, policyName); + logger.info("... Registered Executor : " + SECUREREQUESTOBJECT_EXECUTOR_NAME); + + String clientId = "Zahlungs-App"; + String clientSecret = "secret"; + String cid = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> { + String[] defaultRoles = {"sample-client-role"}; + clientRep.setDefaultRoles(defaultRoles); + clientRep.setSecret(clientSecret); + }); + + try { + oauth.clientId(clientId); + AuthorizationEndpointRequestObject requestObject; + + // check whether whether request object exists + oauth.request(null); + oauth.requestUri(null); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Invalid parameter", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // check whether request_uri is https scheme + // cannot test because existing AuthorizationEndpoint check and return error before executing client policy + + // check whether request object can be retrieved from request_uri + // cannot test because existing AuthorizationEndpoint check and return error before executing client policy + + // check whether request object can be parsed successfully + // cannot test because existing AuthorizationEndpoint check and return error before executing client policy + + // check whether scope exists in both query parameter and request object + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.setScope(null); + registerRequestObject(requestObject, clientId, Algorithm.ES256, true); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Missing parameter : scope", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // check whether "exp" claim exists + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.exp(null); + registerRequestObject(requestObject, clientId, Algorithm.ES256, false); + oauth.openLoginForm(); + assertEquals(SecureRequestObjectExecutor.INVALID_REQUEST_OBJECT, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Missing parameter : exp", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // check whether request object not expired + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.exp(Long.valueOf(0)); + registerRequestObject(requestObject, clientId, Algorithm.ES256, true); + oauth.openLoginForm(); + assertEquals(SecureRequestObjectExecutor.INVALID_REQUEST_OBJECT, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Request Expired", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // check whether "aud" claim exists + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.audience((String)null); + registerRequestObject(requestObject, clientId, Algorithm.ES256, false); + oauth.openLoginForm(); + assertEquals(SecureRequestObjectExecutor.INVALID_REQUEST_OBJECT, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Missing parameter : aud", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // check whether "aud" claim points to this keycloak as authz server + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.audience(suiteContext.getAuthServerInfo().getContextRoot().toString()); + registerRequestObject(requestObject, clientId, Algorithm.ES256, true); + oauth.openLoginForm(); + assertEquals(SecureRequestObjectExecutor.INVALID_REQUEST_OBJECT, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Invalid parameter : aud", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // confirm whether all parameters in query string are included in the request object, and have the same values + // argument "request" are parameters overridden by parameters in request object + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + requestObject.setState("notmatchstate"); + registerRequestObject(requestObject, clientId, Algorithm.ES256, false); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Invalid parameter", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // valid request object + requestObject = createValidRequestObjectForSecureRequestObjectExecutor(clientId); + registerRequestObject(requestObject, clientId, Algorithm.ES256, true); + + successfulLoginAndLogout(clientId, clientSecret); + } finally { + deleteClientByAdmin(cid); + } + + } + + private AuthorizationEndpointRequestObject createValidRequestObjectForSecureRequestObjectExecutor(String clientId) throws URISyntaxException { + AuthorizationEndpointRequestObject requestObject = new AuthorizationEndpointRequestObject(); + requestObject.id(KeycloakModelUtils.generateId()); + requestObject.iat(Long.valueOf(Time.currentTime())); + requestObject.exp(requestObject.getIat() + Long.valueOf(300)); + requestObject.nbf(Long.valueOf(0)); + requestObject.setClientId(clientId); + requestObject.setResponseType("code"); + requestObject.setRedirectUriParam(oauth.getRedirectUri()); + requestObject.setScope("openid"); + String scope = KeycloakModelUtils.generateId(); + oauth.stateParamHardcoded(scope); + requestObject.setState(scope); + requestObject.setMax_age(Integer.valueOf(600)); + requestObject.setOtherClaims("custom_claim_ein", "rot"); + requestObject.audience(Urls.realmIssuer(new URI(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth"), REALM_NAME), "https://example.com"); + return requestObject; + } + + private void registerRequestObject(AuthorizationEndpointRequestObject requestObject, String clientId, Algorithm sigAlg, boolean isUseRequestUri) throws URISyntaxException, IOException { + TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); + + // Set required signature for request_uri + // use and set jwks_url + ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId); + ClientRepresentation clientRep = clientResource.toRepresentation(); + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setRequestObjectSignatureAlg(sigAlg); + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUseJwksUrl(true); + String jwksUrl = TestApplicationResourceUrls.clientJwksUri(); + OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setJwksUrl(jwksUrl); + clientResource.update(clientRep); + + oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); + + // generate and register client keypair + oidcClientEndpointsResource.generateKeys(sigAlg.name()); + + // register request object + byte[] contentBytes = JsonSerialization.writeValueAsBytes(requestObject); + String encodedRequestObject = Base64Url.encode(contentBytes); + oidcClientEndpointsResource.registerOIDCRequest(encodedRequestObject, sigAlg.name()); + + if (isUseRequestUri) { + oauth.request(null); + oauth.requestUri(TestApplicationResourceUrls.clientRequestUri()); + } else { + oauth.requestUri(null); + oauth.request(oidcClientEndpointsResource.getOIDCRequest()); + } + } + private void setupPolicyAcceptableAuthType(String policyName) { createPolicy(policyName, DefaultClientPolicyProviderFactory.PROVIDER_ID, null, null, null);