Skip to content

Send saml logout response even when validation errors happen #14676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -389,14 +389,14 @@ public void saml2LogoutRequestWhenNoRegistrationThen400() throws Exception {
}

@Test
public void saml2LogoutRequestWhenInvalidSamlRequestThen401() throws Exception {
public void saml2LogoutRequestWhenInvalidSamlRequestThen302Redirect() throws Exception {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this makes sense to change this test since it is what the bug is about

this.spring.register(Saml2LogoutDefaultsConfig.class).autowire();
this.mvc
.perform(get("/logout/saml2/slo").param("SAMLRequest", this.apLogoutRequest)
.param("RelayState", this.apLogoutRequestRelayState)
.param("SigAlg", this.apLogoutRequestSigAlg)
.with(authentication(this.user)))
.andExpect(status().isUnauthorized());
.andExpect(status().isFound());
verifyNoInteractions(getBean(LogoutHandler.class));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -288,14 +288,14 @@ public void saml2LogoutRequestWhenNoRegistrationThen400() throws Exception {
}

@Test
public void saml2LogoutRequestWhenInvalidSamlRequestThen401() throws Exception {
public void saml2LogoutRequestWhenInvalidSamlRequestThen302Redirect() throws Exception {
this.spring.configLocations(this.xml("Default")).autowire();
this.mvc
.perform(get("/logout/saml2/slo").param("SAMLRequest", this.apLogoutRequest)
.param("RelayState", this.apLogoutRequestRelayState)
.param("SigAlg", this.apLogoutRequestSigAlg)
.with(authentication(this.saml2User)))
.andExpect(status().isUnauthorized());
.andExpect(status().isFound());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -43,8 +43,11 @@

import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.core.OpenSamlInitializationService;
import org.springframework.security.saml2.core.Saml2Error;
import org.springframework.security.saml2.core.Saml2ErrorCodes;
import org.springframework.security.saml2.core.Saml2ParameterNames;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
Expand Down Expand Up @@ -130,6 +133,16 @@ final class BaseOpenSamlLogoutResponseResolver implements Saml2LogoutResponseRes
*/
@Override
public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication) {
return resolve(request, authentication, StatusCode.SUCCESS);
}

@Override
public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication,
Saml2AuthenticationException authenticationException) {
return resolve(request, authentication, getSamlStatus(authenticationException));
}

private Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication, String statusCode) {
LogoutRequest logoutRequest = this.saml.deserialize(extractSamlRequest(request));
String registrationId = getRegistrationId(authentication);
RelyingPartyRegistration registration = this.relyingPartyRegistrationResolver.resolve(request, registrationId);
Expand All @@ -152,7 +165,7 @@ public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication au
issuer.setValue(entityId);
logoutResponse.setIssuer(issuer);
StatusCode code = this.statusCodeBuilder.buildObject();
code.setValue(StatusCode.SUCCESS);
code.setValue(statusCode);
Status status = this.statusBuilder.buildObject();
status.setStatusCode(code);
logoutResponse.setStatus(status);
Expand Down Expand Up @@ -224,6 +237,15 @@ private String serialize(LogoutResponse logoutResponse) {
return this.saml.serialize(logoutResponse).serialize();
}

private String getSamlStatus(Saml2AuthenticationException exception) {
Saml2Error saml2Error = exception.getSaml2Error();
return switch (saml2Error.getErrorCode()) {
case Saml2ErrorCodes.INVALID_DESTINATION -> StatusCode.REQUEST_DENIED;
case Saml2ErrorCodes.INVALID_REQUEST -> StatusCode.REQUESTER;
default -> StatusCode.RESPONDER;
};
}

static final class LogoutResponseParameters {

private final HttpServletRequest request;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.saml2.core.Saml2Error;
import org.springframework.security.saml2.core.Saml2ErrorCodes;
import org.springframework.security.saml2.core.Saml2ParameterNames;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
Expand Down Expand Up @@ -125,40 +126,31 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
chain.doFilter(request, response);
return;
}
RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration();
if (registration.getSingleLogoutServiceLocation() == null) {
this.logger.trace(
"Did not process logout request since RelyingPartyRegistration has not been configured with a logout request endpoint");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}

Saml2MessageBinding saml2MessageBinding = Saml2MessageBindingUtils.resolveBinding(request);
if (!registration.getSingleLogoutServiceBindings().contains(saml2MessageBinding)) {
this.logger.trace("Did not process logout request since used incorrect binding");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
try {
validateLogoutRequest(request, parameters);
}
catch (Saml2AuthenticationException ex) {
Saml2LogoutResponse errorLogoutResponse = this.logoutResponseResolver.resolve(request, authentication, ex);
if (errorLogoutResponse == null) {
this.logger.trace(LogMessage.format(
"Returning error since no error logout response could be generated: %s", ex.getSaml2Error()));
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please have this include the error message from the exception so that it gives the same information that this did

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return;
}

Saml2LogoutValidatorResult result = this.logoutRequestValidator.validate(parameters);
if (result.hasErrors()) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, result.getErrors().iterator().next().toString());
this.logger.debug(LogMessage.format("Failed to validate LogoutRequest: %s", result.getErrors()));
sendLogoutResponse(request, response, errorLogoutResponse);
return;
}

this.handler.logout(request, response, authentication);
Saml2LogoutResponse logoutResponse = this.logoutResponseResolver.resolve(request, authentication);
if (logoutResponse == null) {
this.logger.trace("Returning 401 since no logout response generated");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
this.logger.trace("Returning error since no logout response generated");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) {
doRedirect(request, response, logoutResponse);
}
else {
doPost(response, logoutResponse);
}
sendLogoutResponse(request, response, logoutResponse);
}

public void setLogoutRequestMatcher(RequestMatcher logoutRequestMatcher) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See if you can avoid moving these, so that it's easier to identify the changes to fix the bug.

Expand All @@ -180,6 +172,40 @@ public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy secur
this.securityContextHolderStrategy = securityContextHolderStrategy;
}

