Skip consent for developers in staging

With the custom authorization in place, the current user is always the developer while the authorization request token may contain a custom principal. When Spring Authorization Server attempts to validate the submitted consent, it checks that the current user is the same as the authorization request token - which it is not. The easiest solution is to disable consent in staging for developers.
This commit is contained in:
Andreas Svanberg 2025-04-23 17:10:13 +02:00
parent 119e27f5da
commit f1fbd306e2
Signed by: ansv7779
GPG Key ID: 729B051CFFD42F92
3 changed files with 159 additions and 3 deletions
src

@ -18,6 +18,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
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.token.JwtEncodingContext;
@ -31,11 +32,12 @@ import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import se.su.dsv.oauth2.shibboleth.Entitlement;
import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer;
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator;
import se.su.dsv.oauth2.staging.SkipConsentForDevelopers;
import se.su.dsv.oauth2.staging.StagingSecurityConfigurer;
import se.su.dsv.oauth2.web.client.DeveloperAccessCheck;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@SpringBootApplication(proxyBeanMethods = false)
@EnableWebSecurity
@ -75,7 +77,9 @@ public class AuthorizationServer extends SpringBootServletInitializer {
/// the session and allow the flow to start.
@Bean
@Order(1)
public SecurityFilterChain oauth2Endpoints(HttpSecurity http, Optional<HttpSecurityCustomizer> customizer)
public SecurityFilterChain oauth2Endpoints(
HttpSecurity http,
List<HttpSecurityCustomizer> customizer)
throws Exception
{
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
@ -100,7 +104,7 @@ public class AuthorizationServer extends SpringBootServletInitializer {
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
));
customizer.ifPresent(c -> {
customizer.forEach(c -> {
try {
c.customize(http);
} catch (Exception e) {
@ -130,6 +134,27 @@ public class AuthorizationServer extends SpringBootServletInitializer {
};
}
/// Disable consent in staging. With the custom authorization in place, the current user is always the developer
/// while the authorization request token may contain a custom principal. When Spring Authorization Server attempts
/// to validate the submitted consent, it checks that the current user is the same as the authorization request
/// token - which it is not. The easiest solution is to disable consent in staging for developers.
@Bean
@Profile("staging")
public HttpSecurityCustomizer disableConsentInStaging(
DeveloperAccessCheck developerAccessCheck)
{
ObjectPostProcessor<OAuth2AuthorizationCodeRequestAuthenticationProvider> postprocessor = new ObjectPostProcessor<>() {
@Override
public <O extends OAuth2AuthorizationCodeRequestAuthenticationProvider> O postProcess(final O object) {
object.setAuthorizationConsentRequired(new SkipConsentForDevelopers(developerAccessCheck));
return object;
}
};
return http -> http
.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.withObjectPostProcessor(postprocessor);
}
public interface HttpSecurityCustomizer {
void customize(HttpSecurity http) throws Exception;
}
@ -205,4 +230,5 @@ public class AuthorizationServer extends SpringBootServletInitializer {
return false;
};
}
}

@ -0,0 +1,26 @@
package se.su.dsv.oauth2.staging;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext;
import java.util.function.Predicate;
public class SkipConsentForDevelopers implements Predicate<OAuth2AuthorizationCodeRequestAuthenticationContext> {
private final Predicate<Authentication> developerAccessCheck;
public SkipConsentForDevelopers(final Predicate<Authentication> developerAccessCheck) {
this.developerAccessCheck = developerAccessCheck;
}
@Override
public boolean test(final OAuth2AuthorizationCodeRequestAuthenticationContext context) {
Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
if (developerAccessCheck.test(currentUser)) {
return false;
} else {
// TODO maybe check if consent is already given? useful to always request consent during testing?
return context.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent();
}
}
}

@ -0,0 +1,104 @@
package se.su.dsv.oauth2;
import com.fasterxml.jackson.databind.JsonNode;
import com.nimbusds.jwt.JWTClaimsSet;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import org.testcontainers.containers.MariaDBContainer;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static se.su.dsv.oauth2.ShibbolethRequestProcessor.remoteUser;
@SpringBootTest(
properties = {
"se.su.dsv.oauth2.developer-entitlement=" + ConsentFlowCustomAuthorizationTest.DEVELOPER_ENTITLEMENT
},
classes = ConsentFlowTest.TestConfig.class)
@ActiveProfiles("staging")
public class ConsentFlowCustomAuthorizationTest extends AbstractMetadataTest {
public static final String DEVELOPER_ENTITLEMENT = "developer";
@ServiceConnection
static MariaDBContainer<?> mariaDBContainer = new MariaDBContainer<>("mariadb:10.11");
@Test
public void consent_is_disabled_for_developers() throws Exception {
String developerPrincipal = "master-of-the-universe";
String customPrincipal = "lowly-regular-user";
MvcResult authorizationResult = mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser(developerPrincipal)
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("client_id", ConsentFlowTest.TestConfig.CLIENT_ID)
.queryParam("response_type", "code")
.formField("principal", customPrincipal))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern(ConsentFlowTest.TestConfig.REDIRECT_URI + "?*"))
.andReturn();
String redirectUrl = getRedirectUrl(authorizationResult);
String authorizationCode = UriComponentsBuilder.fromUriString(redirectUrl)
.build()
.getQueryParams()
.getFirst("code");
assertNotNull(authorizationCode, "Should have received an authorization code");
MvcResult tokenResult = mockMvc.perform(post(getTokenEndpoint())
.with(httpBasic(ConsentFlowTest.TestConfig.CLIENT_ID, ConsentFlowTest.TestConfig.CLIENT_SECRET))
.formField("grant_type", "authorization_code")
.formField("code", authorizationCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").exists())
.andReturn();
JsonNode tokenResponse = objectMapper.readTree(tokenResult.getResponse().getContentAsString());
JWTClaimsSet claims = verifyToken(tokenResponse.required("access_token").asText());
assertEquals(customPrincipal, claims.getSubject());
}
@Test
public void regular_users_are_prompted_for_consent() throws Exception {
String regularPrincipal = "lowly-regular-user";
MvcResult authorizationResult = mockMvc.perform(get(getAuthorizationEndpoint())
.with(remoteUser(regularPrincipal))
.queryParam("client_id", ConsentFlowTest.TestConfig.CLIENT_ID)
.queryParam("response_type", "code"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/oauth2/consent?*"))
.andReturn();
String redirectUrl = getRedirectUrl(authorizationResult);
MultiValueMap<String, String> queryParams = UriComponentsBuilder.fromUriString(redirectUrl)
.build()
.getQueryParams();
mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser(regularPrincipal))
.formField("client_id", queryParams.getFirst("client_id"))
.formField("state", queryParams.getFirst("state")))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern(ConsentFlowTest.TestConfig.REDIRECT_URI + "?*"));
}
private static String getRedirectUrl(final MvcResult result) {
String redirectedUrl = result.getResponse().getRedirectedUrl();
assertNotNull(redirectedUrl, "Should have gotten a redirect URL");
return URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8);
}
}