Compare commits

...

5 Commits

9 changed files with 248 additions and 31 deletions

@ -1,4 +1,4 @@
FROM eclipse-temurin:23 AS build
FROM eclipse-temurin:24 AS build
WORKDIR /build
@ -25,22 +25,52 @@ COPY src src
RUN ./mvnw compile
FROM debian:stable-slim AS runtime
WORKDIR /app
FROM eclipse-temurin:24 AS training
WORKDIR /build
COPY --from=build /build/jre jre
COPY --from=build /build/lib lib
COPY --from=build /build/classpath classpath
COPY --from=build /build/target/classes classes
# Adds the output of Maven compilation to output
RUN echo ":classes" >> classpath
# There can be no directories on the classpath when training
RUN cd classes && jar -cf /build/app.jar *
# Adds the new jar to the classpath
RUN echo ":app.jar" >> classpath
RUN [ "./jre/bin/java" \
, "-cp", "@classpath" \
, "-XX:AOTMode=record" \
, "-XX:AOTConfiguration=app.aotconf" \
, "se.su.dsv.oauth2.Training" \
, "--spring.profiles.active=dev,embedded" \
]
RUN [ "./jre/bin/java" \
, "-cp", "@classpath" \
, "-XX:AOTMode=create" \
, "-XX:AOTConfiguration=app.aotconf" \
, "-XX:AOTCache=app.aot" \
, "se.su.dsv.oauth2.Training" \
, "--spring.profiles.active=dev,embedded" \
]
FROM debian:stable-slim AS runtime
WORKDIR /app
COPY --from=training /build/jre jre
COPY --from=training /build/lib lib
COPY --from=training /build/classpath classpath
COPY --from=training /build/app.jar app.jar
COPY --from=training /build/app.aot app.aot
EXPOSE 8080
CMD [ "./jre/bin/java" \
, "-cp", "@classpath" \
, "-XX:AOTCache=app.aot" \
, "se.su.dsv.oauth2.AuthorizationServer" \
, "--spring.profiles.active=dev,embedded" \
]

@ -9,7 +9,6 @@ import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
@ -24,10 +23,8 @@ 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.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import se.su.dsv.oauth2.shibboleth.Entitlement;
import se.su.dsv.oauth2.shibboleth.ShibbolethAuthenticationDetailsSource;
import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer;
import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator;
import se.su.dsv.oauth2.staging.StagingSecurityConfigurer;
@ -140,30 +137,11 @@ public class AuthorizationServer extends SpringBootServletInitializer {
.accessDeniedPage("/forbidden")
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")));
http.jee(jee -> jee
.withObjectPostProcessor(makeShibbolethAware()));
http.with(new ShibbolethConfigurer(), Customizer.withDefaults());
return http.build();
}
private static ObjectPostProcessor<J2eePreAuthenticatedProcessingFilter> makeShibbolethAware()
{
return new ObjectPostProcessor<>() {
@Override
public <O extends J2eePreAuthenticatedProcessingFilter> O postProcess(final O object) {
// Using a custom authentication details source to extract the Shibboleth attributes
// and convert them to the relevant Spring Security objects.
object.setAuthenticationDetailsSource(new ShibbolethAuthenticationDetailsSource());
// Prevent session creation
// It can cause conflicts when running on the same host as an embedded docker container
// as it overwrites the session cookie (it does not factor in port)
object.setSecurityContextRepository(new RequestAttributeSecurityContextRepository());
return object;
}
};
}
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return new ShibbolethTokenPopulator();