private void validateLogoutRequest(HttpServletRequest request, Saml2LogoutRequestValidatorParameters parameters) {
RelyingPartyRegistration registration = parameters.getRelyingPartyRegistration();
if (registration.getSingleLogoutServiceLocation() == null) {
this.logger.trace(
"Did not process logout request since RelyingPartyRegistration has not been configured with a logout request endpoint");
throw new Saml2AuthenticationException(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION,
"RelyingPartyRegistration has not been configured with a logout request endpoint"));
}

Saml2MessageBinding saml2MessageBinding = Saml2MessageBindingUtils.resolveBinding(request);
if (!registration.getSingleLogoutServiceBindings().contains(saml2MessageBinding)) {
this.logger.trace("Did not process logout request since used incorrect binding");
throw new Saml2AuthenticationException(
new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "Logout request used invalid binding"));
}

Saml2LogoutValidatorResult result = this.logoutRequestValidator.validate(parameters);
if (result.hasErrors()) {
this.logger.debug(LogMessage.format("Failed to validate LogoutRequest: %s", result.getErrors()));
throw new Saml2AuthenticationException(
new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "Failed to validate the logout request"));
}
}

private void sendLogoutResponse(HttpServletRequest request, HttpServletResponse response,
Saml2LogoutResponse logoutResponse) throws IOException {
if (logoutResponse.getBinding() == Saml2MessageBinding.REDIRECT) {
doRedirect(request, response, logoutResponse);
}
else {
doPost(response, logoutResponse);
}
}

private void doRedirect(HttpServletRequest request, HttpServletResponse response,
Saml2LogoutResponse logoutResponse) throws IOException {
String location = logoutResponse.getResponseLocation();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -19,6 +19,7 @@
import jakarta.servlet.http.HttpServletRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;

Expand All @@ -44,4 +45,17 @@ public interface Saml2LogoutResponseResolver {
*/
Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication);

/**
* Prepare to create, sign, and serialize a SAML 2.0 Error Logout Response.
* @param request the HTTP request
* @param authentication the current user
* @param authenticationException the thrown exception when the logout request was
* processed
* @return a signed and serialized SAML 2.0 Logout Response
*/
default Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication,
Saml2AuthenticationException authenticationException) {
return null;
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,6 +24,7 @@
import org.opensaml.saml.saml2.core.LogoutRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
Expand Down Expand Up @@ -66,6 +67,15 @@ public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication au
return this.delegate.resolve(request, authentication);
}

/**
* {@inheritDoc}
*/
@Override
public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication,
Saml2AuthenticationException exception) {
return this.delegate.resolve(request, authentication, exception);
}

