Enable public clients to exchange codes for access tokens #14

Manually merged
ansv7779 merged 1 commits from public-client-token-exchange into main 2026-02-20 09:21:13 +01:00
2 changed files with 76 additions and 2 deletions

View File

@ -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.OAuth2AuthorizationCodeRequestAuthenticationProvider;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationProvider; 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.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.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.AuthenticationEntryPoint; 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.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter; 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.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.Entitlement;
import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer; import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer;
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator; 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 /// in the session and the user is redirected back to the OAuth 2.0
/// authorization endpoint which will then retrieve the authentication from /// authorization endpoint which will then retrieve the authentication from
/// the session and allow the flow to start. /// 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 @Bean
@Order(1) @Order(1)
public SecurityFilterChain oauth2Endpoints( public SecurityFilterChain oauth2Endpoints(
HttpSecurity http, HttpSecurity http,
List<HttpSecurityCustomizer> customizer) List<HttpSecurityCustomizer> customizer,
AuthorizationServerSettings authorizationServerSettings)
throws Exception throws Exception
{ {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer(); OAuth2AuthorizationServerConfigurer.authorizationServer();
String tokenEndpoint = authorizationServerSettings.getTokenEndpoint();
RequestMatcher corsEnabledMatcher = new OrRequestMatcher(
authorizationServerConfigurer.getEndpointsMatcher(),
new AntPathRequestMatcher(tokenEndpoint, "OPTIONS")
);
http http
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .securityMatcher(corsEnabledMatcher)
.with(authorizationServerConfigurer, authorizationServer -> authorizationServer .with(authorizationServerConfigurer, authorizationServer -> authorizationServer
.authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint .authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint
.consentPage("/oauth2/consent")) .consentPage("/oauth2/consent"))
@ -100,6 +116,7 @@ public class AuthorizationServer extends SpringBootServletInitializer {
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()) .anyRequest().authenticated())
.cors(cors -> cors.configurationSource(corsConfigurationSource(tokenEndpoint)))
.exceptionHandling(exceptions -> exceptions .exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor( .defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"), new LoginUrlAuthenticationEntryPoint("/login"),
@ -117,6 +134,16 @@ public class AuthorizationServer extends SpringBootServletInitializer {
return http.build(); 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 /// The [OAuth2AuthorizationConsentAuthenticationProvider], which is the component
/// that handles incoming consent, requires there to be at least one scope that is /// 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 /// approved by the user. This is a problem since we want to allow the user to give

View File

@ -18,8 +18,11 @@ import java.security.MessageDigest;
import java.util.Base64; import java.util.Base64;
import java.util.Set; 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.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.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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -112,4 +115,48 @@ public class PublicClientCodeFlowTest extends AbstractMetadataCodeFlowTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.refresh_token").doesNotExist()); .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", "*"));
}
} }