Compare commits

...

4 Commits

Author SHA1 Message Date
2ebf8c649d
Handle consent token coming back during custom authorization flow 2025-04-23 17:12:16 +02:00
f1fbd306e2
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.
2025-04-23 17:10:13 +02:00
119e27f5da
Do not ask for consent for "openid" scope
The scope itself does nothing, without any of the additional OIDC scopes such as "profile" or "email" the ID token is completely empty. Therefore, it is unneccessary to ask for consent for it and it would just complicate matters. What would happen if a user consented to the "profile" scope but not the "openid" scope?
2025-04-23 12:50:50 +02:00
efcfddaa70
Expand on what each scope means during user consent 2025-04-23 12:27:01 +02:00
8 changed files with 249 additions and 18 deletions

@ -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;
};
}
}

@ -18,10 +18,12 @@ import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.security.web.util.RedirectUrlBuilder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
@ -117,15 +119,21 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
Authentication authenticatedPrincipal = authenticationManager.authenticate(principal);
Authentication normalCodeRequest = authenticationConverter.convert(withGetMethod(request));
Authentication codeRequest = overridePrincipal(authenticatedPrincipal, (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest);
OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken = (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest;
Authentication codeRequest = overridePrincipal(authenticatedPrincipal, codeRequestAuthenticationToken);
Authentication authenticatedCodeRequest = authenticationManager.authenticate(codeRequest);
if (!authenticatedCodeRequest.isAuthenticated()) {
String authorizationUrl = getAuthorizationUrl(request);
respondWithTemplate(response, templates.authorize(authorizationUrl, loggedInUser.getName(), (ShibbolethAuthenticationDetails) authenticatedPrincipal));
} else {
sendAuthorizationResponse(request, response, authenticatedCodeRequest);
if (authenticatedCodeRequest instanceof OAuth2AuthorizationCodeRequestAuthenticationToken authenticatedCodeRequestAuthenticationToken) {
sendAuthorizationResponse(request, response, authenticatedCodeRequestAuthenticationToken);
} else if (authenticatedCodeRequest instanceof OAuth2AuthorizationConsentAuthenticationToken consent) {
sendConsentResponse(request, response, codeRequestAuthenticationToken, consent);
} else {
response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Invalid authentication token");
}
}
}
@ -188,10 +196,12 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
output.writeTo(response.getOutputStream());
}
private void sendAuthorizationResponse(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
private void sendAuthorizationResponse(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication)
throws IOException
{
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
.queryParam(OAuth2ParameterNames.CODE,
@ -240,4 +250,31 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
String redirectUri = uriBuilder.build(true).toUriString();
this.redirectStrategy.sendRedirect(request, response, redirectUri);
}
private void sendConsentResponse(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken,
OAuth2AuthorizationConsentAuthenticationToken consent)
throws IOException
{
Set<String> requestedScopes = codeRequestAuthenticationToken.getScopes();
String consentUri = resolveConsentUri(request, "/oauth2/consent");
String redirectUri = UriComponentsBuilder.fromUriString(consentUri)
.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
.queryParam(OAuth2ParameterNames.CLIENT_ID, consent.getClientId())
.queryParam(OAuth2ParameterNames.STATE, consent.getState())
.toUriString();
this.redirectStrategy.sendRedirect(request, response, redirectUri);
}
private String resolveConsentUri(HttpServletRequest request, String consentPage) {
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
urlBuilder.setScheme(request.getScheme());
urlBuilder.setServerName(request.getServerName());
urlBuilder.setPort(request.getServerPort());
urlBuilder.setContextPath(request.getContextPath());
urlBuilder.setPathInfo(consentPage);
return urlBuilder.getUrl();
}
}

@ -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();
}
}
}

@ -81,6 +81,7 @@ public class ConsentController {
}
return Arrays.stream(scopeString.split(" "))
.filter(s -> !s.isBlank())
.filter(scope -> !scope.equals("openid"))
.collect(Collectors.toSet());
}

@ -30,10 +30,7 @@
</li>
@for (var scope : scopes)
<li class="list-group-item">
<input class="form-check-input" type="checkbox" name="scope" value="${scope}" id="scope_${scope}" checked>
<label class="form-check-label" for="scope_${scope}">
${scope}
</label>
@template.consent_scope(scope = scope)
</li>
@endfor
</ul>

@ -0,0 +1,31 @@
@import java.util.Objects
@param String scope
<label class="d-flex gap-3">
<input class="form-check-input flex-shrink-0" type="checkbox" name="scope" value="${scope}" id="scope_${scope}" checked aria-label="${scope}">
@if (Objects.equals("profile", scope))
<dl>
<dt>Given name</dt>
<dd>...</dd>
<dt>Family name</dt>
<dd>...</dd>
<dt>Display name</dt>
<dd>...</dd>
</dl>
@elseif (Objects.equals("email", scope))
<dl>
<dt>E-mail address</dt>
<dd>...</dd>
</dl>
@elseif (Objects.equals("offline_access", scope))
<div>Maintain access after you leave the application</div>
@else
<div>${scope}</div>
@endif
<div class="ms-auto">
<small class="text-muted">scope: ${scope}</small>
</div>
</label>

@ -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);
}
}

@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -84,13 +85,20 @@ public class ConsentFlowTest extends AbstractMetadataTest {
@Test
public void shows_requested_scopes() throws Exception {
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "profile"))
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "email", "profile"))
.andExpect(status().isOk())
.andExpectAll(
content().string(containsString("openid")),
content().string(containsString("email")),
content().string(containsString("profile")));
}
@Test
public void does_not_ask_for_consent_for_openid_scope() throws Exception {
attemptAuthorizationWithConsentResponseUsingScopes("some-other-end-user", Set.of("openid", "profile"))
.andExpect(status().isOk())
.andExpect(content().string(not(containsString("openid"))));
}
private ResultActions attemptAuthorizationWithConsentResponse(String principal) throws Exception {
Set<String> scopes = Set.of();
return attemptAuthorizationWithConsentResponseUsingScopes(principal, scopes);
@ -109,8 +117,9 @@ public class ConsentFlowTest extends AbstractMetadataTest {
.andExpect(redirectedUrlPattern("**/oauth2/consent?**"))
.andReturn();
String consentUrl = result.getResponse().getRedirectedUrl();
assertNotNull(consentUrl, "Should have redirected to the consent page");
String redirectedUrl = result.getResponse().getRedirectedUrl();
assertNotNull(redirectedUrl, "Should have redirected to the consent page");
String consentUrl = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8);
return mockMvc.perform(get(consentUrl)
.with(remoteUser(principal)));