KEYCLOAK-18903 More customizable OIDC WellKnown provider

This commit is contained in:
mposolda 2021-07-28 10:27:40 +02:00 committed by Marek Posolda
parent 7efc3e8170
commit 9b0e1fff8d
11 changed files with 315 additions and 17 deletions

View file

@ -46,6 +46,7 @@ import org.keycloak.services.clientregistration.ClientRegistrationService;
import org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.urls.UrlType;
import org.keycloak.util.JsonSerialization;
import org.keycloak.wellknown.WellKnownProvider;
import javax.ws.rs.core.UriBuilder;
@ -88,10 +89,18 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
public static final List<String> DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED = list(OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256);
private KeycloakSession session;
private final KeycloakSession session;
private final Map<String, Object> openidConfigOverride;
private final boolean includeClientScopes;
public OIDCWellKnownProvider(KeycloakSession session) {
this(session, null, true);
}
public OIDCWellKnownProvider(KeycloakSession session, Map<String, Object> openidConfigOverride, boolean includeClientScopes) {
this.session = session;
this.openidConfigOverride = openidConfigOverride;
this.includeClientScopes = includeClientScopes;
}
@Override
@ -150,12 +159,15 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setClaimTypesSupported(DEFAULT_CLAIM_TYPES_SUPPORTED);
config.setClaimsParameterSupported(true);
List<String> scopeNames = realm.getClientScopesStream()
.filter(clientScope -> Objects.equals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientScope.getProtocol()))
.map(ClientScopeModel::getName)
.collect(Collectors.toList());
scopeNames.add(0, OAuth2Constants.SCOPE_OPENID);
config.setScopesSupported(scopeNames);
// 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()))
.map(ClientScopeModel::getName)
.collect(Collectors.toList());
scopeNames.add(0, OAuth2Constants.SCOPE_OPENID);
config.setScopesSupported(scopeNames);
}
config.setRequestParameterSupported(true);
config.setRequestUriParameterSupported(true);
@ -190,6 +202,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
MTLSEndpointAliases mtlsEndpointAliases = getMtlsEndpointAliases(config);
config.setMtlsEndpointAliases(mtlsEndpointAliases);
config = checkConfigOverride(config);
return config;
}
@ -269,4 +282,15 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
mtls_endpoints.setPushedAuthorizationRequestEndpoint(config.getPushedAuthorizationRequestEndpoint());
return mtls_endpoints;
}
private OIDCConfigurationRepresentation checkConfigOverride(OIDCConfigurationRepresentation config) {
if (openidConfigOverride != null) {
Map<String, Object> asMap = JsonSerialization.mapper.convertValue(config, Map.class);
// Override configuration
asMap.putAll(openidConfigOverride);
return JsonSerialization.mapper.convertValue(asMap, OIDCConfigurationRepresentation.class);
} else {
return config;
}
}
}

View file

@ -17,9 +17,16 @@
package org.keycloak.protocol.oidc;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.common.util.FindFile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.util.JsonSerialization;
import org.keycloak.wellknown.WellKnownProvider;
import org.keycloak.wellknown.WellKnownProviderFactory;
@ -30,13 +37,36 @@ public class OIDCWellKnownProviderFactory implements WellKnownProviderFactory {
public static final String PROVIDER_ID = "openid-configuration";
private static final Logger logger = Logger.getLogger(OIDCWellKnownProviderFactory.class);
private Map<String, Object> openidConfigOverride = null;
private boolean includeClientScopes = true;
@Override
public WellKnownProvider create(KeycloakSession session) {
return new OIDCWellKnownProvider(session);
return new OIDCWellKnownProvider(session, openidConfigOverride, includeClientScopes);
}
@Override
public void init(Config.Scope config) {
String openidConfigurationOverride = config.get("openid-configuration-override");
this.includeClientScopes = config.getBoolean("include-client-scopes", true);
logger.debugf("Include Client Scopes in OIDC Well-known endpoint: %s", this.includeClientScopes);
if (openidConfigurationOverride != null) {
initConfigOverrideFromFile(openidConfigurationOverride);
}
}
protected void initConfigOverrideFromFile(String openidConfigurationOverrideFile) {
try {
InputStream is = FindFile.findFile(openidConfigurationOverrideFile);
this.openidConfigOverride = JsonSerialization.readValue(is, Map.class);
logger.infof("Overriding default OIDC well-known endpoint configuration with the options from file '%s'", openidConfigurationOverrideFile);
} catch (RuntimeException re) {
logger.warnf(re, "Unable to find file specified for openid-configuration-override on custom location '%s'. Will stick to the default configuration for OIDC WellKnown endpoint", openidConfigurationOverrideFile);
} catch (IOException ioe) {
logger.warnf(ioe, "Error when trying to deserialize JSON from the file '%s'. Check the JSON format. Will stick to the default configuration for OIDC WellKnown endpoint", openidConfigurationOverrideFile);
}
}
@Override
@ -52,4 +82,13 @@ public class OIDCWellKnownProviderFactory implements WellKnownProviderFactory {
return PROVIDER_ID;
}
// Custom implementation with alias "openid-configuration" should win over this default one
@Override
public int getPriority() {
return 100;
}
protected Map<String, Object> getOpenidConfigOverride() {
return openidConfigOverride;
}
}

