Skip to content

Commit ddf8e7a

Browse files
committed
Add Single Logout Support
Closes gh-8731
1 parent 8453ed4 commit ddf8e7a

File tree

32 files changed

+3567
-25
lines changed

32 files changed

+3567
-25
lines changed

docs/manual/src/docs/asciidoc/_includes/servlet/saml2/saml2-login.adoc

Lines changed: 290 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,20 +1048,302 @@ filter.setRequestMatcher(new AntPathRequestMatcher("/saml2/metadata", "GET"));
10481048
[[servlet-saml2login-logout]]
10491049
=== Performing Single Logout
10501050

1051-
Spring Security does not yet support single logout.
1051+
Spring Security ships with support for RP- and AP-initiated SAML 2.0 Single Logout.
10521052

1053-
Generally speaking, though, you can achieve this by creating and registering a custom `LogoutSuccessHandler` and `RequestMatcher`:
1053+
Briefly, there are two use cases Spring Security supports:
1054+
1055+
* **RP-Initiated** - Your application has an endpoint that, when POSTed to, will send a `saml2:LogoutRequest` to the asserting party.
1056+
Thereafter, the asserting party will send back a `saml2:LogoutResponse` and your application will complete its logout at that point
1057+
* **AP-Initiated** - Your application has an endpoint that will receive a `saml2:LogoutRequest` from the asserting party.
1058+
Your application will complete its logout at that point and then send a `saml2:LogoutResponse` to the asserting party.
1059+
1060+
=== Minimal Configuration for Single Logout
1061+
1062+
To use Spring Security's SAML 2.0 Single Logout feature, you will need the following things:
1063+
1064+
* First, the asserting party must support SAML 2.0 Single Logout
1065+
* Second, the asserting party should be configured to sign and POST `saml2:LogoutRequest` s and `saml2:LogoutResponse` s your application's `/logout` endpoint
1066+
* Third, your application must have a PKCS#8 private key and X.509 certificate for signing `saml2:LogoutRequest` s and `saml2:LogoutResponse` s
1067+
1068+
==== RP-Initiated Single Logout
1069+
1070+
Given those, then for RP-initiated Single Logout, you can begin from the initial minimal example and add the following configuration:
1071+
1072+
[source,java]
1073+
----
1074+
@Value("${private.key}") RSAPrivateKey key;
1075+
@Value("${public.certificate}") X509Certificate certificate;
1076+
1077+
@Bean
1078+
RelyingPartyRegistrationRepository registrations() {
1079+
RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
1080+
.fromMetadataLocation("https://ap.example.org/metadata")
1081+
.registrationId("id")
1082+
.signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1>
1083+
.build();
1084+
return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration);
1085+
}
1086+
1087+
@Bean
1088+
SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception {
1089+
RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations);
1090+
1091+
http
1092+
.authorizeRequests((authorize) -> authorize
1093+
.anyRequest().authenticated()
1094+
)
1095+
.saml2Login(withDefaults())
1096+
.logout((logout) -> logout.addLogoutHandler(logoutHandler(registrationResolver)))
1097+
.addFilterBefore(filter(registrationResolver), LogoutFilter.class)
1098+
.csrf((csrf) -> csrf.ignoringRequestMatchers(hasLogoutResponse("/logout", "POST")));
1099+
1100+
return http.build();
1101+
}
1102+
1103+
private RequestMatcher hasLogoutResponse(String endpoint, String method) {
1104+
AntPathRequestMatcher logout = new AndPathRequestMatcher(endpoint, method);
1105+
return (request) -> logout.matches(request) && request.getParameter("SAMLResponse") != null;
1106+
}
1107+
1108+
private Filter filter(RelyingPartyRegistrationResolver registrationResolver) { <2>
1109+
OpenSamlLogoutRequestResolver delegate = new OpenSamlLogoutRequestResolver(registrationResolver);
1110+
Saml2LogoutRequestResolver logoutRequestResolver = (request, authentication) ->
1111+
delegate.resolveLogoutRequest(request, authentication)
1112+
.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(DateTime.now()));
1113+
return new Saml2RelyingPartyInitiatedLogoutFilter(logoutRequestResolver);
1114+
}
1115+
1116+
private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <3>
1117+
return new OpenSamlLogoutResponseHandler(relyingPartyRegistrationResolver);
1118+
}
1119+
----
1120+
<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <<servlet-saml2login-rpr-duplicated,multiple instances>>
1121+
<2> - Second, supply a `Filter` for initiating Single Logout, sending a `saml2:LogoutRequest` to the asserting party
1122+
<3> - Third, supply the `LogoutHandler` s needed to handle the `saml2:LogoutResponse` s sent from the asserting party.
1123+
1124+
==== Runtime Expectations for RP-Initiated
1125+
1126+
Given the above configuration any logged in user can send a `POST /saml2/logout` to your application.
1127+
Your application will then do the following:
1128+
1129+
1. Use a `Saml2LogoutRequestResolver` to create, sign, and serialize a `<saml2:LogoutRequest>` based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>> associated with the currently logged-in user.
1130+
2. Send a redirect or post to the asserting party based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>>
1131+
3. Deserialize, verify, and process the `<saml2:LogoutResponse>` sent by the asserting party
1132+
4. Logout the user and redirect to any configured successful logout endpoint
1133+
1134+
[TIP]
1135+
If your asserting party does not send `<saml2:LogoutResponse>` s when logout is complete, the asserting party can still send a `POST /logout` and then there is no need to configure the `Saml2LogoutResponseHandler`.
1136+
1137+
==== AP-Initiated Single Logout
1138+
1139+
Instead of RP-initiated Single Logout, you can again begin from the initial minimal example and add the following configuration to achieve AP-initiated Single Logout:
1140+
1141+
[source,java]
1142+
----
1143+
@Value("${private.key}") RSAPrivateKey key;
1144+
@Value("${public.certificate}") X509Certificate certificate;
1145+
1146+
@Bean
1147+
RelyingPartyRegistrationRepository registrations() {
1148+
RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
1149+
.fromMetadataLocation("https://ap.example.org/metadata")
1150+
.registrationId("id")
1151+
.signingX509Credentials((signing) -> signing.add(Saml2X509Credential.signing(key, certificate))) <1>
1152+
.build();
1153+
return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistration);
1154+
}
1155+
1156+
@Bean
1157+
SecurityFilterChain web(HttpSecurity http, RelyingPartyRegistrationRepository registrations) throws Exception {
1158+
RelyingPartyRegistrationResolver registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrations);
1159+
1160+
http
1161+
.authorizeRequests((authorize) -> authorize
1162+
.anyRequest().authenticated()
1163+
)
1164+
.saml2Login(withDefaults())
1165+
.logout((logout) -> logout
1166+
.addLogoutHandler(logoutHandler(registrationResolver))
1167+
.logoutSuccessHandler(logoutSuccessHandler(registrationResolver))
1168+
)
1169+
.csrf((csrf) -> csrf.ignoringRequestMatchers(hasLogoutRequest("/logout", "POST")));
1170+
1171+
return http.build();
1172+
}
1173+
1174+
private RequestMatcher hasLogoutRequest(String endpoint, String method) {
1175+
AntPathRequestMatcher logout = new AndPathRequestMatcher(endpoint, method);
1176+
return (request) -> logout.matches(request) && request.getParameter("SAMLRequest") != null;
1177+
}
1178+
1179+
private LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) { <2>
1180+
return new OpenSamlLogoutRequestHandler(relyingPartyRegistrationResolver);
1181+
}
1182+
1183+
private LogoutSuccessHandler logoutSuccessHandler(RelyingPartyRegistrationResolver registrationResolver) { <3>
1184+
OpenSamlLogoutResponseResolver delegate = new OpenSamlLogoutResponseResolver(registrationResolver);
1185+
Saml2LogoutResponsetResolver logoutResponseResolver = (request, authentication) ->
1186+
delegate.resolveLogoutResponse(request, authentication)
1187+
.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(DateTime.now()));
1188+
return new Saml2AssertingPartyInitiatedLogoutSuccessHandler(logoutResponseResolver);
1189+
}
1190+
----
1191+
<1> - First, add your signing key to the `RelyingPartyRegistration` instance or to <<servlet-saml2login-rpr-duplicated,multiple instances>>
1192+
<2> - Second, supply the `LogoutHandler` needed to handle the `saml2:LogoutRequest` s sent from the asserting party.
1193+
<3> - Third, supply a `LogoutSuccessHandler` for completing Single Logout, sending a `saml2:LogoutResponse` to the asserting party
1194+
1195+
==== Runtime Expectations for AP-Initiated
1196+
1197+
Given the above configuration, an asserting party can send a `POST /logout` to your application that includes a `<saml2:LogoutRequest>`
1198+
Your application will then do the following:
1199+
1200+
1. Use a `Saml2LogoutRequestHandler` to deserialize, verify, and process the `<saml2:LogoutRequest>` sent by the asserting party
1201+
2. Logout the user
1202+
3. Create, sign, and serialize a `<saml2:LogoutResponse>` based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>> associated with the just logged-out user
1203+
4. Send a redirect or post to the asserting party based on the <<servlet-saml2login-relyingpartyregistration,`RelyingPartyRegistration`>>
1204+
1205+
[TIP]
1206+
If your asserting party does not expect you do send a `<saml2:LogoutResponse>` s when logout is complete, you may not need to configure a `LogoutSuccessHandler`
1207+
1208+
[NOTE]
1209+
In the event that you need to support both logout flows, you can combine the above to configurations.
1210+
1211+
=== Configuring Logout Endpoints
1212+
1213+
There are two default endpoints that Spring Security's SAML 2.0 Single Logout support exposes:
1214+
* `/saml2/logout` - the endpoint for initiating single logout with an asserting party
1215+
* `/logout` - the endpoint for receiving logout requests and responses from an asserting party
1216+
1217+
Because the user is already logged in, the `registrationId` is already known.
1218+
For this reason, `+{registrationId}+` is not part of these URLs by default.
1219+
1220+
The first URL is not customizable at this point since this is not a URL that gets configured with the asserting party.
1221+
As such the need to customize this endpoint is minimal, though this can be added to the support down the road.
1222+
1223+
The second URL is customizable through Spring Security's <<jc-logout,general-purpose logout support>>.
1224+
1225+
For example, if you are migrating your existing relying party over to Spring Security, your asserting party may already be pointing to `GET /SLOService.saml2`.
1226+
To reduce changes in configuration for the asserting party, you can configure `logout` in the DSL like so:
10541227

