Add SAML url attributes to the SecureClientUrisPatternExecutor (#47514)

Closes #46745


Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2026-03-27 14:53:34 +01:00 committed by GitHub
parent f110573310
commit f2c7c673df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 526 additions and 242 deletions

View file

@ -210,17 +210,9 @@ public class SecureClientUrisPatternExecutor implements ClientPolicyExecutorProv
return getAttributeMultivalued(attributes, OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS);
case "cibaClientNotificationEndpoint":
return singletonOrEmpty(attributes.get(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT));
case OIDCConfigAttributes.LOGO_URI:
return singletonOrEmpty(attributes.get(OIDCConfigAttributes.LOGO_URI));
case OIDCConfigAttributes.POLICY_URI:
return singletonOrEmpty(attributes.get(OIDCConfigAttributes.POLICY_URI));
case OIDCConfigAttributes.TOS_URI:
return singletonOrEmpty(attributes.get(OIDCConfigAttributes.TOS_URI));
case OIDCConfigAttributes.SECTOR_IDENTIFIER_URI:
return singletonOrEmpty(attributes.get(OIDCConfigAttributes.SECTOR_IDENTIFIER_URI));
default:
logger.debugv("Field extraction not implemented for: {0}", fieldName);
return Collections.emptyList();
// for the rest just use the fieldName as the attribute name
return singletonOrEmpty(attributes.get(fieldName));
}
}

View file

@ -24,6 +24,8 @@ import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.provider.ProviderConfigProperty;
public class SecureClientUrisPatternExecutorFactory implements ClientPolicyExecutorProviderFactory {
@ -40,7 +42,7 @@ public class SecureClientUrisPatternExecutorFactory implements ClientPolicyExecu
"redirectUris",
"webOrigins",
//attributes
//attributes in OIDC clients
"jwksUri",
"requestUris",
"backchannelLogoutUrl",
@ -49,7 +51,18 @@ public class SecureClientUrisPatternExecutorFactory implements ClientPolicyExecu
OIDCConfigAttributes.LOGO_URI,
OIDCConfigAttributes.POLICY_URI,
OIDCConfigAttributes.TOS_URI,
OIDCConfigAttributes.SECTOR_IDENTIFIER_URI
OIDCConfigAttributes.SECTOR_IDENTIFIER_URI,
// attributes in SAML clients
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE,
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE,
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE,
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE,
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE,
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE,
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE,
SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE,
SamlConfigAttributes.SAML_METADATA_DESCRIPTOR_URL
);
private static final ProviderConfigProperty CLIENT_URI_FIELDS_PROPERTY = new ProviderConfigProperty(

View file

@ -9,7 +9,8 @@ public class OAuthTestFrameworkExtension implements TestFrameworkExtension {
@Override
public List<Supplier<?, ?>> suppliers() {
return List.of(new OAuthClientSupplier(), new TestAppSupplier(), new OAuthIdentityProviderSupplier(), new CimdProviderSupplier());
return List.of(new OAuthClientSupplier(), new TestAppSupplier(), new OAuthIdentityProviderSupplier(),
new CimdProviderSupplier(), new SectorIdentifierRedirectUrisSupplier());
}
}

View file

@ -0,0 +1,50 @@
package org.keycloak.testframework.oauth;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import jakarta.ws.rs.core.Response;
import org.keycloak.util.JsonSerialization;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
/**
*
* @author rmartinc
*/
public class SectorIdentifierRedirectUrisProvider implements Closeable {
public static final String CONTEXT = "/sector-identifier-redirect-uris";
private final HttpServer httpServer;
private final String[] sectorIdentifierRedirectUris;
public SectorIdentifierRedirectUrisProvider(HttpServer httpServer, String[] sectorIdentifierRedirectUris) {
this.httpServer = httpServer;
this.sectorIdentifierRedirectUris = sectorIdentifierRedirectUris;
this.httpServer.createContext(CONTEXT, new SectorIdentifierRedirectUrisHandler());
}
@Override
public void close() {
httpServer.removeContext(CONTEXT);
}
private class SectorIdentifierRedirectUrisHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String metadata = JsonSerialization.writeValueAsString(sectorIdentifierRedirectUris);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(Response.Status.OK.getStatusCode(), metadata.length());
try (OutputStream out = exchange.getResponseBody()) {
out.write(metadata.getBytes(StandardCharsets.UTF_8));
}
}
}
}

