Redirect Wildcard changes and more https checks to secure-client-executor (#46082)

Closes #45587


Signed-off-by: Marie Daly <marie.daly1@ibm.com>
This commit is contained in:
Marie Daly 2026-02-10 12:00:06 +00:00 committed by GitHub
parent 0669a7eb14
commit 7d6108d4b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 270 additions and 25 deletions

View file

@ -14,6 +14,18 @@ It was also not aligned with how other script-based features work in {project_na
If you have existing JavaScript-based policies, make sure to enable the `scripts` feature when starting {project_name}.
=== Stricter Validation for Client URIs (secure-client-uris)
The 'secure-client-uris' client policy executor has been updated to enforce stricter validation on Client URIs. This aligns with FAPI 2.0 security requirements by mandating TLS (HTTPS) for client endpoints.
HTTPS Enforcement: The following URIs must now use the https scheme. Existing clients configured with http in these fields will fail validation during updates:
'Post logout redirect URIs', 'Logo URL', 'Policy URL', and 'Terms of Service URL'.
Expanded Wildcard Support: The + wildcard character is now allowed in 'Valid post logout redirect URIs' and 'Web origins'.
Configuring + defers validation to the client's standard 'Valid redirect URIs'. Since redirect URIs are already checked for HTTPS, using the wildcard maintains FAPI 2.0 compliance while reducing configuration duplication.
Administrators must ensure that all configured URIs for the fields listed above use https. Clients attempting to update or register with http in these fields will fail validation when using the 'secure-client-uris' executor.
// ------------------------ Notable changes ------------------------ //
== Notable changes

View file

@ -135,6 +135,8 @@ public final class Constants {
// multiple values into single string
public static final String CFG_DELIMITER = "##";
public static final String INCLUDE_REDIRECTS = "+";
// Better performance to use this instead of String.split
public static final Pattern CFG_DELIMITER_PATTERN = Pattern.compile("\\s*" + CFG_DELIMITER + "\\s*");

View file

@ -98,7 +98,9 @@ public final class OIDCConfigAttributes {
public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "oauth2.jwt.authorization.grant.enabled";
public static final String JWT_AUTHORIZATION_GRANT_IDP = "oauth2.jwt.authorization.grant.idp";
public static final String LOGO_URI = "logoUri";
public static final String TOS_URI = "tosUri";
public static final String POLICY_URI = "policyUri";
private OIDCConfigAttributes() {
}

View file

@ -20,7 +20,9 @@ package org.keycloak.protocol.oidc.utils;
import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
@ -214,4 +216,29 @@ public class RedirectUtils {
}
return redirectUri;
}
public static Set<String> resolveUrlsWithRedirects(KeycloakSession session, List<String> origUrls,
String rootUrl, List<String> redirectUris, boolean returnAsOrigins) {
Set<String> refactoredUrls = (origUrls != null) ? new HashSet<>(origUrls) : new HashSet<>();
if (refactoredUrls.contains(Constants.INCLUDE_REDIRECTS)) {
refactoredUrls.remove(Constants.INCLUDE_REDIRECTS);
Set<String> redirectsToProcess = (redirectUris != null) ? new HashSet<>(redirectUris) : Collections.emptySet();
for (String redirectUri : resolveValidRedirects(session, rootUrl, redirectsToProcess)) {
if (isValidScheme(redirectUri)) {
if (returnAsOrigins) {
refactoredUrls.add(UriUtils.getOrigin(redirectUri));
} else {
refactoredUrls.add(redirectUri);
}
}
}
}
return refactoredUrls;
}
private static boolean isValidScheme(String url) {
return url != null && (url.startsWith("http://") || url.startsWith("https://"));
}
}

View file

@ -22,6 +22,7 @@ import java.util.Set;
import org.keycloak.common.util.UriUtils;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
/**
@ -29,15 +30,13 @@ import org.keycloak.models.KeycloakSession;
*/
public class WebOriginsUtils {
public static final String INCLUDE_REDIRECTS = "+";
public static Set<String> resolveValidWebOrigins(KeycloakSession session, ClientModel client) {
Set<String> origins = new HashSet<>();
if (client.getWebOrigins() != null) {
origins.addAll(client.getWebOrigins());
}
if (origins.contains(INCLUDE_REDIRECTS)) {
origins.remove(INCLUDE_REDIRECTS);
if (origins.contains(Constants.INCLUDE_REDIRECTS)) {
origins.remove(Constants.INCLUDE_REDIRECTS);
for (String redirectUri : RedirectUtils.resolveValidRedirects(session, client.getRootUrl(), client.getRedirectUris())) {
if (redirectUri.startsWith("http://") || redirectUri.startsWith("https://")) {
origins.add(UriUtils.getOrigin(redirectUri));

View file

@ -17,16 +17,19 @@
package org.keycloak.services.clientpolicy.executor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
@ -86,38 +89,40 @@ public class SecureClientUrisExecutor implements ClientPolicyExecutorProvider<Cl
confirmSecureRedirectUri(((AuthorizationRequestContext)context).getRedirectUri());
return;
default:
return;
}
}
private void confirmSecureUris(ClientRepresentation clientRep) throws ClientPolicyException {
// rootUrl
String rootUrl = clientRep.getRootUrl();
if (rootUrl != null) confirmSecureUris(Arrays.asList(rootUrl), "rootUrl");
if (rootUrl != null) confirmSecureUris(List.of(rootUrl), "rootUrl");
// adminUrl
String adminUrl = clientRep.getAdminUrl();
if (adminUrl != null) confirmSecureUris(Arrays.asList(adminUrl), "adminUrl");
if (adminUrl != null) confirmSecureUris(List.of(adminUrl), "adminUrl");
// baseUrl
String baseUrl = clientRep.getBaseUrl();
if (baseUrl != null) confirmSecureUris(Arrays.asList(baseUrl), "baseUrl");
// web origins
List<String> webOrigins = clientRep.getWebOrigins();
if (webOrigins != null) confirmSecureUris(webOrigins, "webOrigins");
if (baseUrl != null) confirmSecureUris(List.of(baseUrl), "baseUrl");
// backchannel logout URL
String logoutUrl = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL);
if (logoutUrl != null) confirmSecureUris(Arrays.asList(logoutUrl), "logoutUrl");
if (logoutUrl != null) confirmSecureUris(List.of(logoutUrl), "logoutUrl");
// OAuth2 : redirectUris
List<String> redirectUris = clientRep.getRedirectUris();
if (redirectUris != null) confirmSecureUris(redirectUris, "redirectUris");
// web origins
List<String> webOrigins = clientRep.getWebOrigins();
if (webOrigins != null) {
List<String> resolvedWebOriginUrls = resolveUrlWithRedirects(webOrigins, redirectUris, rootUrl, true);
confirmSecureUris(resolvedWebOriginUrls, "webOrigins");
}
// OAuth2 : jwks_uri
String jwksUri = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(OIDCConfigAttributes.JWKS_URL);
if (jwksUri != null) confirmSecureUris(Arrays.asList(jwksUri), "jwksUri");
if (jwksUri != null) confirmSecureUris(List.of(jwksUri), "jwksUri");
// OIDD : requestUris
List<String> requestUris = getAttributeMultivalued(clientRep, OIDCConfigAttributes.REQUEST_URIS);
@ -125,7 +130,27 @@ public class SecureClientUrisExecutor implements ClientPolicyExecutorProvider<Cl
// CIBA : client notification endpoint
String clientNotificationEndpoint = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT);
if (clientNotificationEndpoint != null) confirmSecureUris(Arrays.asList(clientNotificationEndpoint), "cibaClientNotificationEndpoint");
if (clientNotificationEndpoint != null) confirmSecureUris(List.of(clientNotificationEndpoint), "cibaClientNotificationEndpoint");
// OIDC: Post Logout URL
List<String> postLogoutRedirectUris = getAttributeMultivalued(clientRep, OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS);
if (postLogoutRedirectUris != null && !postLogoutRedirectUris.isEmpty()) {
List<String> validRedirects = clientRep.getRedirectUris() != null ? clientRep.getRedirectUris() : Collections.emptyList();
List<String> resolvedPostLogoutUrls = resolveUrlWithRedirects(postLogoutRedirectUris, validRedirects, clientRep.getRootUrl(), false);
confirmSecureUris(resolvedPostLogoutUrls, "postLogoutUris");
}
// logoUri
String logoUri = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(OIDCConfigAttributes.LOGO_URI);
if (logoUri != null) confirmSecureUris(List.of(logoUri), "logoUri");
// termsOfServiceUri
String termsOfServiceUri = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(OIDCConfigAttributes.TOS_URI);
if (termsOfServiceUri != null) confirmSecureUris(List.of(termsOfServiceUri), "tosUri");
// policyUri
String policyUri = Optional.ofNullable(clientRep.getAttributes()).orElse(Collections.emptyMap()).get(OIDCConfigAttributes.POLICY_URI);
if (policyUri != null) confirmSecureUris(List.of(policyUri), "policyUri");
}
private List<String> getAttributeMultivalued(ClientRepresentation clientRep, String attrKey) {
@ -140,9 +165,11 @@ public class SecureClientUrisExecutor implements ClientPolicyExecutorProvider<Cl
}
for (String uri : uris) {
logger.tracev("{0} = {1}", uriType, uri);
if (!uri.startsWith("https://") || uri.contains("*")) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Invalid " + uriType);
if (!uri.isEmpty()) {
logger.tracev("{0} = {1}", uriType, uri);
if (!uri.startsWith("https://") || uri.contains("*")) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Invalid " + uriType);
}
}
}
}
@ -156,6 +183,14 @@ public class SecureClientUrisExecutor implements ClientPolicyExecutorProvider<Cl
if (!redirectUri.startsWith("https://") || redirectUri.contains("*")) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid redirect_uri");
}
}
}
private List<String> resolveUrlWithRedirects(List<String> originalUrls, List<String> redirectUris,
String rootUrl, boolean returnAsOrigins) {
if (originalUrls == null || originalUrls.isEmpty()) {
return Collections.emptyList();
}
Set<String> resolvedUrls = RedirectUtils.resolveUrlsWithRedirects(session, originalUrls, rootUrl, redirectUris, returnAsOrigins);
return new ArrayList<>(resolvedUrls);
}
}