10551228
[source,java]
10561229
----
10571230
http
10581231
// ...
1059-
.logout(logout -> logout
1060-
.logoutSuccessHandler(myCustomSuccessHandler())
1061-
.logoutRequestMatcher(myRequestMatcher())
1062-
)
1232+
.logout((logout) -> logout
1233+
.logoutRequestMatcher(new AntPathRequestMatcher("/SLOService.saml2", "POST"))
1234+
// add logout handlers
1235+
);
10631236
----
10641237

1065-
The success handler will send logout requests to the asserting party.
1238+
In redirect circumstances it's important to maintain your defense mechanisms for CSRF Logout.
1239+
You can configure your SAML 2.0 logout as a GET by only matching when there is a `SAMLRequest` (or `SAMLResponse`) present, like so:
10661240

1067-
The request matcher will detect logout requests from the asserting party.
1241+
[source,java]
1242+
----
1243+
http
1244+
// ...
1245+
.logout((logout) -> logout
1246+
.logoutRequestMatcher(hasLogoutRequest("/SLOService.saml2", "GET"))
1247+
// add logout handlers
1248+
);
1249+
----
1250+
1251+
=== Customizing `<saml2:LogoutRequest>` Resolution
1252+
1253+
It's common to need to set other values in the `<saml2:LogoutRequest>` than the defaults that Spring Security provides.
1254+
1255+
By default, Spring Security will issue a `<saml2:LogoutRequest>` and supply:
1256+
1257+
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceLocation`
1258+
* The `ID` attribute - a GUID
1259+
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
1260+
* The `<NameID>` element - from `Authentication#getName`
1261+
1262+
To add other values, you can use delegation, like so:
1263+
1264+
[source,java]
1265+
----
1266+
OpenSamlLogoutRequestResolver delegate = new OpenSamlLogoutRequestResolver(registrationResolver);
1267+
return (request, response, authentication) -> {
1268+
OpenSamlLogoutRequestPartial partial = delegate.resolveLogoutRequest(request, response, authentication); <1>
1269+
partial.name(((Saml2AuthenticatedPrincipal) authentication.getPrincipal()).getFirstAttribute("CustomAttribute")); <2>
1270+
partial.logoutRequest((logoutRequest) -> logoutRequest.setIssueInstant(DateTime.now()));
1271+
return partial.logoutRequest(); <3>
1272+
};
1273+
----
1274+
<1> - Spring Security partially applies the values to a `<saml2:LogoutRequest>`
1275+
<2> - Your application specifies customizations
1276+
<3> - You complete the invocation by calling `request()`
1277+
1278+
[NOTE]
1279+
Support for OpenSAML 4 is coming.
1280+
In anticipation of that, `OpenSamlLogoutRequestResolver` does not add an `IssueInstant`.
1281+
Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it.
1282+
1283+
=== Customizing `<saml2:LogoutResponse>` Resolution
1284+
1285+
It's common to need to set other values in the `<saml2:LogoutResponse>` than the defaults that Spring Security provides.
1286+
1287+
By default, Spring Security will issue a `<saml2:LogoutResponse>` and supply:
1288+
1289+
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyDetails#getSingleLogoutServiceResponseLocation`
1290+
* The `ID` attribute - a GUID
1291+
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
1292+
* The `<Status>` element - `SUCCESS`
1293+
1294+
To add other values, you can use delegation, like so:
1295+
1296+
[source,java]
1297+
----
1298+
OpenSamlLogoutResponseResolver delegate = new OpenSamlLogoutResponseResolver(registrationResolver);
1299+
return (request, response, authentication) -> {
1300+
OpenSamlLogoutResponsePartial partial = delegate.resolveLogoutResponse(request, response, authentication); <1>
1301+
if (checkOtherPrevailingConditions()) {
1302+
partial.status(StatusCode.PARTIAL_LOGOUT); <2>
1303+
}
1304+
partial.logoutResponse((logoutResponse) -> logoutResponse.setIssueInstant(DateTime.now()));
1305+
return partial.logoutResponse(); <3>
1306+
};
1307+
----
1308+
<1> - Spring Security partially applies the values to a `<saml2:LogoutResponse>`
1309+
<2> - Your application specifies customizations
1310+
<3> - You complete the invocation by calling `response()`
1311+
1312+
[NOTE]
1313+
Support for OpenSAML 4 is coming.
1314+
In anticipation of that, `OpenSamlLogoutResponseResolver` does not add an `IssueInstant`.
1315+
Once OpenSAML 4 support is added, the default will be able to appropriate negotiate that datatype change, meaning you will no longer have to set it.
1316+
1317+
=== Customizing `<saml2:LogoutRequest>` Validation
1318+
1319+
To customize validation, you can implement your own `LogoutHandler`.
1320+
At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so:
1321+
1322+
[source,java]
1323+
----
1324+
LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) {
1325+
OpenSamlLogoutRequestHandler delegate = new OpenSamlLogoutRequestHandler(registrationResolver);
1326+
return (request, response, authentication) -> {
1327+
delegate.logout(request, response, authentication); // verify signature, issuer, destination, and principal name
1328+
LogoutRequest logoutRequest = // ... parse using OpenSAML
1329+
// perform custom validation
1330+
}
1331+
}
1332+
----
1333+
1334+
=== Customizing `<saml2:LogoutResponse>` Validation
1335+
1336+
To customize validation, you can implement your own `LogoutHandler`.
1337+
At this point, the validation is minimal, so you may be able to first delegate to the default `LogoutHandler` like so:
1338+
1339+
[source,java]
1340+
----
1341+
LogoutHandler logoutHandler(RelyingPartyRegistrationResolver registrationResolver) {
1342+
OpenSamlLogoutResponseHandler delegate = new OpenSamlLogoutResponseHandler(registrationResolver);
1343+
return (request, response, authentication) -> {
1344+
delegate.logout(request, response, authentication); // verify signature, issuer, destination, and status
1345+
LogoutResponse logoutResponse = // ... parse using OpenSAML
1346+
// perform custom validation
1347+
}
1348+
}
1349+
----

saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/core/Saml2ErrorCodes.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ public interface Saml2ErrorCodes {
3737
*/
3838
String MALFORMED_RESPONSE_DATA = "malformed_response_data";
3939

40+
/**
41+
* Request is invalid in a general way.
42+
*
43+
* @since 5.5
44+
*/
45+
String INVALID_REQUEST = "invalid_request";
46+
4047
/**
4148
* Response is invalid in a general way.
4249
*

0 commit comments

Comments
 (0)