View file

@ -0,0 +1,41 @@
package org.keycloak.testframework.oauth;
import java.util.List;
import org.keycloak.testframework.injection.DependenciesBuilder;
import org.keycloak.testframework.injection.Dependency;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.oauth.annotations.InjectSectorIdentifierRedirectUrisProvider;
import com.sun.net.httpserver.HttpServer;
/**
*
* @author rmartinc
*/
public class SectorIdentifierRedirectUrisSupplier implements Supplier<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> {
@Override
public List<Dependency> getDependencies(RequestedInstance<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> instanceContext) {
return DependenciesBuilder.create(HttpServer.class).build();
}
@Override
public SectorIdentifierRedirectUrisProvider getValue(InstanceContext<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> instanceContext) {
HttpServer httpServer = instanceContext.getDependency(HttpServer.class);
String[] uris = instanceContext.getAnnotation().value();
return new SectorIdentifierRedirectUrisProvider(httpServer, uris);
}
@Override
public boolean compatible(InstanceContext<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> a, RequestedInstance<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> b) {
return a.getAnnotation().equals(b.getAnnotation());
}
@Override
public void close(InstanceContext<SectorIdentifierRedirectUrisProvider, InjectSectorIdentifierRedirectUrisProvider> instanceContext) {
instanceContext.getValue().close();
}
}

View file