View file

@ -31,6 +31,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.clientregistration.ClientRegistrationService;
import org.keycloak.services.managers.RealmManager;
@ -40,6 +41,7 @@ import org.keycloak.services.util.CacheControlUtil;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.utils.ProfileHelper;
import org.keycloak.wellknown.WellKnownProvider;
import org.keycloak.wellknown.WellKnownProviderFactory;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
@ -47,7 +49,6 @@ import javax.ws.rs.OPTIONS;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@ -55,6 +56,8 @@ import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.Comparator;
import java.util.Optional;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -243,14 +246,22 @@ public class RealmsResource {
}
@GET
@Path("{realm}/.well-known/{provider}")
@Path("{realm}/.well-known/{alias}")
@Produces(MediaType.APPLICATION_JSON)
public Response getWellKnown(final @PathParam("realm") String name,
final @PathParam("provider") String providerName) {
final @PathParam("alias") String alias) {
RealmModel realm = init(name);
checkSsl(realm);
WellKnownProvider wellKnown = session.getProvider(WellKnownProvider.class, providerName);
WellKnownProviderFactory wellKnownProviderFactoryFound = session.getKeycloakSessionFactory().getProviderFactoriesStream(WellKnownProvider.class)
.map(providerFactory -> (WellKnownProviderFactory) providerFactory)
.filter(wellKnownProviderFactory -> alias.equals(wellKnownProviderFactory.getAlias()))
.sorted(Comparator.comparingInt(WellKnownProviderFactory::getPriority))
.findFirst().orElseThrow(NotFoundException::new);
logger.tracef("Use provider with ID '%s' for well-known alias '%s'", wellKnownProviderFactoryFound.getId(), alias);
WellKnownProvider wellKnown = session.getProvider(WellKnownProvider.class, wellKnownProviderFactoryFound.getId());
if (wellKnown != null) {
ResponseBuilder responseBuilder = Response.ok(wellKnown.getConfig()).cacheControl(CacheControlUtil.noCache());

View file

@ -24,4 +24,24 @@ import org.keycloak.provider.ProviderFactory;
*/
public interface WellKnownProviderFactory extends ProviderFactory<WellKnownProvider> {
/**
* Alias, which will be used as URL suffix of this well-known provider. For example if you use alias like "openid-configuration", then your WellKnown provider
* might be available under URL like "https://myhost/auth/realms/myrealm/.well-known/openid-configuration". If there are multiple provider factories with same alias,
* the one with lowest priority will be used.
*
* @see #getPriority()
*
*/
default String getAlias() {
return getId();
}
/**
* Use low priority, so custom implementation with alias "openid-configuration" will win over the default implementation
* with alias "openid-configuration", which is provided by Keycloak (OIDCWellKnownProviderFactory).
*
*/
default int getPriority() {
return 1;
}
}

View file

@ -954,6 +954,18 @@ public class TestingResourceProvider implements RealmResourceProvider {
}
}
@GET
@Path("/set-system-property")
@Consumes(MediaType.TEXT_HTML_UTF_8)
@NoCache
public void setSystemPropertyOnServer(@QueryParam("property-name") String propertyName, @QueryParam("property-value") String propertyValue) {
if (propertyValue == null) {
System.getProperties().remove(propertyName);
} else {
System.setProperty(propertyName, propertyValue);
}
}
/**
* This will send POST request to specified URL with specified form parameters. It's not easily possible to "trick" web driver to send POST
* request with custom parameters, which are not directly available in the form.

View file

@ -0,0 +1,50 @@
/*
* Copyright 2021 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.wellknown;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCWellKnownProvider;
import org.keycloak.protocol.oidc.representations.MTLSEndpointAliases;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CustomOIDCWellKnownProvider extends OIDCWellKnownProvider {
public CustomOIDCWellKnownProvider(KeycloakSession session, Map<String, Object> openidConfigOverride, boolean includeClientScopes) {
super(session, openidConfigOverride, includeClientScopes);
}
@Override
public Object getConfig() {
OIDCConfigurationRepresentation config = (OIDCConfigurationRepresentation) super.getConfig();
config.getOtherClaims().put("foo", "bar");
return config;
}
@Override
protected MTLSEndpointAliases getMtlsEndpointAliases(OIDCConfigurationRepresentation config) {
MTLSEndpointAliases mtlsEndpointAliases = super.getMtlsEndpointAliases(config);
mtlsEndpointAliases.setRegistrationEndpoint("https://placeholder-host-set-by-testsuite-provider/registration");
return mtlsEndpointAliases;
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright 2021 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.wellknown;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory;
import org.keycloak.wellknown.WellKnownProvider;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CustomOIDCWellKnownProviderFactory extends OIDCWellKnownProviderFactory {
public static final String INCLUDE_CLIENT_SCOPES = "oidc.wellknown.include.client.scopes";
@Override
public WellKnownProvider create(KeycloakSession session) {
return new CustomOIDCWellKnownProvider(session, getOpenidConfigOverride(), includeClientScopes());
}
private boolean includeClientScopes() {
String includeClientScopesProp = System.getProperty("oidc.wellknown.include.client.scopes");
return includeClientScopesProp == null || Boolean.parseBoolean(includeClientScopesProp);
}
@Override
public void init(Config.Scope config) {
ClassLoader orig = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(CustomOIDCWellKnownProviderFactory.class.getClassLoader());
initConfigOverrideFromFile("classpath:wellknown/oidc-well-known-config-override.json");
} finally {
Thread.currentThread().setContextClassLoader(orig);
}
}
@Override
public String getId() {
return "custom-testsuite-oidc-well-known-factory";
}
@Override
public String getAlias() {
return OIDCWellKnownProviderFactory.PROVIDER_ID;
}
// Should be prioritized over default factory
@Override
public int getPriority() {
return 1;
}
}

View file

@ -0,0 +1,19 @@
#
# Copyright 2021 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.
#
#
org.keycloak.testsuite.wellknown.CustomOIDCWellKnownProviderFactory

View file

@ -0,0 +1,7 @@
{
"some-new-property": "some-new-property-value",
"some-new-property-compound": {
"nested1": "nested-value"
},
"introspection_endpoint_auth_methods_supported": ["private_key_jwt", "client_secret_jwt", "tls_client_auth", "custom_nonexisting_authenticator"]
}

View file

@ -342,6 +342,14 @@ public interface TestingResource {
@Consumes(MediaType.APPLICATION_JSON)
Response disableFeature(@PathParam("feature") String feature);
/**
* If property-value is null, the system property will be unset (removed) on the server
*/
@GET
@Path("/set-system-property")
@Consumes(MediaType.TEXT_HTML_UTF_8)
void setSystemPropertyOnServer(@QueryParam("property-name") String propertyName, @QueryParam("property-value") String propertyValue);
/**
* This method is here just to have all endpoints from TestingResourceProvider available here.

View file

@ -43,10 +43,12 @@ import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AbstractAdminTest;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.wellknown.CustomOIDCWellKnownProviderFactory;
import org.keycloak.util.JsonSerialization;
import javax.ws.rs.client.Client;
@ -57,8 +59,10 @@ import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -148,8 +152,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// Client authentication
Assert.assertNames(oidcConfig.getTokenEndpointAuthMethodsSupported(), "client_secret_basic", "client_secret_post", "private_key_jwt", "client_secret_jwt", "tls_client_auth");
Assert.assertNames(oidcConfig.getTokenEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256, Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256, Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
Assert.assertNames(oidcConfig.getIntrospectionEndpointAuthMethodsSupported(), "client_secret_basic",
"client_secret_post", "private_key_jwt", "client_secret_jwt", "tls_client_auth");
// NOTE: Those are overriden in "oidc-well-known-config-override.json" and they are tested in testDefaultProviderCustomizations
//Assert.assertNames(oidcConfig.getIntrospectionEndpointAuthMethodsSupported(), "private_key_jwt", "client_secret_jwt", "tls_client_auth", "custom_nonexisting_authenticator");
Assert.assertNames(oidcConfig.getIntrospectionEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256,
Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256,
Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
@ -160,9 +164,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
Assert.assertTrue(oidcConfig.getClaimsParameterSupported());
// Scopes supported
Assert.assertNames(oidcConfig.getScopesSupported(), OAuth2Constants.SCOPE_OPENID, OAuth2Constants.OFFLINE_ACCESS,
OAuth2Constants.SCOPE_PROFILE, OAuth2Constants.SCOPE_EMAIL, OAuth2Constants.SCOPE_PHONE, OAuth2Constants.SCOPE_ADDRESS,
OIDCLoginProtocolFactory.ROLES_SCOPE, OIDCLoginProtocolFactory.WEB_ORIGINS_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE);
assertScopesSupportedMatchesWithRealm(oidcConfig);
// Request and Request_Uri
Assert.assertTrue(oidcConfig.getRequestParameterSupported());
@ -282,6 +284,42 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
}
}
@Test
@AuthServerContainerExclude(REMOTE)
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();
}
}
private 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.ROLES_SCOPE, OIDCLoginProtocolFactory.WEB_ORIGINS_SCOPE, OIDCLoginProtocolFactory.MICROPROFILE_JWT_SCOPE);
}
private OIDCConfigurationRepresentation getOIDCDiscoveryRepresentation(Client client, String uriTemplate) {
try {
return JsonSerialization.readValue(getOIDCDiscoveryConfiguration(client, uriTemplate), OIDCConfigurationRepresentation.class);