Skip to content

Commit 796e4d6

Browse files
committed
Add query parameter support for authn requests
Closes gh-15017
1 parent 587aa49 commit 796e4d6

File tree

6 files changed

+237
-17
lines changed

6 files changed

+237
-17
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java

+82-12
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616

1717
package org.springframework.security.config.annotation.web.configurers.saml2;
1818

19+
import java.util.ArrayList;
1920
import java.util.LinkedHashMap;
21+
import java.util.List;
2022
import java.util.Map;
2123

24+
import jakarta.servlet.http.HttpServletRequest;
25+
2226
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
2327
import org.springframework.context.ApplicationContext;
2428
import org.springframework.security.authentication.AuthenticationManager;
@@ -33,6 +37,7 @@
3337
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
3438
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
3539
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
40+
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations;
3641
import org.springframework.security.saml2.provider.service.web.HttpSessionSaml2AuthenticationRequestRepository;
3742
import org.springframework.security.saml2.provider.service.web.OpenSamlAuthenticationTokenConverter;
3843
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
@@ -50,6 +55,7 @@
5055
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
5156
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
5257
import org.springframework.security.web.util.matcher.OrRequestMatcher;
58+
import org.springframework.security.web.util.matcher.ParameterRequestMatcher;
5359
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
5460
import org.springframework.security.web.util.matcher.RequestMatcher;
5561
import org.springframework.security.web.util.matcher.RequestMatchers;
@@ -111,7 +117,13 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
111117

112118
private String loginPage;
113119

114-
private String authenticationRequestUri = Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI;
120+
private String authenticationRequestUri = "/saml2/authenticate";
121+
122+
private String[] authenticationRequestParams = { "registrationId={registrationId}" };
123+
124+
private RequestMatcher authenticationRequestMatcher = RequestMatchers.anyOf(
125+
new AntPathRequestMatcher(Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI),
126+
new AntPathQueryRequestMatcher(this.authenticationRequestUri, this.authenticationRequestParams));
115127

116128
private Saml2AuthenticationRequestResolver authenticationRequestResolver;
117129