@ -0,0 +1,20 @@
package org.keycloak.testframework.oauth.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.keycloak.testframework.injection.LifeCycle;
/**
*
* @author rmartinc
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectSectorIdentifierRedirectUrisProvider {
LifeCycle lifecycle() default LifeCycle.GLOBAL;
String[] value();
}

View file

@ -0,0 +1,164 @@
/*
* Copyright 2022 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.tests.client.policies;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.testframework.realm.ClientPolicyBuilder;
import org.keycloak.testframework.realm.ClientProfileBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.util.JsonSerialization;
import org.junit.jupiter.api.Assertions;
/**
*
* @author rmartinc
*/
public class AbstractClientPoliciesTest {
protected String generateSuffixedName(String name) {
return name + "-" + UUID.randomUUID().toString().subSequence(0, 7);
}
protected String createClientByAdmin(ManagedRealm realm, String clientName, String protocol, Consumer<ClientRepresentation> op) throws ClientPolicyException {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId(clientName);
clientRep.setName(clientName);
clientRep.setProtocol(protocol);
clientRep.setRedirectUris(Collections.singletonList(realm.getBaseUrl() + "/app/auth"));
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setPostLogoutRedirectUris(Collections.singletonList("+"));
if (protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
clientRep.setBearerOnly(Boolean.FALSE);
clientRep.setPublicClient(Boolean.FALSE);
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
} else {
clientRep.setPublicClient(Boolean.TRUE);
}
op.accept(clientRep);
try (Response resp = realm.admin().clients().create(clientRep)) {
if (resp.getStatus() == Response.Status.BAD_REQUEST.getStatusCode()) {
String respBody = resp.readEntity(String.class);
Map<String, String> responseJson = null;
try {
responseJson = JsonSerialization.readValue(respBody, Map.class);
} catch (IOException e) {
Assertions.fail();
}
throw new ClientPolicyException(responseJson.get(OAuth2Constants.ERROR), responseJson.get(OAuth2Constants.ERROR_DESCRIPTION));
}
Assertions.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
// registered components will be removed automatically when a test method finishes regardless of its success or failure.
String cId = ApiUtil.getCreatedId(resp);
realm.cleanup().add(r -> r.clients().delete(cId));
return cId;
}
}
protected ClientRepresentation findByClientIdByAdmin(ManagedRealm realm, String clientId) throws ClientPolicyException {
return realm.admin().clients().findByClientId(clientId).iterator().next();
}
protected void updateClientByAdmin(ManagedRealm realm, String cId, Consumer<ClientRepresentation> op) throws ClientPolicyException {
ClientResource clientResource = realm.admin().clients().get(cId);
ClientRepresentation clientRep = clientResource.toRepresentation();
op.accept(clientRep);
try {
clientResource.update(clientRep);
} catch (BadRequestException bre) {
processClientPolicyExceptionByAdmin(bre);
}
}
private void processClientPolicyExceptionByAdmin(BadRequestException bre) throws ClientPolicyException {
Response resp = bre.getResponse();
if (resp.getStatus() != Response.Status.BAD_REQUEST.getStatusCode()) {
resp.close();
return;
}
String respBody = resp.readEntity(String.class);
Map<String, String> responseJson = null;
try {
responseJson = JsonSerialization.readValue(respBody, Map.class);
} catch (IOException e) {
Assertions.fail();
}
throw new ClientPolicyException(responseJson.get(OAuth2Constants.ERROR), responseJson.get(OAuth2Constants.ERROR_DESCRIPTION));
}
protected String createClientDynamically(ManagedRealm realm, ClientRegistration reg, String clientName, Consumer<OIDCClientRepresentation> op) throws ClientRegistrationException {
OIDCClientRepresentation clientRep = new OIDCClientRepresentation();
clientRep.setClientName(clientName);
clientRep.setClientUri(realm.getBaseUrl());
clientRep.setRedirectUris(Collections.singletonList(realm.getBaseUrl() + "/app/auth"));
op.accept(clientRep);
// registered components will be removed automatically when a test method finishes regardless of its success or failure.
OIDCClientRepresentation response = reg.oidc().create(clientRep);
String clientId = response.getClientId();
realm.cleanup().add(r -> r.clients().delete(clientId));
return clientId;
}
protected void updateClientDynamically(ClientRegistration reg, String clientId, Consumer<OIDCClientRepresentation> op) throws ClientRegistrationException {
OIDCClientRepresentation clientRep = reg.oidc().get(clientId);
op.accept(clientRep);
OIDCClientRepresentation response = reg.oidc().update(clientRep);
reg.auth(Auth.token(response));
}
protected void setupPolicy(ManagedRealm realm, String executorId, ClientPolicyExecutorConfigurationRepresentation executorConfig,
String conditionId, ClientPolicyConditionConfigurationRepresentation conditionConfig) throws Exception {
realm.updateWithCleanup(r -> {
r.resetClientProfiles()
.clientProfile(ClientProfileBuilder.create()
.name("executor")
.description("executor description")
.executor(executorId, executorConfig)
.build());
r.resetClientPolicies()
.clientPolicy(ClientPolicyBuilder.create()
.name("policy")
.description("description of policy")
.condition(conditionId, conditionConfig)
.profile("executor")
.build());
return r;
});
}
}

View file

