diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutor.java index 84a34af3410..32ac9caf9f2 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutor.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutor.java @@ -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)); } } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutorFactory.java index 34f7b16cb1b..2989dcc7a42 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutorFactory.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientUrisPatternExecutorFactory.java @@ -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( diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthTestFrameworkExtension.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthTestFrameworkExtension.java index dc1ed8d95cf..001a177edf8 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthTestFrameworkExtension.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthTestFrameworkExtension.java @@ -9,7 +9,8 @@ public class OAuthTestFrameworkExtension implements TestFrameworkExtension { @Override public List> 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()); } } diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisProvider.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisProvider.java new file mode 100644 index 00000000000..f9dc83d274c --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisProvider.java @@ -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)); + } + } + } +} diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisSupplier.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisSupplier.java new file mode 100644 index 00000000000..859a48edc26 --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/SectorIdentifierRedirectUrisSupplier.java @@ -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 { + + @Override + public List getDependencies(RequestedInstance instanceContext) { + return DependenciesBuilder.create(HttpServer.class).build(); + } + + @Override + public SectorIdentifierRedirectUrisProvider getValue(InstanceContext instanceContext) { + HttpServer httpServer = instanceContext.getDependency(HttpServer.class); + String[] uris = instanceContext.getAnnotation().value(); + return new SectorIdentifierRedirectUrisProvider(httpServer, uris); + } + + @Override + public boolean compatible(InstanceContext a, RequestedInstance b) { + return a.getAnnotation().equals(b.getAnnotation()); + } + + @Override + public void close(InstanceContext instanceContext) { + instanceContext.getValue().close(); + } +} diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectSectorIdentifierRedirectUrisProvider.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectSectorIdentifierRedirectUrisProvider.java new file mode 100644 index 00000000000..56ca8f4ead9 --- /dev/null +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/annotations/InjectSectorIdentifierRedirectUrisProvider.java @@ -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(); +} diff --git a/tests/base/src/test/java/org/keycloak/tests/client/policies/AbstractClientPoliciesTest.java b/tests/base/src/test/java/org/keycloak/tests/client/policies/AbstractClientPoliciesTest.java new file mode 100644 index 00000000000..70f84015c72 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/policies/AbstractClientPoliciesTest.java @@ -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 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 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 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 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 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 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; + }); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/client/policies/ClientUrisPatternExecutorTest.java b/tests/base/src/test/java/org/keycloak/tests/client/policies/ClientUrisPatternExecutorTest.java new file mode 100644 index 00000000000..3a58391420f --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/client/policies/ClientUrisPatternExecutorTest.java @@ -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 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 setter) throws Exception { + testFieldDynamically(reg, fieldName, setter, SAFE_PATTERN, VALID_URL, INVALID_URL); + } + + public void testFieldDynamically(ClientRegistration reg, String fieldName, BiConsumer 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 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 allowedPatterns, List 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()); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java index 920ddb3d9c6..9f183c1a5ec 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java @@ -680,15 +680,23 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest { // Client CRUD operation by Admin REST API primitives protected String createClientByAdmin(String clientName, Consumer op) throws ClientPolicyException { + return createClientByAdmin(clientName, OIDCLoginProtocol.LOGIN_PROTOCOL, op); + } + + protected String createClientByAdmin(String clientName, String protocol, Consumer 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()) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientUrisPatternExecutorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientUrisPatternExecutorTest.java deleted file mode 100644 index 7b104edf1c6..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientUrisPatternExecutorTest.java +++ /dev/null @@ -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 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 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 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 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 setter) throws Exception { - testFieldDynamically(fieldName, setter, SAFE_PATTERN, VALID_URL, INVALID_URL); - } - - public void testFieldDynamically(String fieldName, BiConsumer 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 allowedPatterns, List 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); - } -}