/**
* Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest}
* @param parametersConsumer a consumer that accepts an
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,17 +17,26 @@
package org.springframework.security.saml2.provider.service.web.authentication.logout;

import java.util.function.Consumer;
import java.util.stream.Stream;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.opensaml.saml.saml2.core.LogoutRequest;
import org.opensaml.saml.saml2.core.StatusCode;

import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.core.Saml2Error;
import org.springframework.security.saml2.core.Saml2ErrorCodes;
import org.springframework.security.saml2.core.Saml2ParameterNames;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.TestOpenSamlObjects;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.registration.TestRelyingPartyRegistrations;
import org.springframework.security.saml2.provider.service.web.RelyingPartyRegistrationResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseResolver.LogoutResponseParameters;
Expand Down Expand Up @@ -56,7 +65,7 @@ public void resolveWhenCustomParametersConsumerThenUses() {
logoutResponseResolver.setParametersConsumer(parametersConsumer);
MockHttpServletRequest request = new MockHttpServletRequest();
RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration()
.assertingPartyDetails(
.assertingPartyMetadata(
(party) -> party.singleLogoutServiceResponseLocation("https://ap.example.com/logout"))
.build();
Authentication authentication = new TestingAuthenticationToken("user", "password");
Expand All @@ -69,6 +78,27 @@ public void resolveWhenCustomParametersConsumerThenUses() {
verify(parametersConsumer).accept(any());
}

@ParameterizedTest
@MethodSource("provideAuthExceptionAndExpectedSamlStatusCode")
public void resolveWithAuthException(Saml2AuthenticationException exception, String expectedStatusCode) {
OpenSaml4LogoutResponseResolver logoutResponseResolver = new OpenSaml4LogoutResponseResolver(
this.relyingPartyRegistrationResolver);
MockHttpServletRequest request = new MockHttpServletRequest();
RelyingPartyRegistration registration = TestRelyingPartyRegistrations.relyingPartyRegistration()
.assertingPartyMetadata(
(party) -> party.singleLogoutServiceResponseLocation("https://ap.example.com/logout")
.singleLogoutServiceBinding(Saml2MessageBinding.POST))
.build();
Authentication authentication = new TestingAuthenticationToken("user", "password");
LogoutRequest logoutRequest = TestOpenSamlObjects.assertingPartyLogoutRequest(registration);
request.setParameter(Saml2ParameterNames.SAML_REQUEST,
Saml2Utils.samlEncode(this.saml.serialize(logoutRequest).serialize().getBytes()));
given(this.relyingPartyRegistrationResolver.resolve(any(), any())).willReturn(registration);
Saml2LogoutResponse logoutResponse = logoutResponseResolver.resolve(request, authentication, exception);
assertThat(logoutResponse).isNotNull();
assertThat(new String(Saml2Utils.samlDecode(logoutResponse.getSamlResponse()))).contains(expectedStatusCode);
}

@Test
public void setParametersConsumerWhenNullThenIllegalArgument() {
OpenSaml4LogoutRequestResolver logoutRequestResolver = new OpenSaml4LogoutRequestResolver(
Expand All @@ -77,4 +107,14 @@ public void setParametersConsumerWhenNullThenIllegalArgument() {
.isThrownBy(() -> logoutRequestResolver.setParametersConsumer(null));
}

private static Stream<Arguments> provideAuthExceptionAndExpectedSamlStatusCode() {
return Stream.of(
Arguments.of(new Saml2AuthenticationException(new Saml2Error(Saml2ErrorCodes.INVALID_DESTINATION, "")),
StatusCode.REQUEST_DENIED),
Arguments.of(new Saml2AuthenticationException(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "")),
StatusCode.REQUESTER)

);
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,6 +24,7 @@
import org.opensaml.saml.saml2.core.LogoutRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
Expand Down Expand Up @@ -66,6 +67,15 @@ public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication au
return this.delegate.resolve(request, authentication);
}

/**
* {@inheritDoc}
*/
@Override
public Saml2LogoutResponse resolve(HttpServletRequest request, Authentication authentication,
Saml2AuthenticationException exception) {
return this.delegate.resolve(request, authentication, exception);
}

/**
* Set a {@link Consumer} for modifying the OpenSAML {@link LogoutRequest}
* @param parametersConsumer a consumer that accepts an
Expand Down
Loading