@ -1,8 +1,11 @@
package se.su.dsv.oauth2;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.web.filter.ForwardedHeaderFilter;
import se.su.dsv.oauth2.admin.repository.ClientRepository;
import se.su.dsv.oauth2.admin.repository.ClientRow;
@ -15,6 +18,16 @@ import java.util.Optional;
@Configuration
@Profile("embedded")
public class EmbeddedConfiguration {
/**
* This filter takes the `X-Forwarded-*` headers and updates the request to reflect the original HTTP request.
*/
@Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter());
filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return filterRegistrationBean;
}
@Bean
public ClientRepository clientRepository() {
ArrayList<ClientRow> clients = new ArrayList<>();

@ -0,0 +1,15 @@
package se.su.dsv.oauth2;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
public class Training {
// Used by the Docker build to do a training run to speed up the startup time of the application.
// See https://openjdk.org/jeps/483
// Training is very basic and just boots the application and immediately shuts it down.
// A better training run would involve going through a full OAuth 2 flow to load all the classes.
public static void main(String[] args) {
ConfigurableApplicationContext application = SpringApplication.run(AuthorizationServer.class, args);
application.close();
}
}

@ -0,0 +1,29 @@
package se.su.dsv.oauth2.shibboleth;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.log.LogMessage;
import org.springframework.security.web.authentication.preauth.j2ee.J2eePreAuthenticatedProcessingFilter;
import java.security.Principal;
/// Can't use the built-in [J2eePreAuthenticatedProcessingFilter] because Tomcat
/// unconditionally provides a [HttpServletRequest#getUserPrincipal()] with a
/// blank [Principal#getName()] that causes the authentication process to fail.
class ShibbolethAuthenticatedProcessingFilter extends J2eePreAuthenticatedProcessingFilter {
@Override
protected Object getPreAuthenticatedPrincipal(final HttpServletRequest httpRequest) {
Principal userPrincipal = httpRequest.getUserPrincipal();
if (userPrincipal == null) {
return null;
}
// Tomcat provides a blank Principal name when the user is not authenticated.
String principalName = userPrincipal.getName();
if (principalName == null || principalName.isBlank()) {
return null;
}
this.logger.debug(LogMessage.format("PreAuthenticated J2EE principal: %s", principalName));
return principalName;
}
}

@ -20,7 +20,7 @@ public class ShibbolethConfigurer extends AbstractHttpConfigurer<ShibbolethConfi
@Override
public void configure(final HttpSecurity http) {
J2eePreAuthenticatedProcessingFilter filter = new J2eePreAuthenticatedProcessingFilter();
J2eePreAuthenticatedProcessingFilter filter = new ShibbolethAuthenticatedProcessingFilter();
filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
filter.setAuthenticationDetailsSource(new ShibbolethAuthenticationDetailsSource());
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());

@ -9,11 +9,14 @@ import jakarta.servlet.http.HttpFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
import org.springframework.security.web.DefaultRedirectStrategy;
@ -72,7 +75,11 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
}
if (loggedInUser.getAuthorities().contains(new Entitlement(developerEntitlement))) {
proceedWithDeveloperAuthorization(request, response, loggedInUser);
try {
proceedWithDeveloperAuthorization(request, response, loggedInUser);
} catch (OAuth2AuthorizationCodeRequestAuthenticationException exception) {
sendAuthorizationError(request, response, exception);
}
} else {
chain.doFilter(request, response);
}
@ -85,6 +92,10 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
throws IOException
{
if (Objects.equals(request.getMethod(), "GET")) {
// Validate authorization request
// This will throw if the request is not valid
authenticationConverter.convert(request);
String authorizationUrl = getAuthorizationUrl(request);
JteModel view = templates.authorize(authorizationUrl, loggedInUser.getName(), (ShibbolethAuthenticationDetails) loggedInUser.getDetails());
respondWithTemplate(response, view);
@ -193,4 +204,40 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter {
String redirectUri = uriBuilder.build(true).toUriString();
this.redirectStrategy.sendRedirect(request, response, redirectUri);
}
private void sendAuthorizationError(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthorizationCodeRequestAuthenticationException authorizationCodeRequestAuthenticationException)
throws IOException
{
OAuth2Error error = authorizationCodeRequestAuthenticationException.getError();
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authorizationCodeRequestAuthenticationException
.getAuthorizationCodeRequestAuthentication();
if (authorizationCodeRequestAuthentication == null
|| !StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) {
response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString());
return;
}
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
.queryParam(OAuth2ParameterNames.ERROR, error.getErrorCode());
if (StringUtils.hasText(error.getDescription())) {
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_DESCRIPTION,
UriUtils.encode(error.getDescription(), StandardCharsets.UTF_8));
}
if (StringUtils.hasText(error.getUri())) {
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_URI,
UriUtils.encode(error.getUri(), StandardCharsets.UTF_8));
}
if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) {
uriBuilder.queryParam(OAuth2ParameterNames.STATE,
UriUtils.encode(authorizationCodeRequestAuthentication.getState(), StandardCharsets.UTF_8));
}
// build(true) -> Components are explicitly encoded
String redirectUri = uriBuilder.build(true).toUriString();
this.redirectStrategy.sendRedirect(request, response, redirectUri);
}
}