@@ -196,11 +208,31 @@ public Saml2LoginConfigurer<B> authenticationRequestResolver(
196208
* Request
197209
* @return the {@link Saml2LoginConfigurer} for further configuration
198210
* @since 6.0
211+
* @deprecated Use {@link #authenticationRequestUriQuery} instead
199212
*/
200213
public Saml2LoginConfigurer<B> authenticationRequestUri(String authenticationRequestUri) {
201-
Assert.state(authenticationRequestUri.contains("{registrationId}"),
202-
"authenticationRequestUri must contain {registrationId} path variable");
203-
this.authenticationRequestUri = authenticationRequestUri;
214+
return authenticationRequestUriQuery(authenticationRequestUri);
215+
}
216+
217+
/**
218+
* Customize the URL that the SAML Authentication Request will be sent to. This method
219+
* also supports query parameters like so: <pre>
220+
* authenticationRequestUriQuery("/saml/authenticate?registrationId={registrationId}")
221+
* </pre> {@link RelyingPartyRegistrations}
222+
* @param authenticationRequestUriQuery the URI and query to use for the SAML 2.0
223+
* Authentication Request
224+
* @return the {@link Saml2LoginConfigurer} for further configuration
225+
* @since 6.0
226+
*/
227+
public Saml2LoginConfigurer<B> authenticationRequestUriQuery(String authenticationRequestUriQuery) {
228+
Assert.state(authenticationRequestUriQuery.contains("{registrationId}"),
229+
"authenticationRequestUri must contain {registrationId} path variable or query value");
230+
String[] parts = authenticationRequestUriQuery.split("[?&]");
231+
this.authenticationRequestUri = parts[0];
232+
this.authenticationRequestParams = new String[parts.length - 1];
233+
System.arraycopy(parts, 1, this.authenticationRequestParams, 0, parts.length - 1);
234+
this.authenticationRequestMatcher = new AntPathQueryRequestMatcher(this.authenticationRequestUri,
235+
this.authenticationRequestParams);
204236
return this;
205237
}
206238

@@ -255,7 +287,7 @@ public void init(B http) throws Exception {
255287
}
256288
else {
257289
Map<String, String> providerUrlMap = getIdentityProviderUrlMap(this.authenticationRequestUri,
258-
this.relyingPartyRegistrationRepository);
290+
this.authenticationRequestParams, this.relyingPartyRegistrationRepository);
259291
boolean singleProvider = providerUrlMap.size() == 1;
260292
if (singleProvider) {
261293
// Setup auto-redirect to provider login page
@@ -336,8 +368,7 @@ private Saml2AuthenticationRequestResolver getAuthenticationRequestResolver(B ht
336368
}
337369
OpenSaml4AuthenticationRequestResolver openSaml4AuthenticationRequestResolver = new OpenSaml4AuthenticationRequestResolver(
338370
relyingPartyRegistrationRepository(http));
339-
openSaml4AuthenticationRequestResolver
340-
.setRequestMatcher(new AntPathRequestMatcher(this.authenticationRequestUri));
371+
openSaml4AuthenticationRequestResolver.setRequestMatcher(this.authenticationRequestMatcher);
341372
return openSaml4AuthenticationRequestResolver;
342373
}
343374

@@ -382,20 +413,28 @@ private void initDefaultLoginFilter(B http) {
382413
return;
383414
}
384415
loginPageGeneratingFilter.setSaml2LoginEnabled(true);
385-
loginPageGeneratingFilter.setSaml2AuthenticationUrlToProviderName(
386-
this.getIdentityProviderUrlMap(this.authenticationRequestUri, this.relyingPartyRegistrationRepository));
416+
loginPageGeneratingFilter
417+
.setSaml2AuthenticationUrlToProviderName(this.getIdentityProviderUrlMap(this.authenticationRequestUri,
418+
this.authenticationRequestParams, this.relyingPartyRegistrationRepository));
387419
loginPageGeneratingFilter.setLoginPageUrl(this.getLoginPage());
388420
loginPageGeneratingFilter.setFailureUrl(this.getFailureUrl());
389421
}
390422

391423
@SuppressWarnings("unchecked")
392-
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl,
424+
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl, String[] authRequestQueryParams,
393425
RelyingPartyRegistrationRepository idpRepo) {
394426
Map<String, String> idps = new LinkedHashMap<>();
395427
if (idpRepo instanceof Iterable) {
396428
Iterable<RelyingPartyRegistration> repo = (Iterable<RelyingPartyRegistration>) idpRepo;
397-
repo.forEach((p) -> idps.put(authRequestPrefixUrl.replace("{registrationId}", p.getRegistrationId()),
398-
p.getRegistrationId()));
429+
StringBuilder authRequestQuery = new StringBuilder("?");
430+
for (String authRequestQueryParam : authRequestQueryParams) {
431+
authRequestQuery.append(authRequestQueryParam + "&");
432+
}
433+
authRequestQuery.deleteCharAt(authRequestQuery.length() - 1);
434+
String authenticationRequestUriQuery = authRequestPrefixUrl + authRequestQuery;
435+
repo.forEach(
436+
(p) -> idps.put(authenticationRequestUriQuery.replace("{registrationId}", p.getRegistrationId()),
437+
p.getRegistrationId()));
399438
}
400439
return idps;
401440
}
@@ -437,4 +476,35 @@ private <C> void setSharedObject(B http, Class<C> clazz, C object) {
437476
}
438477
}
439478

479+
static class AntPathQueryRequestMatcher implements RequestMatcher {
480+
481+
private final RequestMatcher matcher;
482+
483+
AntPathQueryRequestMatcher(String path, String... params) {
484+
List<RequestMatcher> matchers = new ArrayList<>();
485+
matchers.add(new AntPathRequestMatcher(path));
486+
for (String param : params) {
487+
String[] parts = param.split("=");
488+
if (parts.length == 1) {
489+
matchers.add(new ParameterRequestMatcher(parts[0]));
490+
}
491+
else {
492+
matchers.add(new ParameterRequestMatcher(parts[0], parts[1]));
493+
}
494+
}
495+
this.matcher = new AndRequestMatcher(matchers);
496+
}
497+
498+
@Override
499+
public boolean matches(HttpServletRequest request) {
500+
return matcher(request).isMatch();
501+
}
502+
503+
@Override
504+
public MatchResult matcher(HttpServletRequest request) {
505+
return this.matcher.matcher(request);
506+
}
507+
508+
}
509+
440510
}

config/src/main/kotlin/org/springframework/security/config/annotation/web/Saml2Dsl.kt