@ -0,0 +1,220 @@
/*
* Copyright 2026 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.tests.client.policies;
import java.util.Collections;
import java.util.List;
import java.util.function.BiConsumer;
import org.keycloak.OAuthErrorException;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistration;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.models.CibaConfig;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
import org.keycloak.representations.idm.ClientInitialAccessPresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
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.SecureClientUrisPatternExecutor;
import org.keycloak.services.clientpolicy.executor.SecureClientUrisPatternExecutorFactory;
import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy;
import org.keycloak.services.clientregistration.policy.impl.TrustedHostClientRegistrationPolicyFactory;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.SectorIdentifierRedirectUrisProvider;
import org.keycloak.testframework.oauth.annotations.InjectSectorIdentifierRedirectUrisProvider;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.server.KeycloakUrls;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
/**
*
* @author rmartinc
*/
@KeycloakIntegrationTest
public class ClientUrisPatternExecutorTest extends AbstractClientPoliciesTest {
private static final String SAFE_PATTERN = "^https://trusted\\.com.*";
private static final String VALID_URL = "https://trusted.com/callback";
private static final String INVALID_URL = "http://untrusted.com/callback";
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
@InjectRealm
protected ManagedRealm realm;
@InjectSectorIdentifierRedirectUrisProvider("http://localhost:8080/app")
protected SectorIdentifierRedirectUrisProvider sectorIdentifierRedirectUris;
@Test
public void testFieldsByAdmin() throws Exception {
testFieldByAdmin("rootUrl", OIDCLoginProtocol.LOGIN_PROTOCOL, ClientRepresentation::setRootUrl);
testFieldByAdmin("adminUrl", OIDCLoginProtocol.LOGIN_PROTOCOL, ClientRepresentation::setAdminUrl);
testFieldByAdmin("baseUrl", OIDCLoginProtocol.LOGIN_PROTOCOL, ClientRepresentation::setBaseUrl);
testFieldByAdmin("redirectUris", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.setRedirectUris(Collections.singletonList(val)));
testFieldByAdmin("webOrigins", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.setWebOrigins(Collections.singletonList(val)));
testFieldByAdmin("jwksUri", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.JWKS_URL, val));
testFieldByAdmin("requestUris", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.REQUEST_URIS, val));
testFieldByAdmin("backchannelLogoutUrl", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, val));
testFieldByAdmin("postLogoutRedirectUris", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, val));
testFieldByAdmin("cibaClientNotificationEndpoint", OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT, val));
testFieldByAdmin(OIDCConfigAttributes.LOGO_URI, OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.LOGO_URI, val));
testFieldByAdmin(OIDCConfigAttributes.POLICY_URI, OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.POLICY_URI, val));
testFieldByAdmin(OIDCConfigAttributes.TOS_URI, OIDCLoginProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.TOS_URI, val));
}
@Test
public void testFieldsByAdminSaml() throws Exception {
testFieldByAdmin("rootUrl", SamlProtocol.LOGIN_PROTOCOL, ClientRepresentation::setRootUrl);
testFieldByAdmin("adminUrl", SamlProtocol.LOGIN_PROTOCOL, ClientRepresentation::setAdminUrl);
testFieldByAdmin("baseUrl", SamlProtocol.LOGIN_PROTOCOL, ClientRepresentation::setBaseUrl);
testFieldByAdmin("redirectUris", SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.setRedirectUris(Collections.singletonList(val)));
testFieldByAdmin(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, val));
testFieldByAdmin(SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE, val));
testFieldByAdmin(SamlConfigAttributes.SAML_METADATA_DESCRIPTOR_URL, SamlProtocol.LOGIN_PROTOCOL, (c, val) -> c.getAttributes().put(SamlConfigAttributes.SAML_METADATA_DESCRIPTOR_URL, val));
}
@Test
public void testFieldsDynamically() throws Exception {
//remove trust registration policy
List<ComponentRepresentation> components = realm.admin().components().query(null, ClientRegistrationPolicy.class.getCanonicalName())
.stream()
.filter(c -> c.getProviderId().equals(TrustedHostClientRegistrationPolicyFactory.PROVIDER_ID))
.toList();
for (ComponentRepresentation component : components) {
realm.admin().components().removeComponent(component.getId());
}
ClientRegistration reg = ClientRegistration.create().url(keycloakUrls.getBase(), realm.getName()).build();
ClientInitialAccessPresentation token = realm.admin().clientInitialAccess().create(new ClientInitialAccessCreatePresentation(0, 10));
reg.auth(Auth.token(token));
testFieldDynamically(reg, "baseUrl", OIDCClientRepresentation::setClientUri);
testFieldDynamically(reg, "redirectUris", (c, val) -> c.setRedirectUris(Collections.singletonList(val)));
testFieldDynamically(reg, "jwksUri", OIDCClientRepresentation::setJwksUri);
testFieldDynamically(reg, "logoUri", OIDCClientRepresentation::setLogoUri);
testFieldDynamically(reg, "policyUri", OIDCClientRepresentation::setPolicyUri);
testFieldDynamically(reg, "backchannelLogoutUrl", OIDCClientRepresentation::setBackchannelLogoutUri);
//test sectorIdentifierUri
String sectorIdentifierUriPattern = "^http://localhost.*/sector-identifier-redirect-uris$";
String redirectUri = "http://localhost:8080/app";
testFieldDynamically(reg, "sectorIdentifierUri", ((c, s) -> {
c.setRedirectUris(Collections.singletonList(redirectUri));
c.setSubjectType("pairwise");
c.setSectorIdentifierUri(s);
}), sectorIdentifierUriPattern, "http://localhost:8500/sector-identifier-redirect-uris", INVALID_URL);
}
@Test
public void testInvalidPatternConfiguration() throws Exception {
setupPolicy(List.of("("), null);
String allFieldsClient = generateSuffixedName("invalid-config");
ClientPolicyException cpe = Assertions.assertThrows(ClientPolicyException.class, ()
-> createClientByAdmin(realm, allFieldsClient, OIDCLoginProtocol.LOGIN_PROTOCOL, (ClientRepresentation c) -> {
c.setRootUrl("invalid-url");
}));
Assertions.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, cpe.getError());
}
@Test
public void testEmptyPatternConfiguration() throws Exception {
setupPolicy(Collections.emptyList(), null);
String allFieldsClient = generateSuffixedName("invalid-config");
ClientPolicyException cpe = Assertions.assertThrows(ClientPolicyException.class, ()
-> createClientByAdmin(realm, allFieldsClient, OIDCLoginProtocol.LOGIN_PROTOCOL, (ClientRepresentation c) -> {
c.setRootUrl("invalid-url");
}));
Assertions.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, cpe.getError());
}
public void testFieldDynamically(ClientRegistration reg, String fieldName, BiConsumer<OIDCClientRepresentation, String> setter) throws Exception {
testFieldDynamically(reg, fieldName, setter, SAFE_PATTERN, VALID_URL, INVALID_URL);
}
public void testFieldDynamically(ClientRegistration reg, String fieldName, BiConsumer<OIDCClientRepresentation, String> setter, String pattern, String validUrl, String invalidUrl) throws Exception {
setupPolicy(List.of(pattern), List.of(fieldName));
//create with valid field
String validClientId = createClientDynamically(realm, reg, generateSuffixedName("valid-" + fieldName), (OIDCClientRepresentation c) -> setter.accept(c, validUrl));
//create invalid field
ClientRegistrationException cre = Assertions.assertThrows(ClientRegistrationException.class, ()
-> createClientDynamically(realm, reg, generateSuffixedName("invalid-" + fieldName), (OIDCClientRepresentation c) -> setter.accept(c, invalidUrl)));
Assertions.assertEquals("Failed to send request", cre.getMessage());
//try to update with invalid field
cre = Assertions.assertThrows(ClientRegistrationException.class, ()
-> updateClientDynamically(reg, validClientId, (OIDCClientRepresentation c) -> setter.accept(c, invalidUrl)));
Assertions.assertEquals("Failed to send request", cre.getMessage());
}
public void testFieldByAdmin(String fieldName, String protocol, BiConsumer<ClientRepresentation, String> setter) throws Exception {
setupPolicy(List.of(SAFE_PATTERN), List.of(fieldName));
//create with valid field
String validClientId = generateSuffixedName("valid-" + fieldName);
createClientByAdmin(realm, validClientId, protocol, (ClientRepresentation c) -> setter.accept(c, VALID_URL));
//create invalid field
ClientPolicyException cpe = Assertions.assertThrows(ClientPolicyException.class, ()
-> createClientByAdmin(realm, generateSuffixedName("invalid-" + fieldName), protocol, (ClientRepresentation c) -> setter.accept(c, INVALID_URL)));
Assertions.assertEquals("Invalid " + fieldName, cpe.getErrorDetail());
//try to update with invalid field
ClientRepresentation cRep = realm.admin().clients().findByClientId(validClientId).get(0);
cpe = Assertions.assertThrows(ClientPolicyException.class, ()
-> updateClientByAdmin(realm, cRep.getId(), (ClientRepresentation c) -> setter.accept(c, INVALID_URL)));
Assertions.assertEquals("Invalid " + fieldName, cpe.getErrorDetail());
}
private void setupPolicy(List<String> allowedPatterns, List<String> fieldsToValidate) throws Exception {
SecureClientUrisPatternExecutor.Configuration executorConfig = new SecureClientUrisPatternExecutor.Configuration();
executorConfig.setAllowedPatterns(allowedPatterns);
executorConfig.setClientUriFields(fieldsToValidate);
setupPolicy(realm, SecureClientUrisPatternExecutorFactory.PROVIDER_ID, executorConfig,
AnyClientConditionFactory.PROVIDER_ID, new ClientPolicyConditionConfigurationRepresentation());
}
}

