hide scopes from scopes_supported in discovery endpoint

Closes #10388

Signed-off-by: cgeorgilakis-grnet <cgeorgilakis@admin.grnet.gr>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
This commit is contained in:
KONSTANTINOS GEORGILAKIS 2025-11-03 18:26:12 +02:00 committed by GitHub
parent 2216ada20b
commit 1c0d4616a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 43 deletions

View File

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

View File

@ -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}}
workflowUpdateError=Could not update the workflow\: {{error}}

View File

@ -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 && (
<DefaultSwitchControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.include.in.openid.provider.metadata",
)}
defaultValue="true"
label={t("includeInOpenIdProviderMetadata")}
labelIcon={t("includeInOpenIdProviderMetadataHelp")}
stringify
/>
)}
<TextControl
name={convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.gui.order",

View File

@ -525,6 +525,16 @@ public class ClientModelLazyDelegate implements ClientModel {
return getDelegate().getDynamicScopeRegexp();
}
@Override
public boolean isIncludeInOpenIDProviderMetadata() {
return getDelegate().isIncludeInOpenIDProviderMetadata();
}
@Override
public void setIncludeInOpenIDProviderMetadata(boolean includeInOpenIDProviderMetadata) {
getDelegate().setIncludeInOpenIDProviderMetadata(includeInOpenIDProviderMetadata);
}
@Override
public Stream<RoleModel> getScopeMappingsStream() {
return getDelegate().getScopeMappingsStream();

View File

@ -73,6 +73,7 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
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 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
default String getDynamicScopeRegexp() {
return getAttribute(DYNAMIC_SCOPE_REGEXP);
}
default boolean isIncludeInOpenIDProviderMetadata() {
String includeInOpenIDProviderMetadata = getAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA);
return includeInOpenIDProviderMetadata == null ? true : Boolean.parseBoolean(includeInOpenIDProviderMetadata);
}
default void setIncludeInOpenIDProviderMetadata(boolean includeInOpenIDProviderMetadata) {
setAttribute(INCLUDE_IN_OPENID_PROVIDER_METADATA, String.valueOf(includeInOpenIDProviderMetadata));
}
}

View File

@ -169,7 +169,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
// 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<String> scopeNames = realm.getClientScopesStream()
.filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()))
.filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()) && clientScope.isIncludeInOpenIDProviderMetadata())
.map(ClientScopeModel::getName)
.collect(Collectors.toList());
if (!scopeNames.contains(OAuth2Constants.SCOPE_OPENID)) {

View File

@ -36,13 +36,16 @@ import org.keycloak.crypto.Algorithm;
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.services.resources.RealmsResource;
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.AccessTokenResponse;
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.net.URI;
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 abstract class AbstractWellKnownProviderTest extends AbstractKeycloakTest
}
}
@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<String,String> 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<String> 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<String> 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,

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -39,33 +28,4 @@ public class OIDCWellKnownProviderTest extends AbstractWellKnownProviderTest {
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();
}
}
}