+4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
4848
class Saml2Dsl {
4949
var relyingPartyRegistrationRepository: RelyingPartyRegistrationRepository? = null
5050
var loginPage: String? = null
51+
var authenticationRequestUriQuery: String? = null
5152
var authenticationSuccessHandler: AuthenticationSuccessHandler? = null
5253
var authenticationFailureHandler: AuthenticationFailureHandler? = null
5354
var failureUrl: String? = null
@@ -88,6 +89,9 @@ class Saml2Dsl {
8889
defaultSuccessUrlOption?.also {
8990
saml2Login.defaultSuccessUrl(defaultSuccessUrlOption!!.first, defaultSuccessUrlOption!!.second)
9091
}
92+
authenticationRequestUriQuery?.also {
93+
saml2Login.authenticationRequestUriQuery(authenticationRequestUriQuery)
94+
}
9195
authenticationSuccessHandler?.also { saml2Login.successHandler(authenticationSuccessHandler) }
9296
authenticationFailureHandler?.also { saml2Login.failureHandler(authenticationFailureHandler) }
9397
authenticationManager?.also { saml2Login.authenticationManager(authenticationManager) }

config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java

+33-1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
import org.springframework.web.util.UriComponentsBuilder;
102102

103103
import static org.assertj.core.api.Assertions.assertThat;
104+
import static org.hamcrest.Matchers.startsWith;
104105
import static org.mockito.ArgumentMatchers.any;
105106
import static org.mockito.BDDMockito.given;
106107
import static org.mockito.Mockito.atLeastOnce;
@@ -113,6 +114,7 @@
113114
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
114115
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
115116
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
117+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
116118
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
117119
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
118120

@@ -343,6 +345,19 @@ public void authenticationRequestWhenCustomAuthenticationRequestUriRepositoryThe
343345
any(HttpServletRequest.class), any(HttpServletResponse.class));
344346
}
345347

348+
@Test
349+
public void authenticationRequestWhenCustomAuthenticationRequestPathRepositoryThenUses() throws Exception {
350+
this.spring.register(CustomAuthenticationRequestUriQuery.class).autowire();
351+
MockHttpServletRequestBuilder request = get("/custom/auth/sso");
352+
this.mvc.perform(request)
353+
.andExpect(status().isFound())
354+
.andExpect(redirectedUrl("http://localhost/custom/auth/sso?entityId=registration-id"));
355+
request.queryParam("entityId", registration.getRegistrationId());
356+
MvcResult result = this.mvc.perform(request).andExpect(status().isFound()).andReturn();
357+
String redirectedUrl = result.getResponse().getRedirectedUrl();
358+
assertThat(redirectedUrl).startsWith(registration.getAssertingPartyDetails().getSingleSignOnServiceLocation());
359+
}
360+
346361
@Test
347362
public void saml2LoginWhenLoginProcessingUrlWithoutRegistrationIdAndDefaultAuthenticationConverterThenAutowires()
348363
throws Exception {
@@ -390,7 +405,7 @@ public void getFaviconWhenDefaultConfigurationThenDoesNotSaveAuthnRequest() thro
390405
.andExpect(redirectedUrl("http://localhost/login"));
391406
this.mvc.perform(get("/").accept(MediaType.TEXT_HTML))
392407
.andExpect(status().isFound())
393-
.andExpect(redirectedUrl("http://localhost/saml2/authenticate/registration-id"));
408+
.andExpect(header().string("Location", startsWith("http://localhost/saml2/authenticate")));
394409
}
395410

396411
@Test
@@ -669,6 +684,23 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
669684

670685
}
671686

687+
@Configuration
688+
@EnableWebSecurity
689+
@Import(Saml2LoginConfigBeans.class)
690+
static class CustomAuthenticationRequestUriQuery {
691+
692+
@Bean
693+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
694+
// @formatter:off
695+
http
696+
.authorizeHttpRequests((authz) -> authz.anyRequest().authenticated())
697+
.saml2Login((saml2) -> saml2.authenticationRequestUriQuery("/custom/auth/sso?entityId={registrationId}"));
698+
// @formatter:on
699+
return http.build();
700+
}
701+
702+
}
703+
672704
@Configuration
673705
@EnableWebSecurity
674706
@Import(Saml2LoginConfigBeans.class)

config/src/test/kotlin/org/springframework/security/config/annotation/web/Saml2DslTests.kt

+42-1
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ import org.springframework.security.saml2.provider.service.registration.TestRely
4343
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter
4444
import org.springframework.security.web.SecurityFilterChain
4545
import org.springframework.test.web.servlet.MockMvc
46+
import org.springframework.test.web.servlet.MvcResult
4647
import org.springframework.test.web.servlet.get
4748
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
49+
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
4850
import java.security.cert.Certificate
4951
import java.security.cert.CertificateFactory
50-
import java.util.Base64
52+
import java.util.*
5153