View file

@ -197,8 +197,8 @@ public class RealmManager {
String baseUrl = "/admin/" + Encode.encodePathAsIs(realm.getName()) + "/console/";
adminConsole.setBaseUrl(baseUrl);
adminConsole.addRedirectUri(baseUrl + "*");
adminConsole.setAttribute(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, "+");
adminConsole.setWebOrigins(Collections.singleton("+"));
adminConsole.setAttribute(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, Constants.INCLUDE_REDIRECTS);
adminConsole.setWebOrigins(Collections.singleton(Constants.INCLUDE_REDIRECTS));
adminConsole.setEnabled(true);
adminConsole.setAlwaysDisplayInConsole(false);

View file

@ -1216,14 +1216,14 @@ public class ClientPoliciesExecutorTest extends AbstractClientPoliciesTest {
clientRep.setAdminUrl("https://client.example.com/admin/");
// baseUrl
clientRep.setBaseUrl("https://client.example.com/base/");
// OAuth2 : redirectUris
clientRep.setRedirectUris(Arrays.asList("https://client.example.com/redirect/", "https://client.example.com/callback/"));
// web origins
clientRep.setWebOrigins(Arrays.asList("https://valid.other.client.example.com/", "https://valid.another.client.example.com/"));
// backchannel logout URL
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "https://client.example.com/logout/");
clientRep.setAttributes(attributes);
// OAuth2 : redirectUris
clientRep.setRedirectUris(Arrays.asList("https://client.example.com/redirect/", "https://client.example.com/callback/"));
// OAuth2 : jwks_uri
attributes.put(OIDCConfigAttributes.JWKS_URL, "https://client.example.com/jwks/");
clientRep.setAttributes(attributes);
@ -1341,6 +1341,45 @@ public class ClientPoliciesExecutorTest extends AbstractClientPoliciesTest {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
assertEquals("Invalid cibaClientNotificationEndpoint", e.getErrorDetail());
}
try {
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// OIDC: logo_uri
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put("logoUri", "http://client.example.com/logo/");
clientRep.setAttributes(attributes);
});
fail();
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
assertEquals("Invalid logoUri", e.getErrorDetail());
}
try {
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// OIDC: tos_uri (Terms of Service)
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put("tosUri", "http://client.example.com/tos/");
clientRep.setAttributes(attributes);
});
fail();
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
assertEquals("Invalid tosUri", e.getErrorDetail());
}
try {
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// OIDC: policy_uri
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put("policyUri", "http://client.example.com/policy/");
clientRep.setAttributes(attributes);
});
fail();
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
assertEquals("Invalid policyUri", e.getErrorDetail());
}
}
@Test
@ -1830,4 +1869,133 @@ public class ClientPoliciesExecutorTest extends AbstractClientPoliciesTest {
return client.execute(post);
}
}
@Test
public void testSecureClientRegisteringUriEnforceExecutorWithRedirectWildcard() throws Exception {
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Ensimmainen Profiili")
.addExecutor(SecureClientUrisExecutorFactory.PROVIDER_ID, null)
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Ensimmainen Politiikka", Boolean.TRUE)
.addCondition(ClientUpdaterContextConditionFactory.PROVIDER_ID,
createClientUpdateContextConditionConfig(Arrays.asList(
ClientUpdaterContextConditionFactory.BY_AUTHENTICATED_USER,
ClientUpdaterContextConditionFactory.BY_INITIAL_ACCESS_TOKEN,
ClientUpdaterContextConditionFactory.BY_REGISTRATION_ACCESS_TOKEN)))
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
String cid = null;
String clientId = generateSuffixedName(CLIENT_NAME);
try {
cid = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
clientRep.setRedirectUris(List.of("https://client.example.com/redirect/"));
});
} catch (Exception e) {
fail();
}
try { // Redirect relative wildcard - '+' as single value
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// rootUrl
clientRep.setRootUrl("https://client.example.com/");
// adminUrl
clientRep.setAdminUrl("https://client.example.com/admin/");
// baseUrl
clientRep.setBaseUrl("https://client.example.com/base/");
// OAuth2 : redirectUris
clientRep.setRedirectUris(Arrays.asList("https://client.example.com/redirect/", "https://client.example.com/callback/"));
// web origins
clientRep.setWebOrigins(List.of("+"));
// backchannel logout URL
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "https://client.example.com/logout/");
clientRep.setAttributes(attributes);
// OIDC: postLogoutUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, List.of("+"));
// OAuth2 : jwks_uri
attributes.put(OIDCConfigAttributes.JWKS_URL, "https://client.example.com/jwks/");
clientRep.setAttributes(attributes);
// OIDC : requestUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.REQUEST_URIS, Arrays.asList("https://client.example.com/request/", "https://client.example.com/reqobj/"));
// CIBA Client Notification Endpoint
attributes.put(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT, "https://client.example.com/client-notification/");
clientRep.setAttributes(attributes);
});
} catch (Exception e) {
fail();
}
try { // Redirect relative wildcard - '+' with multiple values
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// rootUrl
clientRep.setRootUrl("https://client.example.com/");
// adminUrl
clientRep.setAdminUrl("https://client.example.com/admin/");
// baseUrl
clientRep.setBaseUrl("https://client.example.com/base/");
// OAuth2 : redirectUris
clientRep.setRedirectUris(List.of("https://client.example.com/redirect/"));
// web origins
clientRep.setWebOrigins(Arrays.asList("https://valid.other.client.example.com/", "https://valid.another.client.example.com/", "+"));
// backchannel logout URL
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "https://client.example.com/logout/");
clientRep.setAttributes(attributes);
// OIDC: postLogoutUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, Arrays.asList("https://client.example.com/postlogout/", "+"));
// OAuth2 : jwks_uri
attributes.put(OIDCConfigAttributes.JWKS_URL, "https://client.example.com/jwks/");
clientRep.setAttributes(attributes);
// OIDC : requestUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.REQUEST_URIS, Arrays.asList("https://client.example.com/request/", "https://client.example.com/reqobj/"));
// CIBA Client Notification Endpoint
attributes.put(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT, "https://client.example.com/client-notification/");
clientRep.setAttributes(attributes);
});
} catch (Exception e) {
fail();
}
try { // Redirect relative wildcard - '+' with no redirect value added
updateClientByAdmin(cid, (ClientRepresentation clientRep) -> {
// rootUrl
clientRep.setRootUrl("https://client.example.com/");
// adminUrl
clientRep.setAdminUrl("https://client.example.com/admin/");
// baseUrl
clientRep.setBaseUrl("https://client.example.com/base/");
// OAuth2 : redirectUris
clientRep.setRedirectUris(null);
// web origins
clientRep.setWebOrigins(Arrays.asList("+", "https://valid.other.client.example.com/"));
// backchannel logout URL
Map<String, String> attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
attributes.put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "https://client.example.com/logout/");
clientRep.setAttributes(attributes);
// OIDC: postLogoutUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, Arrays.asList("https://client.example.com/postlogout/", "+"));
// OAuth2 : jwks_uri
attributes.put(OIDCConfigAttributes.JWKS_URL, "https://client.example.com/jwks/");
clientRep.setAttributes(attributes);
// OIDC : requestUris
setAttributeMultivalued(clientRep, OIDCConfigAttributes.REQUEST_URIS, Arrays.asList("https://client.example.com/request/", "https://client.example.com/reqobj/"));
// CIBA Client Notification Endpoint
attributes.put(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT, "https://client.example.com/client-notification/");
clientRep.setAttributes(attributes);
});
} catch (Exception e) {
fail();
}
}
}