View file

@ -680,15 +680,23 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
// Client CRUD operation by Admin REST API primitives
protected String createClientByAdmin(String clientName, Consumer<ClientRepresentation> op) throws ClientPolicyException {
return createClientByAdmin(clientName, OIDCLoginProtocol.LOGIN_PROTOCOL, op);
}
protected String createClientByAdmin(String clientName, String protocol, Consumer<ClientRepresentation> op) throws ClientPolicyException {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId(clientName);
clientRep.setName(clientName);
clientRep.setProtocol("openid-connect");
clientRep.setBearerOnly(Boolean.FALSE);
clientRep.setPublicClient(Boolean.FALSE);
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
clientRep.setProtocol(protocol);
clientRep.setRedirectUris(Collections.singletonList(ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"));
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setPostLogoutRedirectUris(Collections.singletonList("+"));
if (protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
clientRep.setBearerOnly(Boolean.FALSE);
clientRep.setPublicClient(Boolean.FALSE);
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
} else {
clientRep.setPublicClient(Boolean.TRUE);
}
op.accept(clientRep);
Response resp = adminClient.realm(REALM_NAME).clients().create(clientRep);
if (resp.getStatus() == Response.Status.BAD_REQUEST.getStatusCode()) {

View file

@ -1,225 +0,0 @@
/*
* Copyright 2026 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 java.util.Collections;
import java.util.List;
import java.util.function.BiConsumer;
import org.keycloak.OAuthErrorException;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.models.CibaConfig;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
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.SecureClientUrisPatternExecutor;
import org.keycloak.services.clientpolicy.executor.SecureClientUrisPatternExecutorFactory;
import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy;
import org.keycloak.services.clientregistration.policy.impl.TrustedHostClientRegistrationPolicyFactory;
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource;
import org.keycloak.testsuite.util.ClientPoliciesUtil;
import org.junit.Test;
import static org.keycloak.testsuite.AbstractAdminTest.loadJson;
import static org.keycloak.testsuite.AbstractTestRealmKeycloakTest.TEST_REALM_NAME;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
public class ClientUrisPatternExecutorTest extends AbstractClientPoliciesTest {
private static final String SAFE_PATTERN = "^https://trusted\\.com.*";
private static final String VALID_URL = "https://trusted.com/callback";
private static final String INVALID_URL = "http://untrusted.com/callback";
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
testRealms.add(realm);
}
@Test
public void testFieldsByAdmin() throws Exception {
testFieldByAdmin("rootUrl", ClientRepresentation::setRootUrl);
testFieldByAdmin("adminUrl", ClientRepresentation::setAdminUrl);
testFieldByAdmin("baseUrl", ClientRepresentation::setBaseUrl);
testFieldByAdmin("redirectUris", (c, val) -> c.setRedirectUris(Collections.singletonList(val)));
testFieldByAdmin("webOrigins", (c, val) -> c.setWebOrigins(Collections.singletonList(val)));
testFieldByAdmin("jwksUri", (c, val) -> c.getAttributes().put(OIDCConfigAttributes.JWKS_URL, val));
testFieldByAdmin("requestUris", (c, val) -> c.getAttributes().put(OIDCConfigAttributes.REQUEST_URIS, val));
testFieldByAdmin("backchannelLogoutUrl", (c, val) -> c.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, val));
testFieldByAdmin("postLogoutRedirectUris", (c, val) -> c.getAttributes().put(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, val));
testFieldByAdmin("cibaClientNotificationEndpoint", (c, val) -> c.getAttributes().put(CibaConfig.CIBA_BACKCHANNEL_CLIENT_NOTIFICATION_ENDPOINT, val));
testFieldByAdmin(OIDCConfigAttributes.LOGO_URI, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.LOGO_URI, val));
testFieldByAdmin(OIDCConfigAttributes.POLICY_URI, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.POLICY_URI, val));
testFieldByAdmin(OIDCConfigAttributes.TOS_URI, (c, val) -> c.getAttributes().put(OIDCConfigAttributes.TOS_URI, val));
}
@Test
public void testFieldsDynamically() throws Exception {
//remove trust registration policy
List<ComponentRepresentation> components = realmsResouce().realm(TEST_REALM_NAME).components().query(null, ClientRegistrationPolicy.class.getCanonicalName()).stream().filter(c -> c.getProviderId().equals(TrustedHostClientRegistrationPolicyFactory.PROVIDER_ID)).toList();
for (ComponentRepresentation component : components) {
realmsResouce().realm(TEST_REALM_NAME).components().removeComponent(component.getId());
}
testFieldDynamically("baseUrl", OIDCClientRepresentation::setClientUri);
testFieldDynamically("redirectUris", (c, val) -> c.setRedirectUris(Collections.singletonList(val)));
testFieldDynamically("jwksUri", OIDCClientRepresentation::setJwksUri);
testFieldDynamically("logoUri", OIDCClientRepresentation::setLogoUri);
testFieldDynamically("policyUri", OIDCClientRepresentation::setPolicyUri);
testFieldDynamically("backchannelLogoutUrl", OIDCClientRepresentation::setBackchannelLogoutUri);
//test sectorIdentifierUri
String redirectUri = oauth.getRedirectUri();
List<String> sectorRedirects = List.of(redirectUri);
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);
String sectorIdentifierUriPattern = "^https://localhost.*/get-sector-identifier-redirect-uris$";
testFieldDynamically("sectorIdentifierUri", ((c, s) -> {
c.setRedirectUris(Collections.singletonList(redirectUri));
c.setSubjectType("pairwise");
c.setSectorIdentifierUri(s);
}), sectorIdentifierUriPattern, TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), INVALID_URL);
}
public void testFieldByAdmin(String fieldName, BiConsumer<ClientRepresentation, String> setter) throws Exception {
setupPolicy(List.of(SAFE_PATTERN), List.of(fieldName));
//create with valid field
String validClientId = generateSuffixedName("valid-" + fieldName);
try {
createClientByAdmin(validClientId, (ClientRepresentation c) -> setter.accept(c, VALID_URL));
} catch (Exception e) {
fail("Create failed for valid URI on field " + fieldName + ": " + e.getMessage());
}
//create invalid field
try {
createClientByAdmin(generateSuffixedName("invalid-" + fieldName), (ClientRepresentation c) -> setter.accept(c, INVALID_URL));
fail("Create should have failed for invalid URI on field: " + fieldName);
} catch (ClientPolicyException e) {
assertEquals("Invalid " + fieldName, e.getErrorDetail());
}
//try to update with invalid field
try {
ClientRepresentation cRep = getAdminClient().realm(REALM_NAME).clients().findByClientId(validClientId).get(0);
updateClientByAdmin(cRep.getId(), (ClientRepresentation c) -> setter.accept(c, INVALID_URL));
fail("Update should have failed for invalid URI on field: " + fieldName);
} catch (ClientPolicyException e) {
assertEquals("Invalid " + fieldName, e.getErrorDetail());
}
}
public void testFieldDynamically(String fieldName, BiConsumer<OIDCClientRepresentation, String> setter) throws Exception {
testFieldDynamically(fieldName, setter, SAFE_PATTERN, VALID_URL, INVALID_URL);
}
public void testFieldDynamically(String fieldName, BiConsumer<OIDCClientRepresentation, String> setter, String pattern, String validUrl, String invalidUrl) throws Exception {
setupPolicy(List.of(pattern), List.of(fieldName));
//create with valid field
String validClientId = null;
try {
validClientId = createClientDynamically(generateSuffixedName("valid-" + fieldName), (OIDCClientRepresentation c) -> setter.accept(c, validUrl));
} catch (Exception e) {
fail("Create failed for valid URI on field " + fieldName + ": " + e.getMessage());
}
//create invalid field
try {
createClientDynamically(generateSuffixedName("invalid-" + fieldName), (OIDCClientRepresentation c) -> setter.accept(c, invalidUrl));
fail("Create should have failed for invalid URI on field: " + fieldName);
} catch (ClientRegistrationException e) {
assertEquals("Failed to send request", e.getMessage());
}
//try to update with invalid field
try {
updateClientDynamically(validClientId, (OIDCClientRepresentation c) -> setter.accept(c, invalidUrl));
fail("Update should have failed for invalid URI on field: " + fieldName);
} catch (ClientRegistrationException e) {
assertEquals("Failed to send request", e.getMessage());
}
}
@Test
public void testInvalidPatternConfiguration() throws Exception {
setupPolicy(List.of("("), null);
String allFieldsClient = generateSuffixedName("invalid-config");
try {
createClientByAdmin(allFieldsClient, (ClientRepresentation c) -> {
c.setRootUrl("invalid-url");
});
fail("Should fail because regex is invalid");
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
}
}
@Test
public void testEmptyPatternConfiguration() throws Exception {
setupPolicy(Collections.emptyList(), null);
String allFieldsClient = generateSuffixedName("invalid-config");
try {
createClientByAdmin(allFieldsClient, (ClientRepresentation c) -> {
c.setRootUrl("invalid-url");
});
fail();
} catch (ClientPolicyException e) {
assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getError());
}
}
private void setupPolicy(List<String> allowedPatterns, List<String> fieldsToValidate) throws Exception {
SecureClientUrisPatternExecutor.Configuration config = new SecureClientUrisPatternExecutor.Configuration();
config.setAllowedPatterns(allowedPatterns);
config.setClientUriFields(fieldsToValidate);
String jsonProfile = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile(
(new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Test Profile")
.addExecutor(SecureClientUrisPatternExecutorFactory.PROVIDER_ID, config)
.toRepresentation()
).toString();
updateProfiles(jsonProfile);
String jsonPolicy = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy(
(new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Test Policy", Boolean.TRUE)
.addCondition(AnyClientConditionFactory.PROVIDER_ID, createAnyClientConditionConfig())
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(jsonPolicy);
}
}