@ -0,0 +1,8 @@
@param Integer status
@param String error
@param String message
@template.base(title = message, content = @`
<h1>${status} ${error}</h1>
<p>${message}</p>
`)

@ -6,8 +6,15 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.test.context.ActiveProfiles;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.*;
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;
import static se.su.dsv.oauth2.TestRegisteredClientConfiguration.CLIENT_ID;
import static se.su.dsv.oauth2.TestRegisteredClientConfiguration.REDIRECT_URI;
@SpringBootTest(
classes = TestRegisteredClientConfiguration.class,
@ -155,4 +162,94 @@ public class StagingProfileTest extends AbstractMetadataCodeFlowTest {
assertTrue(claims.getStringListClaim("entitlements").contains(customEntitlement),
"Does not contain custom entitlement");
}
@Test
public void correctly_handles_missing_response_type_parameter() throws Exception {
mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("client_id", CLIENT_ID)
.queryParam("redirect_uri", REDIRECT_URI)
.formField("principal", "developer"))
.andExpect(status().isBadRequest())
.andExpect(status().reason(containsString("response_type")));
}
@Test
public void redirects_back_to_client_if_there_are_errors_but_valid_redirect_uri() throws Exception {
mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("response_type", "code")
.queryParam("client_id", CLIENT_ID)
.queryParam("redirect_uri", REDIRECT_URI)
.queryParam("scope", "invalid")
.formField("principal", "developer"))
.andExpect(status().is3xxRedirection())
.andExpect(result -> {
String redirectedUrl = result.getResponse().getRedirectedUrl();
assertThat(redirectedUrl, containsString("error=invalid_scope"));
});
}
@Test
public void does_not_redirect_with_invalid_client_id() throws Exception {
mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("response_type", "code")
.queryParam("client_id", "invalid-client-id")
.queryParam("redirect_uri", REDIRECT_URI)
.formField("principal", "developer"))
.andExpect(status().isBadRequest())
.andExpect(status().reason(containsString("client_id")));
}
@Test
public void maintains_state_during_error_redirect() throws Exception {
String state = "state123";
mockMvc.perform(post(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("response_type", "code")
.queryParam("client_id", CLIENT_ID)
.queryParam("redirect_uri", REDIRECT_URI)
.queryParam("state", state)
.queryParam("scope", "invalid")
.formField("principal", "developer"))
.andExpect(status().is3xxRedirection())
.andExpect(result -> {
String redirectedUrl = result.getResponse().getRedirectedUrl();
assertThat(redirectedUrl, containsString("error=invalid_scope"));
assertThat(redirectedUrl, containsString("state=" + state));
});
}
@Test
public void fails_immediately_with_invalid_request_parameters() throws Exception {
mockMvc.perform(get(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("response_type", "code")
.queryParam("client_id", CLIENT_ID)
.queryParam("redirect_uri", REDIRECT_URI)
.queryParam("scope", "openid")
.queryParam("scope", "profile"))
.andExpect(status().isBadRequest())
.andExpect(status().reason(containsString("scope")));
}
@Test
public void shows_custom_authorization_form_for_valid_requests() throws Exception {
mockMvc.perform(get(getAuthorizationEndpoint())
.with(remoteUser("developer")
.entitlement(DEVELOPER_ENTITLEMENT))
.queryParam("response_type", "code")
.queryParam("client_id", CLIENT_ID)
.queryParam("redirect_uri", REDIRECT_URI)
.queryParam("scope", "openid profile"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("<form")));
}
}