diff --git a/docs/documentation/server_admin/topics/clients/client-policies.adoc b/docs/documentation/server_admin/topics/clients/client-policies.adoc index 5ec2643d58..0624aa350a 100644 --- a/docs/documentation/server_admin/topics/clients/client-policies.adoc +++ b/docs/documentation/server_admin/topics/clients/client-policies.adoc @@ -131,6 +131,7 @@ One of several purposes for this executor is to realize the security requirement * Enforce <<_dpop-bound-tokens,DPoP-binding tokens>> is used (available when `dpop` feature is enabled) * Enforce <<_using_lightweight_access_token, using lightweight access token>> * Enforce that <<_refresh_token_rotation,refresh token rotation>> is skipped and there is no refresh token returned from the refresh token response +* Enforce a valid redirect URI that the OAuth 2.1 specification requires [[_client_policy_profile]] === Profile diff --git a/docs/documentation/server_admin/topics/threat/open-redirect.adoc b/docs/documentation/server_admin/topics/threat/open-redirect.adoc index a741714607..f37f95a79f 100644 --- a/docs/documentation/server_admin/topics/threat/open-redirect.adoc +++ b/docs/documentation/server_admin/topics/threat/open-redirect.adoc @@ -6,3 +6,5 @@ An open redirector is an endpoint using a parameter to automatically redirect a {project_name} requires that all registered applications and clients register at least one redirection URI pattern. When a client requests that {project_name} performs a redirect, {project_name} checks the redirect URI against the list of valid registered URI patterns. Clients and applications must register as specific a URI pattern as possible to mitigate open redirector attacks. If an application requires a non http(s) custom scheme, it should be an explicit part of the validation pattern (for example `custom:/app/\*`). For security reasons a general pattern like `*` does not cover non http(s) schemes. + +By using <<_client_policies, Client Policies>>, an administrator can make sure that clients cannot register open redirect URLs such as `*`. diff --git a/docs/documentation/server_admin/topics/threat/redirect.adoc b/docs/documentation/server_admin/topics/threat/redirect.adoc index eeca92d712..1a65d74b86 100644 --- a/docs/documentation/server_admin/topics/threat/redirect.adoc +++ b/docs/documentation/server_admin/topics/threat/redirect.adoc @@ -3,3 +3,5 @@ === Unspecific redirect URIs Make your registered redirect URIs as specific as feasible. Registering vague redirect URIs for xref:con-oidc-auth-flows_{context}[Authorization Code Flows] can allow malicious clients to impersonate another client with broader access. Impersonation can happen if two clients live under the same domain, for example. + +You can use secure redirect uris enforcer executor for your realm. The result makes sure that client administrators are able to register only clients with specific redirect-uris matching various requirements such as requiring that a URL cannot have wildcards in the context path or can be limited to specified permitted domains. See <<_client_policies, Client Policies>> for details about how to configure client policies with a specific executor. \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutor.java new file mode 100644 index 0000000000..c96118ab37 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutor.java @@ -0,0 +1,512 @@ +/* + * Copyright 2024 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.net.*; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.function.Predicate; + +import com.google.common.net.InetAddresses; +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.AdminClientRegisterContext; +import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext; +import org.keycloak.services.clientpolicy.context.ClientCRUDContext; +import org.keycloak.services.clientpolicy.context.DynamicClientRegisterContext; +import org.keycloak.services.clientpolicy.context.DynamicClientUpdateContext; +import org.keycloak.services.clientpolicy.context.PreAuthorizationRequestContext; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.UriType; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SecureRedirectUrisEnforcerExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(SecureRedirectUrisEnforcerExecutor.class); + + private final KeycloakSession session; + private Configuration configuration; + + public static final String ERR_GENERAL = "Invalid Redirect Uri: invalid uri"; + + public static final String ERR_LOOPBACK = "Invalid Redirect Uri: invalid loopback address"; + public static final String ERR_PRIVATESCHEME = "Invalid Redirect Uri: invalid private use scheme"; + public static final String ERR_NORMALURI = "Invalid Redirect Uri: invalid uri"; + + public SecureRedirectUrisEnforcerExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public String getProviderId() { + return SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID; + } + + @Override + public void setupConfiguration(Configuration config) { + this.configuration = config; + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_PRIVATE_USE_URI_SCHEME) + protected boolean allowPrivateUseUriScheme; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_IPV4_LOOPBACK_ADDRESS) + protected boolean allowIPv4LoopbackAddress; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_IPV6_LOOPBACK_ADDRESS) + protected boolean allowIPv6LoopbackAddress; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_HTTP_SCHEME) + protected boolean allowHttpScheme; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_WILDCARD_CONTEXT_PATH) + protected boolean allowWildcardContextPath; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_PERMITTED_DOMAINS) + protected List allowPermittedDomains = Collections.emptyList(); + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.OAUTH_2_1_COMPLIANT) + protected boolean oauth2_1complient; + @JsonProperty(SecureRedirectUrisEnforcerExecutorFactory.ALLOW_OPEN_REDIRECT) + protected boolean allowOpenRedirect; + + public boolean isAllowPrivateUseUriScheme() { + return allowPrivateUseUriScheme; + } + + public void setAllowPrivateUseUriScheme(boolean allowPrivateUseUriScheme) { + this.allowPrivateUseUriScheme = allowPrivateUseUriScheme; + } + + public boolean isAllowIPv4LoopbackAddress() { + return allowIPv4LoopbackAddress; + } + + public void setAllowIPv4LoopbackAddress(boolean allowIPv4LoopbackAddress) { + this.allowIPv4LoopbackAddress = allowIPv4LoopbackAddress; + } + + public boolean isAllowIPv6LoopbackAddress() { + return allowIPv6LoopbackAddress; + } + + public void setAllowIPv6LoopbackAddress(boolean allowIPv6LoopbackAddress) { + this.allowIPv6LoopbackAddress = allowIPv6LoopbackAddress; + } + + public boolean isAllowHttpScheme() { + return allowHttpScheme; + } + + public void setAllowHttpScheme(boolean allowHttpScheme) { + this.allowHttpScheme = allowHttpScheme; + } + + public boolean isAllowWildcardContextPath() { + return allowWildcardContextPath; + } + + public void setAllowWildcardContextPath(boolean allowWildcardContextPath) { + this.allowWildcardContextPath = allowWildcardContextPath; + } + + public List getAllowPermittedDomains() { + return allowPermittedDomains; + } + + public void setAllowPermittedDomains(List permittedDomains) { + this.allowPermittedDomains = permittedDomains; + } + + public boolean isOAuth2_1Compliant() { + return oauth2_1complient; + } + + public void setOAuth2_1Compliant(boolean oauth21complient) { + this.oauth2_1complient = oauth21complient; + } + + public boolean isAllowOpenRedirect() { + return allowOpenRedirect; + } + + public void setAllowOpenRedirect(boolean allowOpenRedirect) { + this.allowOpenRedirect = allowOpenRedirect; + } + } + + public Configuration getConfiguration() { + return configuration; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case REGISTER: + if (context instanceof AdminClientRegisterContext || context instanceof DynamicClientRegisterContext) { + List redirectUris = ((ClientCRUDContext)context).getProposedClientRepresentation().getRedirectUris(); + if (redirectUris == null || redirectUris.isEmpty() || ((ClientCRUDContext)context).getAuthenticatedClient() == null) { + throw invalidRedirectUri(ERR_GENERAL); + } + verifyRedirectUris(((ClientCRUDContext)context).getAuthenticatedClient().getRootUrl(), redirectUris); + } else { + throw invalidRedirectUri(ERR_GENERAL); + } + return; + case UPDATE: + if (context instanceof AdminClientUpdateContext || context instanceof DynamicClientUpdateContext) { + List redirectUris = ((ClientCRUDContext)context).getProposedClientRepresentation().getRedirectUris(); + if (redirectUris == null || redirectUris.isEmpty() || ((ClientCRUDContext)context).getAuthenticatedClient() == null) { + return; + } + verifyRedirectUris(((ClientCRUDContext)context).getAuthenticatedClient().getRootUrl(), redirectUris); + } else { + throw invalidRedirectUri(ERR_GENERAL); + } + return; + case PRE_AUTHORIZATION_REQUEST: + String redirectUriParam = ((PreAuthorizationRequestContext)context).getRequestParameters() + .getFirst(OAuth2Constants.REDIRECT_URI); + String clientId = ((PreAuthorizationRequestContext)context).getClientId(); + ClientModel client = session.getContext().getRealm().getClientByClientId(clientId); + if (client == null) { + throw invalidRedirectUri("Invalid parameter: clientId"); + } + verifyRedirectUri(redirectUriParam, true); + return; + default: + } + } + + void verifyRedirectUris(String rootUri, List redirectUris) throws ClientPolicyException { + // open redirect allows any value for redirect uri + if (configuration.isAllowOpenRedirect()) { + return; + } + + for (String uri : RedirectUtils.resolveValidRedirects(session, rootUri, new HashSet<>(redirectUris))) { + verifyRedirectUri(uri, false); + } + } + + void verifyRedirectUri(String redirectUri, boolean isRedirectUriParam) throws ClientPolicyException { + UriValidation validation; + + try { + validation = new UriValidation(redirectUri, isRedirectUriParam, configuration); + } catch (URISyntaxException e) { + logger.debugv("URISyntaxException - input = {0}, errMessage = {1], errReason = {2}, redirectUri = {3}", e.getInput(), e.getMessage(), e.getReason(), redirectUri); + throw invalidRedirectUri(ERR_GENERAL); + } + + validation.validate(); + } + + public static class UriValidation { + public final URI uri; + public final boolean isRedirectUriParameter; + public final Configuration config; + + public UriValidation(String uriString, boolean isRedirectUriParameter, Configuration config) throws URISyntaxException { + this.uri = new URI(uriString); + this.isRedirectUriParameter = isRedirectUriParameter; + this.config = config; + } + + public void validate() throws ClientPolicyException { + switch (identifyUriType()) { + case IPV4_LOOPBACK_ADDRESS: + if(!config.isAllowIPv4LoopbackAddress() || !isValidIPv4LoopbackAddress()) { + throw invalidRedirectUri(ERR_LOOPBACK); + } + break; + case IPV6_LOOPBACK_ADDRESS: + if(!config.isAllowIPv6LoopbackAddress() || !isValidIPv6LoopbackAddress()) { + throw invalidRedirectUri(ERR_LOOPBACK); + } + break; + case PRIVATE_USE_URI_SCHEME: + if(!config.isAllowPrivateUseUriScheme() || !isValidPrivateUseUriScheme()) { + throw invalidRedirectUri(ERR_PRIVATESCHEME); + } + break; + case NORMAL_URI: + if(!isValidNormalUri()) { + throw invalidRedirectUri(ERR_NORMALURI); + } + break; + default : + logger.debugv("Invalid URI Type - input = {0}", uri.toString()); + throw invalidRedirectUri(ERR_GENERAL); + } + } + + UriType identifyUriType() { + // NOTE: the order of evaluation methods is important. + if (isIPv4LoopbackAddress()) { + return UriType.IPV4_LOOPBACK_ADDRESS; + } else if (isIPv6LoopbackAddress()) { + return UriType.IPV6_LOOPBACK_ADDRESS; + } else if (isPrivateUseScheme()) { + return UriType.PRIVATE_USE_URI_SCHEME; + } else if (isNormalUri()) { + return UriType.NORMAL_URI; + } else { + return UriType.INVALID_URI; + } + } + + boolean isIPv4LoopbackAddress() { + return isLoopbackAddressRedirectUri(i->i instanceof Inet4Address); + } + + boolean isIPv6LoopbackAddress() { + return isLoopbackAddressRedirectUri(i->i instanceof Inet6Address); + } + + boolean isLoopbackAddressRedirectUri(Predicate p) { + if (uri.getHost() == null) { + return false; + } + + InetAddress addr; + try { + addr = InetAddresses.forUriString(uri.getHost()); + } catch (IllegalArgumentException e) { + return false; + } + + if (!addr.isLoopbackAddress()) { + return false; + } + + if (!p.test(addr)) return false; + + return true; + } + + boolean isPrivateUseScheme() { + // NOTE: this method assumes that the uri is not loopback address + return uri.isAbsolute() && !isHttp() && !isHttps(); + } + + boolean isNormalUri() { + // NOTE: this method assumes that the uri is not loopback address + return isHttp() || isHttps(); + } + + boolean isValidIPv4LoopbackAddress() { + return isValidLoopbackAddress(i->true); + } + + boolean isValidIPv6LoopbackAddress() { + return isValidLoopbackAddress(i->{ + if (!"[::1]".equals(i.getHost())) { // [::1] is only allowed. + logger.debugv("Invalid IPv6LoopbackAddress: unacceptable form - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } else { + return true; + } + }); + } + + boolean isValidLoopbackAddress(Predicate p) { + // valid addresses depend on configurations + + if (!config.isAllowHttpScheme() && isHttp()) { + logger.debugv("Invalid LoopbackAddress: HTTP not allowed - input = {0}", uri.toString()); + return false; + } + + if (config.isAllowWildcardContextPath()) { + if (isIncludeInvalidWildcard()) { + logger.debugv("Invalid LoopbackAddress: invalid Wildcard - input = {0}", uri.toString()); + return false; + } + } else { + if (isIncludeWildcard()) { + logger.debugv("Invalid LoopbackAddress: Wildcard not allowed - input = {0}", uri.toString()); + return false; + } + } + + if (config.isOAuth2_1Compliant()) { + if (isIncludeUriFragment()) { // URL fragment is not allowed + logger.debugv("Invalid LoopbackAddress: URI fragment not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (isIncludeWildcard()) { // wildcard is not allowed + logger.debugv("Invalid LoopbackAddress: Wildcard not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if ("localhost".equalsIgnoreCase(uri.getHost())) { // "localhost" is not allowed. + logger.debugv("Invalid LoopbackAddress: localhost not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (isRedirectUriParameter) { + if (uri.getPort() < 0 || uri.getPort() > 65535) { // only 0 to 65535 are allowed. no port number is not allowed. + logger.debugv("Invalid LoopbackAddress: invalid port number - OAuth 2.1 compliant - redirect_uri parameter - input = {0}", uri.toString()); + return false; + } + } else { + if (uri.getPort() > -1) { // any port number is not allowed + logger.debugv("Invalid LoopbackAddress: port number not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + } + + if (!p.test(uri)) return false; // additional tests for OAuth 2.1 compliant + } + return true; + } + + boolean isValidPrivateUseUriScheme() { + // valid addresses depend on configurations + + if (config.isAllowWildcardContextPath()) { + if (isIncludeInvalidWildcard()) { + logger.debugv("Invalid PrivateUseUriScheme: invalid Wildcard - input = {0}", uri.toString()); + return false; + } + } else { + if (isIncludeWildcard()) { + logger.debugv("Invalid PrivateUseUriScheme: Wildcard not allowed - input = {0}", uri.toString()); + return false; + } + } + + if (config.isOAuth2_1Compliant()) { + if (isIncludeUriFragment()) { // URL fragment is not allowed + logger.debugv("Invalid PrivateUseUriScheme: URI fragment not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (isIncludeWildcard()) { // wildcard is not allowed + logger.debugv("Invalid PrivateUseUriScheme: Wildcard not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (uri.getScheme() == null || !uri.getScheme().contains(".")) { // a single word scheme name is not allowed. + logger.debugv("Invalid PrivateUseUriScheme: a single word scheme name is not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + } + + return true; + } + + boolean isValidNormalUri() { + // valid addresses depend on configurations + + if (!config.isAllowHttpScheme() && isHttp()) { + logger.debugv("Invalid NormalUri: HTTP not allowed - input = {0}", uri.toString()); + return false; + } + + if (config.isAllowWildcardContextPath()) { + if (isIncludeInvalidWildcard()) { + logger.debugv("Invalid NormalUri: invalid Wildcard - input = {0}", uri.toString()); + return false; + } + } else { + if (isIncludeWildcard()) { + logger.debugv("Invalid NormalUri: Wildcard not allowed - input = {0}", uri.toString()); + return false; + } + } + + if (config.getAllowPermittedDomains() != null && !config.getAllowPermittedDomains().isEmpty()) { + if (!matchDomains(config.getAllowPermittedDomains())) { + logger.debugv("Invalid NormalUri: no permitted domain matched - input = {0}", uri.toString()); + return false; + } + } + + if (config.isOAuth2_1Compliant()) { + if (!isHttps()) { // only https scheme is allowed. + logger.debugv("Invalid NormalUri: HTTP not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (isIncludeUriFragment()) { // URL fragment is not allowed. + logger.debugv("Invalid NormalUri: URI fragment not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + + if (isIncludeWildcard()) { // wildcard is not allowed. + logger.debugv("Invalid NormalUri: Wildcard not allowed - OAuth 2.1 compliant - input = {0}", uri.toString()); + return false; + } + } + + return true; + } + + boolean matchDomain(String domainPattern) { + return uri.getHost() != null && uri.getHost().matches(domainPattern); + } + + boolean matchDomains(List permittedDomains) { + return permittedDomains.stream().anyMatch(this::matchDomain); + } + + boolean isHttp() { + return "http".equals(uri.getScheme()); + } + + boolean isHttps() { + return "https".equals(uri.getScheme()); + } + + boolean isWildcardContextPath() { + return uri.getPath() != null && (uri.getPath().startsWith("/*") || uri.getPath().startsWith("*")); + } + + boolean isIncludeUriFragment() { + return uri.toString().contains("#"); + } + + boolean isIncludeWildcard() { + return uri.toString().contains("*"); + } + + boolean isIncludeInvalidWildcard() { + // NOTE: this method assumes that the uri includes at least one wildcard. + if (!isWildcardContextPath()) { + return false; + } + return uri.toString().length() - uri.toString().replace("*", "").length() != 1; + } + + } + + private static ClientPolicyException invalidRedirectUri(String errorDetail) { + return new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, errorDetail); + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorFactory.java new file mode 100644 index 0000000000..5608fa2a87 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorFactory.java @@ -0,0 +1,162 @@ +/* + * Copyright 2023 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.List; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +public class SecureRedirectUrisEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "secure-redirect-uris-enforcer"; + + public static final String ALLOW_IPV4_LOOPBACK_ADDRESS = "allow-ipv4-loopback-address"; + public static final String ALLOW_IPV6_LOOPBACK_ADDRESS = "allow-ipv6-loopback-address"; + public static final String ALLOW_PRIVATE_USE_URI_SCHEME = "allow-private-use-uri-scheme"; + + public static final String ALLOW_HTTP_SCHEME = "allow-http-scheme"; + public static final String ALLOW_WILDCARD_CONTEXT_PATH = "allow-wildcard-context-path"; + public static final String ALLOW_PERMITTED_DOMAINS = "allow-permitted-domains"; + public static final String OAUTH_2_1_COMPLIANT = "oauth-2-1-compliant"; + + public static final String ALLOW_OPEN_REDIRECT = "allow-open-redirect"; + + public enum UriType { + NORMAL_URI, + IPV4_LOOPBACK_ADDRESS, + IPV6_LOOPBACK_ADDRESS, + PRIVATE_USE_URI_SCHEME, + INVALID_URI + } + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new SecureRedirectUrisEnforcerExecutor(session); + } + + @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 "On registering and updating a client, this executor only allows a valid redirect uri. On receiving an authorization request, this executor checks whether a redirect uri parameter matches registered redirect uris in the way that depends on the executor's setting."; + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + private static final List CONFIG_PROPERTIES; + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + + .property() + .name(ALLOW_IPV4_LOOPBACK_ADDRESS) + .label("Allow IPv4 loopback address") + .helpText("If ON, then the executor allows IPv4 loopback address as a valid redirect uri. " + + "For example, 'http://127.0.0.1:{port}/{path}' . ") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_IPV6_LOOPBACK_ADDRESS) + .label("Allow IPv6 loopback address") + .helpText("If ON, then the executor allows IPv6 loopback address as a valid redirect uri. " + + "For example, 'http://[::1]:{port}/{path}' . ") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_PRIVATE_USE_URI_SCHEME) + .label("Allow private use URI scheme") + .helpText("If ON, then the executor allows a private-use URI scheme (aka custom URL scheme) as a valid redirect uri. " + + "For example, an app that controls the domain name 'app.example.com' " + + "can use 'com.example.app' as their scheme.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_HTTP_SCHEME) + .label("Allow http scheme") + .helpText("If On, then the executor allows http scheme as a valid redirect uri.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_WILDCARD_CONTEXT_PATH) + .label("Allow wildcard in context-path") + .helpText("If ON, then it will allow wildcard in context-path uris. " + + "For example, domain.example.com/*") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_PERMITTED_DOMAINS) + .label("Allow permitted domains") + .helpText("If some domains are filled, the redirect uri host must match one of registered domains. " + + "If not filled, then all domains are possible to use. The domains are checked by using regex. " + + "For example use pattern like this '(.*)\\.example\\.org' if you want clients to register redirect-uris only from domain 'example.org'." + + "Don't forget to use escaping of special characters like dots as otherwise dot is interpreted as any character in regex!") + .type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE) + .add() + + .property() + .name(OAUTH_2_1_COMPLIANT) + .label("OAuth 2.1 Compliant") + .helpText("If On, then the executor checks and matches the uri by following OAuth 2.1 specification. This means that for example URL fragments, wildcard redirect uris or URL using 'localhost' are not allowed.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .property() + .name(ALLOW_OPEN_REDIRECT) + .label("Allow open redirect") + .helpText("If ON, then the executor does not verify a redirect uri even if its other setting is ON. " + + "WARNING: This is insecure and should be used with care as open redirects are bad practice.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + + .build(); + } +} 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 4155a7ca85..4f0320c276 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 @@ -24,3 +24,4 @@ org.keycloak.services.clientpolicy.executor.RejectImplicitGrantExecutorFactory org.keycloak.services.clientpolicy.executor.SecureParContentsExecutorFactory org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.UseLightweightAccessTokenExecutorFactory +org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorTest.java b/services/src/test/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorTest.java new file mode 100644 index 0000000000..9802910164 --- /dev/null +++ b/services/src/test/java/org/keycloak/services/clientpolicy/executor/SecureRedirectUrisEnforcerExecutorTest.java @@ -0,0 +1,442 @@ +/* + * Copyright 2024 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.keycloak.OAuthErrorException; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutor.Configuration; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutor.UriValidation; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_HTTP_SCHEME; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_IPV4_LOOPBACK_ADDRESS; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_IPV6_LOOPBACK_ADDRESS; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_PRIVATE_USE_URI_SCHEME; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_WILDCARD_CONTEXT_PATH; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_OPEN_REDIRECT; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.ALLOW_PERMITTED_DOMAINS; +import static org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory.OAUTH_2_1_COMPLIANT; + +public class SecureRedirectUrisEnforcerExecutorTest { + + private static SecureRedirectUrisEnforcerExecutor executor; + private ObjectNode configuration; + + @BeforeClass + public static void setupAll() { + executor = new SecureRedirectUrisEnforcerExecutor(null); + } + + @Before + public void setup() { + configuration = JsonSerialization.createObjectNode(); + } + + @Test + public void defaultConfiguration() { + setupConfiguration(configuration); + Configuration configuration = executor.getConfiguration(); + + assertFalse(configuration.isAllowIPv4LoopbackAddress()); + assertFalse(configuration.isAllowIPv6LoopbackAddress()); + assertFalse(configuration.isAllowPrivateUseUriScheme()); + assertFalse(configuration.isAllowHttpScheme()); + assertFalse(configuration.isAllowWildcardContextPath()); + assertFalse(configuration.isOAuth2_1Compliant()); + assertFalse(configuration.isAllowOpenRedirect()); + + assertTrue(configuration.getAllowPermittedDomains().isEmpty()); + } + + @Test + public void failUriSyntax() { + checkFail("https://keycloak.org\n" ,false, SecureRedirectUrisEnforcerExecutor.ERR_GENERAL); + checkFail("Collins'&1=1;--" ,false, SecureRedirectUrisEnforcerExecutor.ERR_GENERAL); + } + + @Test + public void failValidatePrivateUseUriScheme() { + // default config + checkFail("myapp:/oauth.redirect", false, SecureRedirectUrisEnforcerExecutor.ERR_PRIVATESCHEME); + + // allow private use uri scheme + enable(ALLOW_PRIVATE_USE_URI_SCHEME); + checkFail("myapp.example.com:/*", false, SecureRedirectUrisEnforcerExecutor.ERR_PRIVATESCHEME); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkFail("myapp.example.com:/*/abc/*/efg", false, SecureRedirectUrisEnforcerExecutor.ERR_PRIVATESCHEME); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + Stream.of( + "myapp:/oauth.redirect#pinpoint", + "myapp.example.com:/oauth.redirect/*", + "myapp:/oauth.redirect" + ).forEach(i->checkFail(i, false, SecureRedirectUrisEnforcerExecutor.ERR_PRIVATESCHEME)); + } + + @Test + public void successValidatePrivateUseUriScheme() { + // allow private use uri scheme + enable(ALLOW_PRIVATE_USE_URI_SCHEME); + Stream.of( + "com.example.app:/oauth2redirect/example-provider", + "com.example.app:51004/oauth2redirect/example-provider" + ).forEach(i->checkSuccess(i, false)); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkSuccess("myapp.example.com:/*", false); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + checkSuccess("com.example.app:/oauth2redirect/example-provider", false); + } + + @Test + public void failValidateIPv4LoopbackAddress() { + // default config + checkFail("https://127.0.0.1/auth/admin", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + + // allow IPv4 loopback address + enable(ALLOW_IPV4_LOOPBACK_ADDRESS); + Stream.of( + "http://127.0.0.1:8080/auth/admin", + "https://127.0.0.1:8080/*" + ).forEach(i->checkFail(i, false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK)); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkFail("https://127.0.0.1:8080/*/efg/*/abc", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + Stream.of( + "http://127.0.0.1:8080/auth/admin", + "http://127.0.0.1/*", + "http://127.0.0.1/auth/admin#fragment" + ).forEach(i->checkFail(i, false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK)); + // authorization code request redirect_uri parameter + Stream.of( + "http://127.0.0.1:123456/oauth2redirect/example-provider", + "http://127.0.0.1/oauth2redirect/example-provider" + ).forEach(i->checkFail(i, true, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK)); + } + + @Test + public void successValiIPv4LoopbackAddress() { + // allow IPv4 loopback address + enable(ALLOW_IPV4_LOOPBACK_ADDRESS); + Stream.of( + "https://127.0.0.1:8443", + "https://localhost/auth/admin" + ).forEach(i->checkSuccess(i, false)); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkSuccess("https://localhost/*", false); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + checkSuccess("http://127.0.0.1:8080/oauth2redirect", false); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + checkSuccess("http://127.0.0.1/oauth2redirect/example-provider", false); + checkSuccess("http://127.0.0.1:43321/oauth2redirect/example-provider", true); + } + + @Test + public void failValidateIPv6LoopbackAddress() { + // default config + checkFail("https://[::1]:9999/oauth2redirect/example-provider", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + + // allow IPv6 loopback address + enable(ALLOW_IPV6_LOOPBACK_ADDRESS); + checkFail("http://[::1]:9999/oauth2redirect/example-provider", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + checkFail("https://[::1]:9999/*", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkFail("https://[::1]/*/efg/*/abc", false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + Stream.of( + "http://[::1]:8080/auth/admin", + "http://[::1]/*", + "http://[::1]/auth/admin#fragment", + "https://[0:0:0:0:0:0:0:1]:8080" + ).forEach(i->checkFail(i, false, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK)); + // authorization code request redirect_uri parameter + Stream.of( + "http://[::1]:123456/oauth2redirect/example-provider", + "http://[::1]/oauth2redirect/example-provider" + ).forEach(i->checkFail(i, true, SecureRedirectUrisEnforcerExecutor.ERR_LOOPBACK)); + } + + @Test + public void successValiIPv6LoopbackAddress() { + // allow IPv6 loopback address + enable(ALLOW_IPV6_LOOPBACK_ADDRESS); + for (String i : Arrays.asList( + "https://[::1]/oauth2redirect/example-provider", + "https://[::1]:8443" + )) { + checkSuccess(i, false); + } + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkSuccess("https://[::1]/*", false); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + checkSuccess("http://[::1]:8080/oauth2redirect", false); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + checkSuccess("http://[::1]/oauth2redirect/example-provider", false); + checkSuccess("http://[::1]:43321/oauth2redirect/example-provider", true); + } + + @Test + public void failNormalUri() { + // default config + checkFail("http://192.168.1.211:9088/oauth2redirect/example-provider/test", false, SecureRedirectUrisEnforcerExecutor.ERR_NORMALURI); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkFail("https://192.168.1.211/*/efg/*/abc", false, SecureRedirectUrisEnforcerExecutor.ERR_NORMALURI); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + + // allow permitted domains + permittedDomains("oauth.redirect", "((dev|test)-)*example.org"); + checkFail("http://192.168.1.211/oauth2redirect/example-provider/test", false, SecureRedirectUrisEnforcerExecutor.ERR_NORMALURI); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + Stream.of( + "http://dev-example.com/auth/callback", + "https://test-example.com:8443/*", + "https://oauth.redirect:8443/auth/callback#fragment" + ).forEach(i->checkFail(i, true, SecureRedirectUrisEnforcerExecutor.ERR_NORMALURI)); + } + + @Test + public void successNormalUri() { + // default config + Stream.of( + "https://example.org/realms/master", + "https://192.168.1.211:9088/oauth2redirect/example-provider/test", + "https://192.168.1.211/" + ).forEach(i->checkSuccess(i, false)); + + // allow wildcard context path + enable(ALLOW_WILDCARD_CONTEXT_PATH); + checkSuccess("https://example.org/*", false); + + // allow http scheme + enable(ALLOW_HTTP_SCHEME); + + // allow permitted domains + permittedDomains("oauth.redirect", "((dev|test)-)*example.org"); + checkSuccess("http://test-example.org:8080/*", false); + + // OAuth 2.1 compliant + enable(OAUTH_2_1_COMPLIANT); + checkSuccess("https://dev-example.org:8080/redirect", false); + } + + @Test + public void successDefaultConfiguration() { + Stream.of( + "https://example.org/realms/master", + "https://192.168.1.211:9088/oauth2redirect/example-provider/test", + "https://192.168.1.211/" + ).forEach(i->checkSuccess(i, false)); + } + + @Test + public void successAllConfigurationEnabled() { + // except ALLOW_OPEN_REDIRECT + enable( + ALLOW_WILDCARD_CONTEXT_PATH, + ALLOW_IPV4_LOOPBACK_ADDRESS, + ALLOW_IPV6_LOOPBACK_ADDRESS, + ALLOW_HTTP_SCHEME, + ALLOW_PRIVATE_USE_URI_SCHEME + ); + + permittedDomains("oauth.redirect", "((dev|test)-)*example.org"); + + Stream.of( + "http://127.0.0.1/*/realms/master", + "http://[::1]/*/realms/master", + "myapp.example.com://oauth.redirect", + "https://test-example.org/*/auth/admin" + ).forEach(i->checkSuccess(i, false)); + } + + @Test + public void successAllConfigurationDisabled() { + disable( + ALLOW_WILDCARD_CONTEXT_PATH, + ALLOW_IPV4_LOOPBACK_ADDRESS, + ALLOW_IPV6_LOOPBACK_ADDRESS, + ALLOW_HTTP_SCHEME, + ALLOW_PRIVATE_USE_URI_SCHEME, + ALLOW_OPEN_REDIRECT + ); + + Stream.of( + "https://keycloak.org/sso/silent-callback.html", + "https://example.org/auth/realms/master/broker/oidc/endpoint", + "https://192.168.8.1:12345/auth/realms/master/broker/oidc/endpoint" + ).forEach(i->checkSuccess(i, false)); + } + + private void permittedDomains(String... domains) { + ArrayNode arrayNode = JsonSerialization.mapper.createArrayNode(); + Arrays.stream(domains).forEach(arrayNode::add); + configuration.set(ALLOW_PERMITTED_DOMAINS, arrayNode); + } + + private void disable(String... config) { + Arrays.stream(config).forEach(it -> configuration.set(it, BooleanNode.getFalse())); + } + + private void enable(String... config) { + Arrays.stream(config).forEach(it -> configuration.set(it, BooleanNode.getTrue())); + } + + private void setupConfiguration(JsonNode node) { + Configuration configuration = JsonSerialization.mapper.convertValue(node, executor.getExecutorConfigurationClass()); + executor.setupConfiguration(configuration); + } + + private void checkFail(String redirectUri, boolean isRedirectUriParameter, String errorDetail) { + try { + doValidate(redirectUri, isRedirectUriParameter); + fail(); + } catch (ClientPolicyException cpe) { + assertEquals(OAuthErrorException.INVALID_REQUEST, cpe.getMessage()); + assertEquals(errorDetail, cpe.getErrorDetail()); + } + } + + private void checkSuccess(String redirectUri, boolean isRedirectUriParameter) { + try { + doValidate(redirectUri, isRedirectUriParameter); + } catch (ClientPolicyException e) { + assertNull(e.getErrorDetail(), e); + } + } + + private void doValidate(String redirectUri, boolean isRedirectUriParameter) throws ClientPolicyException { + setupConfiguration(configuration); + executor.verifyRedirectUri(redirectUri, isRedirectUriParameter); + } + + @Test + public void matchDomains() throws URISyntaxException { + SecureRedirectUrisEnforcerExecutor.Configuration config = new SecureRedirectUrisEnforcerExecutor.Configuration(); + + // no domains + UriValidation validation = new UriValidation("http://localhost:8080/auth/realms/master/account", false, config); + boolean matches = validation.matchDomains(Collections.emptyList()); + assertFalse(matches); + + // 1 domain not match + matches = validation.matchDomains(Collections.singletonList("local-\\w+")); + assertFalse(matches); + + // 1 domain match + matches = validation.matchDomains(Collections.singletonList("localhost")); + assertTrue(matches); + + matches = validation.matchDomains(Collections.singletonList("local\\w+")); + assertTrue(matches); + + // 2 domains not match + matches = validation.matchDomains(Arrays.asList( + "local-\\w+", + "localhost2" + )); + assertFalse(matches); + + // 2 domain match + matches = validation.matchDomains(Arrays.asList( + "local\\w+", + "localhost" + )); + assertTrue(matches); + + // 3 more cases + String givenPattern = "((dev|test)-)*example.org"; + String[] expectMatches = new String[]{ + "https://dev-example.org", + "https://test-example.org", + "https://example.org", + }; + + for (String match : expectMatches) { + validation = new UriValidation(match, false, config); + assertTrue(match, validation.matchDomain(givenPattern)); + } + + String[] expectNoneMatches = new String[]{ + "https://prod-example.org", + "https://testexample.org" + }; + + for (String match : expectNoneMatches) { + validation = new UriValidation(match, false, config); + assertFalse(match, validation.matchDomain(givenPattern)); + } + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/SecureRedirectUrisEnforcerExecutorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/SecureRedirectUrisEnforcerExecutorTest.java new file mode 100644 index 0000000000..8663e43dca --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/SecureRedirectUrisEnforcerExecutorTest.java @@ -0,0 +1,556 @@ +/* + * Copyright 2023 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.client.policies; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureRedirectUrisEnforcerExecutorConfig; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.OAuthErrorException; +import org.keycloak.client.registration.ClientRegistrationException; +import org.keycloak.events.Errors; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutor; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory; +import org.keycloak.testsuite.util.ClientPoliciesUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.ServerURLs; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +public class SecureRedirectUrisEnforcerExecutorTest extends AbstractClientPoliciesTest { + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + testRealms.add(realm); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_normalUri() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // no redirect uri is not allowed + testSecureRedirectUrisEnforcerExecutor_failRegisterByAdmin(List.of("")); + + // register - fail + // HTTP scheme not allowed + testSecureRedirectUrisEnforcerExecutor_failRegisterDynamically(List.of("http://app.example.com:51004/oauth2redirect/example-provider")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("https://app.example.com:51004/oauth2redirect/example-provider", "https://dev.example.com/redirect")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // IPv4 loopback address not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateByAdmin(alphaCid, + Arrays.asList("https://127.0.0.1:8443", "https://app.example.com:51004/oauth2redirect/example-provider")); + + // update - fail + // wildcard context path not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateDynamically(alphaClientId, + List.of("https://dev.example.com:8443/*")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + Arrays.asList("https://app.example.com:51004/oauth2redirect/example-provider/update", "https://dev.example.com/redirect/update")); + + // authorization request - fail + // redirect_uri not matched with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "https://app.example.com:51004/oauth2redirect/example-provider"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_IPv4LoopbackAddress() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it-> + it.setAllowIPv4LoopbackAddress(true))) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // IPv6 loopback address not allowed + testSecureRedirectUrisEnforcerExecutor_failRegisterDynamically(Arrays.asList("https://127.0.0.1/oauth2redirect/example-provider", + "http://[::1]/oauth2redirect/example-provider")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("https://app.example.com:51004/oauth2redirect/example-provider", + "https://127.0.0.1/oauth2redirect/example-provider")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // private use uri scheme not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateByAdmin(alphaCid, List.of("com.example.app:/oauth2redirect/example-provider")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + List.of("/auth/realms/master/app/auth", "https://dev.example.com/redirect/update")); + + // authorization request - fail + // invalid uri form + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "https://keycloak.org\n"); + + // authorization request - success + testSecureRedirectUrisEnforcerExecutor_successAuthorizationRequest(alphaClientId, ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_IPv6LoopbackAddress() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it-> + it.setAllowIPv6LoopbackAddress(true))) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // IPv4 loopback address not allowed + testSecureRedirectUrisEnforcerExecutor_failRegisterDynamically(Arrays.asList("https://[::1]/", "https://localhost/auth/admin")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("https://[::1]/oauth2redirect/example-provider", "https://[::1]/")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // representation of IPv6 loopback address [0:0:0:0:0:0:0:1] not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateByAdmin(alphaCid, List.of("https://[0:0:0:0:0:0:0:1]/oauth2redirect/example-provider")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + List.of("https://[::1]/oauth2redirect/example-provider/update", "https://dev.example.com/redirect/update")); + + // authorization request - fail + // redirect_uri parameter not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "http://[::1]:65522/oauth2redirect/example-provider"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_PrivateUseUriScheme() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it-> + it.setAllowPrivateUseUriScheme(true))) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // invalid uri form + testSecureRedirectUrisEnforcerExecutor_failRegisterByAdmin(List.of("com.example:")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + List.of("com.example.app:/oauth2redirect/example-provider", + "dev.com.example.app:/oauth2redirect/example-provider/dev")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // HTTP scheme not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateDynamically(alphaClientId, + Arrays.asList("com.example.app:/oauth2redirect/example-provider", "http://dev.example.com/redirect")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + Arrays.asList("com.example.app:/oauth2redirect/example-provider/update", "https://dev.example.com/redirect/update")); + + // authorization request - fail + // redirect_uri not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "com.example.app:/oauth2redirect/example-provider"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_AllowHttpScheme() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{ + it.setAllowIPv4LoopbackAddress(true); + it.setAllowIPv6LoopbackAddress(true); + it.setAllowHttpScheme(true);})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("http://localhost:8080/redirect", "http://dev.example.com/redirect/update")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + Arrays.asList("http://[::1]:8080/redirect", ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth")); + + // authorization request - fail + // redirect_uri not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "http://[::1]:8080/"); + + // authorization request - success + testSecureRedirectUrisEnforcerExecutor_successAuthorizationRequest(alphaClientId, ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_AllowWildcardContextPath() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{ + it.setAllowPrivateUseUriScheme(true); + it.setAllowIPv4LoopbackAddress(true); + it.setAllowIPv6LoopbackAddress(true); + it.setAllowHttpScheme(true); + it.setAllowWildcardContextPath(true);})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("http://localhost:8080/*", "http://dev.example.com/redirect/update")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + Arrays.asList("http://[::1]:8080/*", ServerURLs.getAuthServerContextRoot() + "/*")); + + // authorization request - fail + // redirect_uri not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "com.example.app:/oauth2redirect/example-provider"); + + // authorization request - success + testSecureRedirectUrisEnforcerExecutor_successAuthorizationRequest(alphaClientId, ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_AllowPermittedDomains() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{ + it.setAllowIPv4LoopbackAddress(true); + it.setAllowPermittedDomains(Arrays.asList( + "oauth.redirect", "((dev|test)-)*example.org", "localhost")); + it.setAllowHttpScheme(true); + it.setAllowWildcardContextPath(true);})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // not match permitted domains + testSecureRedirectUrisEnforcerExecutor_failRegisterByAdmin(Arrays.asList("http://oauth.redirect/*", "http://dev.example.org/redirect")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin( + Arrays.asList("http://oauth.redirect/*", "http://dev-example.org/redirect")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // not match permitted domains + testSecureRedirectUrisEnforcerExecutor_failUpdateDynamically(alphaClientId, + Arrays.asList("http://dev.oauth.redirect/*", "http://test-example.com/redirect")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + Arrays.asList("http://oauth.redirect/*", "http://dev-example.org/redirect", ServerURLs.getAuthServerContextRoot() + "/*")); + + // authorization request - fail + // redirect_uri not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "http://dev-example.org/v2/redirect"); + + // authorization request - success + testSecureRedirectUrisEnforcerExecutor_successAuthorizationRequest(alphaClientId, ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_OAuth2_1Complient() throws Exception { + // register profiles + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{ + it.setAllowPrivateUseUriScheme(true); + it.setAllowIPv4LoopbackAddress(true); + it.setAllowIPv6LoopbackAddress(true); + it.setAllowHttpScheme(true); + it.setOAuth2_1Compliant(true);})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - fail + // IPv4 loopback address with port number not allowed + testSecureRedirectUrisEnforcerExecutor_failRegisterByAdmin(Arrays.asList("http://127.0.0.1/auth/admin", "http://127.0.0.1:8080/auth/admin")); + + // register - success + List registerResultList = testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin(List.of("https://127.0.0.1/auth/admin")); + String alphaClientId = registerResultList.get(0); + String alphaCid = registerResultList.get(1); + + // update - fail + // HTTP scheme not allowed + testSecureRedirectUrisEnforcerExecutor_failUpdateDynamically(alphaClientId, + List.of("https://127.0.0.1/auth/admin", "http://test-example.com/redirect")); + + // update - success + testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(alphaCid, + List.of("https://[::1]/auth/admin", "com.example.app:/oauth2redirect/example-provider")); + + // authorization request - fail + // redirect_uri not match with registered redirect uris + testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(alphaClientId, "com.example.app:/oauth3redirect/example-provider"); + } + + @Test + public void testSecureRedirectUrisEnforcerExecutor_AllowOpenRedirect() throws Exception { + // Allow open redirect + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureRedirectUrisEnforcerExecutorFactory.PROVIDER_ID, + createSecureRedirectUrisEnforcerExecutorConfig(it->{ + it.setAllowOpenRedirect(true); + it.setOAuth2_1Compliant(true);})) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // register - success + // open redirect is allowed in any running mode + testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin(List.of("")); + } + + + private void testSecureRedirectUrisEnforcerExecutor_failRegisterByAdmin(List redirectUrisList) { + try { + createClientByAdmin(generateSuffixedName(CLIENT_NAME), (ClientRepresentation clientRep) -> { + clientRep.setSecret("secret"); + clientRep.setRedirectUris(redirectUrisList); + }); + fail(); + } catch (ClientPolicyException cpe) { + assertEquals(OAuthErrorException.INVALID_REQUEST, cpe.getError()); + } + } + + private void testSecureRedirectUrisEnforcerExecutor_failRegisterDynamically(List redirectUrisList) { + try { + createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> + clientRep.setRedirectUris(redirectUrisList)); + } catch (ClientRegistrationException cre) { + assertEquals(ERR_MSG_CLIENT_REG_FAIL, cre.getMessage()); + } + } + + private List testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin(List redirectUrisList) { + String alphaClientId = null; + String alphaCid = null; + try { + alphaClientId = generateSuffixedName(CLIENT_NAME); + alphaCid = createClientByAdmin(alphaClientId, (ClientRepresentation clientRep) -> { + clientRep.setSecret("secret"); + clientRep.setRedirectUris(redirectUrisList); + }); + ClientRepresentation cRep = getClientByAdmin(alphaCid); + assertEquals(new HashSet<>(redirectUrisList), new HashSet<>(cRep.getRedirectUris())); + } catch (ClientPolicyException cpe) { + fail(); + } + return Arrays.asList(alphaClientId, alphaCid); + } + + private void testSecureRedirectUrisEnforcerExecutor_failUpdateByAdmin(String cId, List redirectUrisList) { + try { + updateClientByAdmin(cId, (ClientRepresentation clientRep) -> { + clientRep.setAttributes(new HashMap<>()); + clientRep.setRedirectUris(redirectUrisList); + }); + } catch (ClientPolicyException cpe) { + assertEquals(Errors.INVALID_REQUEST, cpe.getError()); + } + } + + private void testSecureRedirectUrisEnforcerExecutor_failUpdateDynamically(String clientId, List redirectUrisList) { + try { + updateClientDynamically(clientId, (OIDCClientRepresentation clientRep) -> + clientRep.setRedirectUris(redirectUrisList)); + fail(); + } catch (ClientRegistrationException e) { + assertEquals(ERR_MSG_CLIENT_REG_FAIL, e.getMessage()); + } + } + + private void testSecureRedirectUrisEnforcerExecutor_successUpdateByAdmin(String cId, List redirectUrisList) { + try { + updateClientByAdmin(cId, (ClientRepresentation clientRep) -> { + clientRep.setAttributes(new HashMap<>()); + clientRep.setRedirectUris(redirectUrisList); + }); + ClientRepresentation cRep = getClientByAdmin(cId); + assertEquals(new HashSet<>(redirectUrisList), new HashSet<>(cRep.getRedirectUris())); + } catch (ClientPolicyException cpe) { + fail(); + } + } + + private void testSecureRedirectUrisEnforcerExecutor_failAuthorizationRequest(String clientId, String redirectUri) { + oauth.clientId(clientId); + oauth.redirectUri(redirectUri); + oauth.openLoginForm(); + assertTrue(errorPage.isCurrent()); + } + + private void testSecureRedirectUrisEnforcerExecutor_successAuthorizationRequest(String clientId, String redirectUri) { + oauth.clientId(clientId); + oauth.redirectUri(redirectUri); + OAuthClient.AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + Assert.assertNotNull(response.getCode()); + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(response.getCode(), "secret"); + assertEquals(200, res.getStatusCode()); + oauth.doLogout(res.getRefreshToken(), "secret"); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java index 1df75b3e17..1859e8a5f6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java @@ -63,11 +63,11 @@ import org.keycloak.services.clientpolicy.executor.PKCEEnforcerExecutor; import org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutor; import org.keycloak.services.clientpolicy.executor.RejectImplicitGrantExecutor; import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutor; +import org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutor; import org.keycloak.services.clientpolicy.executor.SecureRequestObjectExecutor; import org.keycloak.services.clientpolicy.executor.SecureResponseTypeExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmExecutor; import org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmForSignedJwtExecutor; -import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExceptionCondition; import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutor; import org.keycloak.util.JsonSerialization; @@ -85,6 +85,7 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; import static org.junit.Assert.fail; import static org.keycloak.jose.jwk.JWKUtil.toIntegerBytes; @@ -271,6 +272,15 @@ public final class ClientPoliciesUtil { return config; } + public static SecureRedirectUrisEnforcerExecutor.Configuration createSecureRedirectUrisEnforcerExecutorConfig( + Consumer apply) { + SecureRedirectUrisEnforcerExecutor.Configuration config = new SecureRedirectUrisEnforcerExecutor.Configuration(); + if (apply != null) { + apply.accept(config); + } + return config; + } + public static class ClientPoliciesBuilder { private final ClientPoliciesRepresentation policiesRep;