From f24c3012bc001499b1f487a37c41bbc168975989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=88=98?= Date: Wed, 17 Sep 2025 20:05:36 +0900 Subject: [PATCH] Fix OIDC discovery to fallback on text/html responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some providers return 200 OK with text/html for /.well-known/openid-configuration. Previously, discovery stopped at the first endpoint. This commit records UnknownContentTypeException and moves to the next candidate, enabling fallback to the OAuth 2.0 AS metadata endpoint. Closes gh-17036 Signed-off-by: 이현수 --- .../registration/ClientRegistrations.java | 4 ++ .../ClientRegistrationsTests.java | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java index d8ace6bb5d9..232254a43cc 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java @@ -37,6 +37,7 @@ import org.springframework.util.Assert; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.UnknownContentTypeException; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -279,6 +280,9 @@ private static ClientRegistration.Builder getBuilder(String issuer, errors.add(ex.getMessage()); // else try another endpoint } + catch (UnknownContentTypeException ex) { + errors.add(ex.getMessage()); + } catch (IllegalArgumentException | IllegalStateException ex) { throw ex; } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTests.java index bf7dba47183..d8c1ccec078 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTests.java @@ -581,6 +581,39 @@ public void oidcWhenHostContainsUnderscoreThenRetains() { assertThat(oidcRfc8414.getHost()).isEqualTo("elated_sutherland"); } + @Test + public void issuerWhenOidcHtmlThenFallbackToOAuth2ThenSuccess() throws Exception { + ClientRegistration registration = registrationOAuth2WithOidcHtml("issuer1", null).build(); + ClientRegistration.ProviderDetails provider = registration.getProviderDetails(); + assertThat(provider.getAuthorizationUri()).isEqualTo("https://example.com/o/oauth2/v2/auth"); + assertThat(provider.getTokenUri()).isEqualTo("https://example.com/oauth2/v4/token"); + assertThat(provider.getIssuerUri()).isEqualTo(this.issuer); + + // order: OIDC(issuer-prefixed) -> OIDC(host-prefixed) -> OAuth + RecordedRequest request1 = this.server.takeRequest(); + assertThat(request1.getPath()).isEqualTo("/issuer1/.well-known/openid-configuration"); + RecordedRequest request2 = this.server.takeRequest(); + assertThat(request2.getPath()).isEqualTo("/.well-known/openid-configuration/issuer1"); + RecordedRequest request3 = this.server.takeRequest(); + assertThat(request3.getPath()).isEqualTo("/.well-known/oauth-authorization-server/issuer1"); + } + + @Test + public void issuerWhenFirstEndpoint5xxThenThrowsIllegalArgumentException() throws Exception { + this.issuer = createIssuerFromServer("issuer1"); + this.server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest req) { + return switch (req.getPath()) { + case "/issuer1/.well-known/openid-configuration" -> new MockResponse().setResponseCode(500); + default -> new MockResponse().setResponseCode(404); + }; + } + }); + assertThatIllegalArgumentException() + .isThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer).build()); + } + @Test public void issuerWhenAllEndpointsFailedThenExceptionIncludesFailureInformation() { this.issuer = createIssuerFromServer("issuer1"); @@ -673,6 +706,41 @@ public MockResponse dispatch(RecordedRequest request) { return ClientRegistrations.fromIssuerLocation(this.issuer).clientId("client-id").clientSecret("client-secret"); } + /** + * Simulates a situation when the OIDC discovery endpoints + * "/issuer1/.well-known/openid-configuration" and + * "/.well-known/openid-configuration/issuer1" respond with HTTP 200 and text/html + * (non-JSON), so discovery falls back to + * "/.well-known/oauth-authorization-server/issuer1", which responds with HTTP 200 and + * JSON. + * + * @see Section 3.1 + * @see Section 5 + */ + private ClientRegistration.Builder registrationOAuth2WithOidcHtml(String path, String body) throws Exception { + this.issuer = createIssuerFromServer(path); + this.response.put("issuer", this.issuer); + String responseBody = (body != null) ? body : this.mapper.writeValueAsString(this.response); + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return switch (request.getPath()) { + case "/issuer1/.well-known/openid-configuration", "/.well-known/openid-configuration/issuer1", + "/.well-known/openid-configuration/" -> + new MockResponse().setResponseCode(200) + .setBody("not json") + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_HTML_VALUE); + case "/.well-known/oauth-authorization-server/issuer1", + "/.well-known/oauth-authorization-server/" -> + buildSuccessMockResponse(responseBody); + default -> new MockResponse().setResponseCode(404); + }; + } + }; + this.server.setDispatcher(dispatcher); + return ClientRegistrations.fromIssuerLocation(this.issuer).clientId("client-id").clientSecret("client-secret"); + } + private MockResponse buildSuccessMockResponse(String body) { // @formatter:off return new MockResponse().setResponseCode(200)