From ea63cdc97a43dece55dde8ff6394568a45dd4aa4 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Thu, 4 Sep 2025 02:14:37 +0900 Subject: [PATCH] Compliant with RFC8414, return server metadata at /.well-known/oauth-authorization-server/realms/{realm} closes #40923 Signed-off-by: Takashi Norimatsu --- .../services/resources/RealmsResource.java | 16 +++- .../resources/ServerMetadataResource.java | 73 +++++++++++++++++++ .../resteasy/ResteasyKeycloakApplication.java | 2 + ...4CompliantOAuth2WellKnownProviderTest.java | 37 ++++++++++ .../oidc/AbstractWellKnownProviderTest.java | 6 +- 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RFC8414CompliantOAuth2WellKnownProviderTest.java diff --git a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java index 8cdc43ca6c2..bdba2df7c6c 100755 --- a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java @@ -180,6 +180,10 @@ public class RealmsResource { } private void resolveRealmAndUpdateSession(String realmName) { + resolveRealmAndUpdateSession(session, realmName); + } + + private static void resolveRealmAndUpdateSession(KeycloakSession session, String realmName) { RealmManager realmManager = new RealmManager(session); RealmModel realm = realmManager.getRealmByName(realmName); if (realm == null) { @@ -225,8 +229,12 @@ public class RealmsResource { @Produces(MediaType.APPLICATION_JSON) public Response getWellKnown(final @PathParam("realm") String name, final @PathParam("alias") String alias) { - resolveRealmAndUpdateSession(name); - checkSsl(session.getContext().getRealm()); + return getWellKnownResponse(session, name, alias, logger); + } + + public static Response getWellKnownResponse(KeycloakSession session, String name, String alias, Logger logger) throws NotFoundException { + resolveRealmAndUpdateSession(session, name); + checkSsl(session, session.getContext().getRealm()); WellKnownProviderFactory wellKnownProviderFactoryFound = session.getKeycloakSessionFactory().getProviderFactoriesStream(WellKnownProvider.class) .map(providerFactory -> (WellKnownProviderFactory) providerFactory) @@ -276,6 +284,10 @@ public class RealmsResource { } private void checkSsl(RealmModel realm) { + checkSsl(session, realm); + } + + private static void checkSsl(KeycloakSession session, RealmModel realm) { if (!"https".equals(session.getContext().getUri().getBaseUri().getScheme()) && realm.getSslRequired().isRequired(session.getContext().getConnection())) { HttpRequest request = session.getContext().getHttpRequest(); diff --git a/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java b/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java new file mode 100644 index 00000000000..582c63c2a86 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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.services.resources; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory; +import org.keycloak.services.cors.Cors; + +import java.util.List; + +@Provider +@Path("/.well-known") +public class ServerMetadataResource { + + protected static final Logger logger = Logger.getLogger(ServerMetadataResource.class); + + @Context + protected KeycloakSession session; + + @OPTIONS + @Path("{provider}/realms/{realm}") + @Produces(MediaType.APPLICATION_JSON) + public Response getOAuth2AuthorizationServerWellKnownVersionPreflight(final @PathParam("provider") String providerName, + final @PathParam("realm") String name) { + if (!isValidProvider(providerName)) throw new NotFoundException(); + return Cors.builder().allowedMethods("GET").preflight().auth().add(Response.ok()); + } + + @GET + @Path("{provider}/realms/{realm}") + @Produces(MediaType.APPLICATION_JSON) + public Response getOAuth2AuthorizationServerWellKnown(final @PathParam("provider") String providerName, + final @PathParam("realm") String name) { + if (!isValidProvider(providerName)) throw new NotFoundException(); + return RealmsResource.getWellKnownResponse(session, name, providerName, logger); + } + + public static UriBuilder wellKnownOAuthProviderUrl(UriBuilder builder) { + return builder.path(ServerMetadataResource.class).path("{provider}/realms/{realm}"); + } + + private boolean isValidProvider(String providerName) { + // you can add codes here considering the current status of the implementation (preview, experimental). + if (OAuth2WellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; + return false; + } +} diff --git a/services/src/test/java/org/keycloak/services/resteasy/ResteasyKeycloakApplication.java b/services/src/test/java/org/keycloak/services/resteasy/ResteasyKeycloakApplication.java index e55e79a13a8..8ad5f51c5a7 100644 --- a/services/src/test/java/org/keycloak/services/resteasy/ResteasyKeycloakApplication.java +++ b/services/src/test/java/org/keycloak/services/resteasy/ResteasyKeycloakApplication.java @@ -28,6 +28,7 @@ import org.keycloak.services.filters.KeycloakSecurityHeadersFilter; import org.keycloak.services.resources.KeycloakApplication; import org.keycloak.services.resources.LoadBalancerResource; import org.keycloak.services.resources.RealmsResource; +import org.keycloak.services.resources.ServerMetadataResource; import org.keycloak.services.resources.ThemeResource; import org.keycloak.services.resources.WelcomeResource; import org.keycloak.services.resources.admin.AdminRoot; @@ -54,6 +55,7 @@ public class ResteasyKeycloakApplication extends KeycloakApplication { singletons.add(new ObjectMapperResolver()); classes.add(WelcomeResource.class); + classes.add(ServerMetadataResource.class); if (MultiSiteUtils.isMultiSiteEnabled()) { // If we are running in multi-site mode, we need to add a resource which to expose diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RFC8414CompliantOAuth2WellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RFC8414CompliantOAuth2WellKnownProviderTest.java new file mode 100644 index 00000000000..dacd560516a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RFC8414CompliantOAuth2WellKnownProviderTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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.oauth; + +import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory; +import org.keycloak.services.resources.ServerMetadataResource; +import org.keycloak.testsuite.oidc.AbstractWellKnownProviderTest; + +import java.net.URI; + +public class RFC8414CompliantOAuth2WellKnownProviderTest extends AbstractWellKnownProviderTest { + + protected String getWellKnownProviderId() { + return OAuth2WellKnownProviderFactory.PROVIDER_ID; + } + + protected URI getOIDCDiscoveryUri(UriBuilder builder) { + return ServerMetadataResource.wellKnownOAuthProviderUrl(builder).build(this.getWellKnownProviderId(), "test"); + } + +} 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 e8b7e855b5b..fdc35cf8b48 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 @@ -410,9 +410,13 @@ public abstract class AbstractWellKnownProviderTest extends AbstractKeycloakTest } } + protected URI getOIDCDiscoveryUri(UriBuilder builder) { + return RealmsResource.wellKnownProviderUrl(builder).build("test", this.getWellKnownProviderId()); + } + private String getOIDCDiscoveryConfiguration(Client client, String uriTemplate) { UriBuilder builder = UriBuilder.fromUri(uriTemplate); - URI oidcDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build("test", this.getWellKnownProviderId()); + URI oidcDiscoveryUri = getOIDCDiscoveryUri(builder); WebTarget oidcDiscoveryTarget = client.target(oidcDiscoveryUri); Response response = oidcDiscoveryTarget.request().get();