From 6db1ce23d19523be2cf411256a82125bf132b639 Mon Sep 17 00:00:00 2001 From: Andreas Svanberg Date: Mon, 26 Jan 2026 13:18:51 +0100 Subject: [PATCH] Enable public clients to exchange codes for access tokens Public clients are intended to be supported with PKCE as a requirement. However, since exchanging the authorization code for a token is a cross-origin POST request it will be blocked due to lack of a CORS policy. This change introduces a CORS policy for just the token exchange endpoint where POST is allowed. --- .../se/su/dsv/oauth2/AuthorizationServer.java | 31 +++++++++++- .../dsv/oauth2/PublicClientCodeFlowTest.java | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java index 17c2ff8..6ffb1b1 100644 --- a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java +++ b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java @@ -21,6 +21,7 @@ import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; import org.springframework.security.web.AuthenticationEntryPoint; @@ -28,7 +29,13 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import se.su.dsv.oauth2.shibboleth.Entitlement; import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer; import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator; @@ -75,18 +82,27 @@ public class AuthorizationServer extends SpringBootServletInitializer { /// in the session and the user is redirected back to the OAuth 2.0 /// authorization endpoint which will then retrieve the authentication from /// the session and allow the flow to start. + /// + /// CORS is enabled on the token endpoint so that public clients can exchange + /// authorization codes for tokens from the browser using `POST`. @Bean @Order(1) public SecurityFilterChain oauth2Endpoints( HttpSecurity http, - List customizer) + List customizer, + AuthorizationServerSettings authorizationServerSettings) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer.authorizationServer(); + String tokenEndpoint = authorizationServerSettings.getTokenEndpoint(); + RequestMatcher corsEnabledMatcher = new OrRequestMatcher( + authorizationServerConfigurer.getEndpointsMatcher(), + new AntPathRequestMatcher(tokenEndpoint, "OPTIONS") + ); http - .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) + .securityMatcher(corsEnabledMatcher) .with(authorizationServerConfigurer, authorizationServer -> authorizationServer .authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint .consentPage("/oauth2/consent")) @@ -100,6 +116,7 @@ public class AuthorizationServer extends SpringBootServletInitializer { .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated()) + .cors(cors -> cors.configurationSource(corsConfigurationSource(tokenEndpoint))) .exceptionHandling(exceptions -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), @@ -117,6 +134,16 @@ public class AuthorizationServer extends SpringBootServletInitializer { return http.build(); } + private CorsConfigurationSource corsConfigurationSource(final String tokenEndpoint) { + UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource(); + CorsConfiguration allowPublicClients = new CorsConfiguration(); + allowPublicClients.setAllowedOrigins(List.of("*")); + allowPublicClients.setAllowedMethods(List.of("POST", "OPTIONS")); + allowPublicClients.setAllowCredentials(false); + urlBasedCorsConfigurationSource.registerCorsConfiguration(tokenEndpoint, allowPublicClients); + return urlBasedCorsConfigurationSource; + } + /// The [OAuth2AuthorizationConsentAuthenticationProvider], which is the component /// that handles incoming consent, requires there to be at least one scope that is /// approved by the user. This is a problem since we want to allow the user to give diff --git a/src/test/java/se/su/dsv/oauth2/PublicClientCodeFlowTest.java b/src/test/java/se/su/dsv/oauth2/PublicClientCodeFlowTest.java index 69bff56..fa92270 100644 --- a/src/test/java/se/su/dsv/oauth2/PublicClientCodeFlowTest.java +++ b/src/test/java/se/su/dsv/oauth2/PublicClientCodeFlowTest.java @@ -18,8 +18,11 @@ import java.security.MessageDigest; import java.util.Base64; import java.util.Set; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -112,4 +115,48 @@ public class PublicClientCodeFlowTest extends AbstractMetadataCodeFlowTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.refresh_token").doesNotExist()); } + + @Test + public void cors_preflight_on_token_endpoint() throws Exception { + mockMvc.perform(options(getTokenEndpoint()) + .header("Origin", "https://some-public-client.example") + .header("Access-Control-Request-Method", "POST")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", "*")) + .andExpect(header().string("Access-Control-Allow-Methods", containsString("POST"))); + } + + @Test + public void cors_on_token_endpoint() throws Exception { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + String codeVerifier = "some-new-code-verifier"; + byte[] codeChallengeDigest = sha256.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + String codeChallenge = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(codeChallengeDigest); + + MvcResult mvcResult = mockMvc.perform(get(getAuthorizationEndpoint()) + .with(remoteUser("random-end-user")) + .queryParam("response_type", "code") + .queryParam("client_id", newClient.clientId()) + .queryParam("redirect_uri", REDIRECT_URI) + .queryParam("scope", "openid offline_access") + .queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("http://localhost/public?code=*")) + .andReturn(); + + String code = extractCode(mvcResult.getResponse().getRedirectedUrl()); + + mockMvc.perform(post(getTokenEndpoint()) + .header("Origin", "https://some-public-client.example") + .param("grant_type", "authorization_code") + .param("code", code) + .param("redirect_uri", REDIRECT_URI) + .param("client_id", newClient.clientId()) + .param("code_verifier", codeVerifier)) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", "*")); + } } -- 2.39.5