5254
/**
5355
* Tests for [Saml2Dsl]
@@ -136,6 +138,23 @@ class Saml2DslTests {
136138
verify(exactly = 1) { Saml2LoginCustomAuthenticationManagerConfig.AUTHENTICATION_MANAGER.authenticate(any()) }
137139
}
138140

141+
@Test
142+
@Throws(Exception::class)
143+
fun authenticationRequestWhenCustomAuthenticationRequestPathRepositoryThenUses() {
144+
this.spring.register(CustomAuthenticationRequestUriQuery::class.java).autowire()
145+
val registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
146+
val request = MockMvcRequestBuilders.get("/custom/auth/sso")
147+
this.mockMvc.perform(request)
148+
.andExpect(MockMvcResultMatchers.status().isFound())
149+
.andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/custom/auth/sso?entityId=simplesamlphp"))
150+
request.queryParam("entityId", registration.registrationId)
151+
val result: MvcResult =
152+
this.mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isFound()).andReturn()
153+
val redirectedUrl = result.response.redirectedUrl
154+
Assertions.assertThat(redirectedUrl)
155+
.startsWith(registration.assertingPartyDetails.singleSignOnServiceLocation)
156+
}
157+
139158
@Configuration
140159
@EnableWebSecurity
141160
open class Saml2LoginCustomAuthenticationManagerConfig {
@@ -162,4 +181,26 @@ class Saml2DslTests {
162181
return repository
163182
}
164183
}
184+
185+
@Configuration
186+
@EnableWebSecurity
187+
open class CustomAuthenticationRequestUriQuery {
188+
@Bean
189+
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
190+
http {
191+
authorizeHttpRequests {
192+
authorize(anyRequest, authenticated)
193+
}
194+
saml2Login {
195+
authenticationRequestUriQuery = "/custom/auth/sso?entityId={registrationId}"
196+
}
197+
}
198+
return http.build()
199+
}
200+
201+
@Bean
202+
open fun relyingPartyRegistrationRepository(): RelyingPartyRegistrationRepository? {
203+
return InMemoryRelyingPartyRegistrationRepository(TestRelyingPartyRegistrations.relyingPartyRegistration().build())
204+
}
205+
}
165206
}

docs/modules/ROOT/pages/servlet/saml2/login/authentication-requests.adoc

+37-1
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,50 @@
44
As stated earlier, Spring Security's SAML 2.0 support produces a `<saml2:AuthnRequest>` to commence authentication with the asserting party.
55

66
Spring Security achieves this in part by registering the `Saml2WebSsoAuthenticationRequestFilter` in the filter chain.
7-
This filter by default responds to endpoint `+/saml2/authenticate/{registrationId}+`.
7+
This filter by default responds to the endpoints `+/saml2/authenticate/{registrationId}+` and `+/saml2/authenticate?registrationId={registrationId}+`.
88

99
For example, if you were deployed to `https://rp.example.com` and you gave your registration an ID of `okta`, you could navigate to:
1010

1111
`https://rp.example.org/saml2/authenticate/okta`
1212

1313
and the result would be a redirect that included a `SAMLRequest` parameter containing the signed, deflated, and encoded `<saml2:AuthnRequest>`.
1414

15+
== Configuring the `<saml2:AuthnRequest>` Endpoint
16+
17+
To configure the endpoint differently from the default, you can set the value in `saml2Login`:
18+
19+
[tabs]
20+
======
21+
Java::
22+
+
23+
[source,java,role="primary"]
24+
----
25+
@Bean
26+
SecurityFilterChain filterChain(HttpSecurity http) {
27+
http
28+
.saml2Login((saml2) -> saml2
29+
.authenticationRequestUriQuery("/custom/auth/sso?peerEntityID={registrationId}")
30+
);
31+
return new CustomSaml2AuthenticationRequestRepository();
32+
}
33+
----
34+
35+
Kotlin::
36+
+
37+
[source,kotlin,role="secondary"]
38+
----
39+
@Bean
40+
fun filterChain(http: HttpSecurity): SecurityFilterChain {
41+
http {
42+
saml2Login {
43+
authenticationRequestUriQuery = "/custom/auth/sso?peerEntityID={registrationId}"
44+
}
45+
}
46+
return CustomSaml2AuthenticationRequestRepository()
47+
}
48+
----
49+
======
50+
1551
[[servlet-saml2login-store-authn-request]]
1652
== Changing How the `<saml2:AuthnRequest>` Gets Stored
1753

0 commit comments

Comments
 (0)