Client policies: executor for validate and match a redirect URI

closes #25637

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2023-12-18 06:52:24 +09:00 committed by Marek Posolda
parent 870befa422
commit 1bdbaa2ca5
9 changed files with 1689 additions and 1 deletions

View file

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

View file

@ -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 `*`.

View file

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

View file

@ -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<SecureRedirectUrisEnforcerExecutor.Configuration> {
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<Configuration> 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<String> 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<String> getAllowPermittedDomains() {
return allowPermittedDomains;
}
public void setAllowPermittedDomains(List<String> 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<String> 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<String> 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<String> 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<InetAddress> 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<URI> 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<String> 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);
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return CONFIG_PROPERTIES;
}
private static final List<ProviderConfigProperty> 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();
}
}

View file

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

View file

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

View file

@ -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<RealmRepresentation> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> redirectUrisList) {
try {
createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) ->
clientRep.setRedirectUris(redirectUrisList));
} catch (ClientRegistrationException cre) {
assertEquals(ERR_MSG_CLIENT_REG_FAIL, cre.getMessage());
}
}
private List<String> testSecureRedirectUrisEnforcerExecutor_successRegisterByAdmin(List<String> 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<String> 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<String> 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<String> 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");
}
}

View file

@ -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<SecureRedirectUrisEnforcerExecutor.Configuration> apply) {
SecureRedirectUrisEnforcerExecutor.Configuration config = new SecureRedirectUrisEnforcerExecutor.Configuration();
if (apply != null) {
apply.accept(config);
}
return config;
}
public static class ClientPoliciesBuilder {
private final ClientPoliciesRepresentation policiesRep;