From f6f3f660abcdffda9fb6b802faa1d95b7225cc0a Mon Sep 17 00:00:00 2001 From: Konstantinos Georgilakis Date: Wed, 23 Feb 2022 09:53:30 +0200 Subject: [PATCH 1/2] RCIAM-859 hide scopes from scopes_supported in discovery endpoint Closes #10388 Signed-off-by: cgeorgilakis-grnet --- .../admin/messages/messages_en.properties | 4 +- .../src/client-scopes/details/ScopeForm.tsx | 12 ++++ .../delegate/ClientModelLazyDelegate.java | 10 ++++ .../org/keycloak/models/ClientScopeModel.java | 10 ++++ .../protocol/oidc/OIDCWellKnownProvider.java | 2 +- .../oidc/AbstractWellKnownProviderTest.java | 56 +++++++++++++++++++ .../oidc/OIDCWellKnownProviderTest.java | 40 ------------- 7 files changed, 92 insertions(+), 42 deletions(-) diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index f156e5311fce..930630584143 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3542,6 +3542,8 @@ oid4vciEnabled=Enable OID4VCI oid4vciEnabledHelp=Enable this option to allow the client to request verifiable credentials from Keycloak's OID4VCI credential endpoint. noAccessPolicies=No access policies noAccessPoliciesInstructions=There haven't been configured any access policies yet. Click the button below to configure the first policy. +includeInOpenIdProviderMetadata=Include in OpenID Provider Metadata +includeInOpenIdProviderMetadataHelp=If on, this client scope will be included in OpenID Provider Metadata. # standard error responses OAuth invalid_request=Invalid request unauthorized_client=Unauthorized client @@ -3604,4 +3606,4 @@ changeStatusTooltip=Enable or disable this workflow workflowEnabled=Workflow enabled workflowDisabled=Workflow disabled workflowUpdated=Workflow updated successfully -workflowUpdateError=Could not update the workflow\: {{error}} \ No newline at end of file +workflowUpdateError=Could not update the workflow\: {{error}} diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index 6c2c3739ace7..b77c25c5e32a 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -64,6 +64,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { const isOid4vcProtocol = selectedProtocol === OID4VC_PROTOCOL; const isOid4vcEnabled = isFeatureEnabled(Feature.OpenId4VCI); + const isNotSaml = selectedProtocol != "saml"; const setDynamicRegex = (value: string, append: boolean) => setValue( @@ -190,6 +191,17 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { labelIcon={t("includeInTokenScopeHelp")} stringify /> + {isNotSaml && ( + ( + "attributes.include.in.openid.provider.metadata", + )} + defaultValue="true" + label={t("includeInOpenIdProviderMetadata")} + labelIcon={t("includeInOpenIdProviderMetadataHelp")} + stringify + /> + )} ( "attributes.gui.order", diff --git a/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java b/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java index 049c548149ef..c713161965a6 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java +++ b/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java @@ -525,6 +525,16 @@ public String getDynamicScopeRegexp() { return getDelegate().getDynamicScopeRegexp(); } + @Override + public boolean isIncludedInOpenIDProviderMetadata() { + return getDelegate().isIncludedInOpenIDProviderMetadata(); + } + + @Override + public void setIncludedInOpenIDProviderMetadata(boolean hideFromOpenIDProviderMetadata) { + getDelegate().setIncludedInOpenIDProviderMetadata(hideFromOpenIDProviderMetadata); + } + @Override public Stream getScopeMappingsStream() { return getDelegate().getScopeMappingsStream(); diff --git a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java index 6d17e448c465..17294deb074f 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java @@ -73,6 +73,7 @@ interface ClientScopeCreatedEvent extends ProviderEvent { String INCLUDE_IN_TOKEN_SCOPE = "include.in.token.scope"; String IS_DYNAMIC_SCOPE = "is.dynamic.scope"; String DYNAMIC_SCOPE_REGEXP = "dynamic.scope.regexp"; + String INCLUDE_IN_OPENID_PROVIDER_METADATA = "include.in.openid.provider.metadata"; default boolean isDisplayOnConsentScreen() { String displayVal = getAttribute(DISPLAY_ON_CONSENT_SCREEN); @@ -125,4 +126,13 @@ default void setIsDynamicScope(boolean isDynamicScope) { default String getDynamicScopeRegexp() { return getAttribute(DYNAMIC_SCOPE_REGEXP); } + + default boolean isIncludedInOpenIDProviderMetadata() { + String includedInOpenIDProviderMetadata = getAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA); + return includedInOpenIDProviderMetadata == null ? true : Boolean.parseBoolean(includedInOpenIDProviderMetadata); + } + + default void setIncludedInOpenIDProviderMetadata(boolean includedInOpenIDProviderMetadata) { + setAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA, String.valueOf(includedInOpenIDProviderMetadata)); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 40b79b33f124..fc25a190c67e 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -169,7 +169,7 @@ public Object getConfig() { // Include client scopes can be disabled in the environments with thousands of client scopes to avoid potentially expensive iteration over client scopes if (includeClientScopes) { List scopeNames = realm.getClientScopesStream() - .filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol())) + .filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()) && clientScope.isIncludedInOpenIDProviderMetadata()) .map(ClientScopeModel::getName) .collect(Collectors.toList()); if (!scopeNames.contains(OAuth2Constants.SCOPE_OPENID)) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java index 977236ab7ba3..28d3feb87441 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AbstractWellKnownProviderTest.java @@ -36,13 +36,16 @@ import org.keycloak.http.simple.SimpleHttpResponse; import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.models.ClientScopeModel; import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.representations.MTLSEndpointAliases; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.clientregistration.ClientRegistrationService; import org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory; @@ -51,6 +54,7 @@ import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AbstractAdminTest; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.forms.BrowserFlowTest; import org.keycloak.testsuite.forms.LevelOfAssuranceFlowTest; @@ -60,6 +64,7 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.testsuite.util.TokenSignatureUtil; +import org.keycloak.testsuite.wellknown.CustomOIDCWellKnownProviderFactory; import org.keycloak.util.JsonSerialization; import java.io.IOException; @@ -67,6 +72,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static jakarta.ws.rs.core.HttpHeaders.ACCEPT; import static jakarta.ws.rs.core.HttpHeaders.CONTENT_TYPE; @@ -388,6 +395,55 @@ public void testDpopSigningAlgValuesSupportedWithDpop() throws IOException { } } + @Test + public void testDefaultProviderCustomizations() throws IOException { + Client client = AdminClientUtil.createResteasyClient(); + String showScopeId = null; + String hideScopeId = null; + try { + OIDCConfigurationRepresentation oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + + // Exact names already tested in OIDC + assertScopesSupportedMatchesWithRealm(oidcConfig); + + //create 2 client scope - one with hideFromOpenIDProviderMetadata equal to true + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("show-scope"); + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Response resp = adminClient.realm("test").clientScopes().create(clientScope); + showScopeId = ApiUtil.getCreatedId(resp); + resp.close(); + + ClientScopeRepresentation clientScope2 = new ClientScopeRepresentation(); + clientScope2.setName("hidden-scope"); + clientScope2.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map attributes = new HashMap<>(); + attributes.put(ClientScopeModel.INCLUDE_IN_OPENID_PROVIDER_METADATA,"false"); + clientScope2.setAttributes(attributes); + Response resp2 = adminClient.realm("test").clientScopes().create(clientScope2); + hideScopeId = ApiUtil.getCreatedId(resp2); + resp2.close(); + + List expectedScopeList = Stream.of(OAuth2Constants.SCOPE_OPENID, OAuth2Constants.OFFLINE_ACCESS, + OAuth2Constants.SCOPE_PROFILE, OAuth2Constants.SCOPE_EMAIL, OAuth2Constants.SCOPE_PHONE, OAuth2Constants.SCOPE_ADDRESS, OIDCLoginProtocolFactory.ACR_SCOPE, OIDCLoginProtocolFactory.BASIC_SCOPE, + OIDCLoginProtocolFactory.ROLES_SCOPE, OIDCLoginProtocolFactory.WEB_ORIGINS_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE, OAuth2Constants.ORGANIZATION, + ServiceAccountConstants.SERVICE_ACCOUNT_SCOPE, "show-scope").collect(Collectors.toList()); + oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + assertScopesSupportedMatchesWithRealm(oidcConfig, expectedScopeList); + } finally { + getTestingClient().testing().setSystemPropertyOnServer(CustomOIDCWellKnownProviderFactory.INCLUDE_CLIENT_SCOPES, null); + if ( showScopeId != null) + adminClient.realm("test").clientScopes().get(showScopeId).remove(); + if ( hideScopeId != null) + adminClient.realm("test").clientScopes().get(hideScopeId).remove(); + client.close(); + } + } + + private void assertScopesSupportedMatchesWithRealm(OIDCConfigurationRepresentation oidcConfig, List expectedScopeList) { + Assert.assertNames(oidcConfig.getScopesSupported(), expectedScopeList.toArray(new String[expectedScopeList.size()]) ); + } + protected void assertScopesSupportedMatchesWithRealm(OIDCConfigurationRepresentation oidcConfig) { Assert.assertNames(oidcConfig.getScopesSupported(), OAuth2Constants.SCOPE_OPENID, OAuth2Constants.OFFLINE_ACCESS, OAuth2Constants.SCOPE_PROFILE, OAuth2Constants.SCOPE_EMAIL, OAuth2Constants.SCOPE_PHONE, OAuth2Constants.SCOPE_ADDRESS, OIDCLoginProtocolFactory.ACR_SCOPE, OIDCLoginProtocolFactory.BASIC_SCOPE, diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index 20058e97e414..74d015c96e10 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -17,18 +17,7 @@ package org.keycloak.testsuite.oidc; -import jakarta.ws.rs.client.Client; -import org.junit.Test; import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory; -import org.keycloak.protocol.oidc.representations.MTLSEndpointAliases; -import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; -import org.keycloak.testsuite.Assert; -import org.keycloak.testsuite.util.AdminClientUtil; -import org.keycloak.testsuite.util.oauth.OAuthClient; -import org.keycloak.testsuite.wellknown.CustomOIDCWellKnownProviderFactory; - -import java.io.IOException; -import java.util.Map; /** * @author Marek Posolda @@ -39,33 +28,4 @@ protected String getWellKnownProviderId() { return OIDCWellKnownProviderFactory.PROVIDER_ID; } - @Test - public void testDefaultProviderCustomizations() throws IOException { - Client client = AdminClientUtil.createResteasyClient(); - try { - OIDCConfigurationRepresentation oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); - - // Assert that CustomOIDCWellKnownProvider was used as a prioritized provider over default OIDCWellKnownProvider - MTLSEndpointAliases mtlsEndpointAliases = oidcConfig.getMtlsEndpointAliases(); - Assert.assertEquals("https://placeholder-host-set-by-testsuite-provider/registration", mtlsEndpointAliases.getRegistrationEndpoint()); - Assert.assertEquals("bar", oidcConfig.getOtherClaims().get("foo")); - - // Assert some configuration was overriden - Assert.assertEquals("some-new-property-value", oidcConfig.getOtherClaims().get("some-new-property")); - Assert.assertEquals("nested-value", ((Map) oidcConfig.getOtherClaims().get("some-new-property-compound")).get("nested1")); - Assert.assertNames(oidcConfig.getIntrospectionEndpointAuthMethodsSupported(), "private_key_jwt", "client_secret_jwt", "tls_client_auth", "custom_nonexisting_authenticator"); - - // Exact names already tested in OIDC - assertScopesSupportedMatchesWithRealm(oidcConfig); - - // Temporarily disable client scopes - getTestingClient().testing().setSystemPropertyOnServer(CustomOIDCWellKnownProviderFactory.INCLUDE_CLIENT_SCOPES, "false"); - oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); - Assert.assertNull(oidcConfig.getScopesSupported()); - } finally { - getTestingClient().testing().setSystemPropertyOnServer(CustomOIDCWellKnownProviderFactory.INCLUDE_CLIENT_SCOPES, null); - client.close(); - } - } - } From c50501ca4a4ef3987da2a580e17ff110e7fade71 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Mon, 3 Nov 2025 15:23:35 +0100 Subject: [PATCH 2/2] Review Signed-off-by: Alexander Schwartz --- .../server_admin/topics/threat/scope.adoc | 11 ++++++++++- .../models/delegate/ClientModelLazyDelegate.java | 8 ++++---- .../java/org/keycloak/models/ClientScopeModel.java | 10 +++++----- .../keycloak/protocol/oidc/OIDCWellKnownProvider.java | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/documentation/server_admin/topics/threat/scope.adoc b/docs/documentation/server_admin/topics/threat/scope.adoc index 6681551cea9e..92d771f17771 100644 --- a/docs/documentation/server_admin/topics/threat/scope.adoc +++ b/docs/documentation/server_admin/topics/threat/scope.adoc @@ -1,6 +1,15 @@ === Limiting scope -By default, new client applications have unlimited `role scope mappings`. Every access token for that client contains all permissions that the user has. If an attacker compromises the client and obtains the client's access tokens, each system that the user can access is compromised. +==== Scope availability + +By default, new client applications have unlimited `role scope mappings`. Every access token for that client contains all permissions that the user has. If an attacker compromises the client and obtains the client's access tokens, each system that the user can access is compromised. Limit the roles of an access token by using the <<_role_scope_mappings, Scope menu>> for each client. Alternatively, you can set role scope mappings at the Client Scope level and assign Client Scopes to your client by using the <<_client_scopes_linking, Client Scope menu>>. + +Removing the offline scope for a client also removes the ability to issue long-lived offline tokens for a client and offers better control over sessions by users. + +==== Scope visibility + +By default, all scopes are included in the OpenID Connect discovery endpoint. +To reduce the discoverability and OSINT-exposure, you can configure each scope to be excluded. diff --git a/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java b/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java index c713161965a6..2dd98f375cbb 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java +++ b/server-spi-private/src/main/java/org/keycloak/models/delegate/ClientModelLazyDelegate.java @@ -526,13 +526,13 @@ public String getDynamicScopeRegexp() { } @Override - public boolean isIncludedInOpenIDProviderMetadata() { - return getDelegate().isIncludedInOpenIDProviderMetadata(); + public boolean isIncludeInOpenIDProviderMetadata() { + return getDelegate().isIncludeInOpenIDProviderMetadata(); } @Override - public void setIncludedInOpenIDProviderMetadata(boolean hideFromOpenIDProviderMetadata) { - getDelegate().setIncludedInOpenIDProviderMetadata(hideFromOpenIDProviderMetadata); + public void setIncludeInOpenIDProviderMetadata(boolean includeInOpenIDProviderMetadata) { + getDelegate().setIncludeInOpenIDProviderMetadata(includeInOpenIDProviderMetadata); } @Override diff --git a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java index 17294deb074f..4a438aa2604f 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientScopeModel.java @@ -127,12 +127,12 @@ default String getDynamicScopeRegexp() { return getAttribute(DYNAMIC_SCOPE_REGEXP); } - default boolean isIncludedInOpenIDProviderMetadata() { - String includedInOpenIDProviderMetadata = getAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA); - return includedInOpenIDProviderMetadata == null ? true : Boolean.parseBoolean(includedInOpenIDProviderMetadata); + default boolean isIncludeInOpenIDProviderMetadata() { + String includeInOpenIDProviderMetadata = getAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA); + return includeInOpenIDProviderMetadata == null ? true : Boolean.parseBoolean(includeInOpenIDProviderMetadata); } - default void setIncludedInOpenIDProviderMetadata(boolean includedInOpenIDProviderMetadata) { - setAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA, String.valueOf(includedInOpenIDProviderMetadata)); + default void setIncludeInOpenIDProviderMetadata(boolean includeInOpenIDProviderMetadata) { + setAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA, String.valueOf(includeInOpenIDProviderMetadata)); } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index fc25a190c67e..64c59224cd03 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -169,7 +169,7 @@ public Object getConfig() { // Include client scopes can be disabled in the environments with thousands of client scopes to avoid potentially expensive iteration over client scopes if (includeClientScopes) { List scopeNames = realm.getClientScopesStream() - .filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()) && clientScope.isIncludedInOpenIDProviderMetadata()) + .filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()) && clientScope.isIncludeInOpenIDProviderMetadata()) .map(ClientScopeModel::getName) .collect(Collectors.toList()); if (!scopeNames.contains(OAuth2Constants.SCOPE_OPENID)) {