Skip to content

Commit 72ab844

Browse files
committed
Add query parameter support for authn requests
Closes gh-15017
1 parent 9771e4a commit 72ab844

File tree

5 files changed

+195
-12
lines changed

5 files changed

+195
-12
lines changed

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

Lines changed: 83 additions & 11 deletions
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;
@@ -113,6 +119,8 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
113119

114120
private String authenticationRequestUri = Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI;
115121

122+
private String[] authenticationRequestParams = new String[0];
123+
116124
private Saml2AuthenticationRequestResolver authenticationRequestResolver;
117125

118126
private RequestMatcher loginProcessingUrl = RequestMatchers.anyOf(
@@ -196,11 +204,30 @@ public Saml2LoginConfigurer<B> authenticationRequestResolver(
196204
* Request
197205
* @return the {@link Saml2LoginConfigurer} for further configuration
198206
* @since 6.0
207+
* @deprecated Use {@link #authenticationRequestUriQuery} instead
199208
*/
200209
public Saml2LoginConfigurer<B> authenticationRequestUri(String authenticationRequestUri) {
201-
Assert.state(authenticationRequestUri.contains("{registrationId}"),
202-
"authenticationRequestUri must contain {registrationId} path variable");
203-
this.authenticationRequestUri = authenticationRequestUri;
210+
return authenticationRequestUriQuery(authenticationRequestUri);
211+
}
212+
213+
/**
214+
* Customize the URL that the SAML Authentication Request will be sent to.
215+
* This method also supports query parameters like so: <pre>
216+
* authenticationRequestUriQuery("/saml/authenticate?registrationId={registrationId}")
217+
* </pre>
218+
* {@link RelyingPartyRegistrations}
219+
* @param authenticationRequestUriQuery the URI and query to use for the SAML 2.0
220+
* Authentication Request
221+
* @return the {@link Saml2LoginConfigurer} for further configuration
222+
* @since 6.0
223+
*/
224+
public Saml2LoginConfigurer<B> authenticationRequestUriQuery(String authenticationRequestUriQuery) {
225+
Assert.state(authenticationRequestUriQuery.contains("{registrationId}"),
226+
"authenticationRequestUri must contain {registrationId} path variable or query value");
227+
String[] parts = authenticationRequestUriQuery.split("[?&]");
228+
this.authenticationRequestUri = parts[0];
229+
this.authenticationRequestParams = new String[parts.length - 1];
230+
System.arraycopy(parts, 1, this.authenticationRequestParams, 0, parts.length - 1);
204231
return this;
205232
}
206233

@@ -255,7 +282,7 @@ public void init(B http) throws Exception {
255282
}
256283
else {
257284
Map<String, String> providerUrlMap = getIdentityProviderUrlMap(this.authenticationRequestUri,
258-
this.relyingPartyRegistrationRepository);
285+
this.authenticationRequestParams, this.relyingPartyRegistrationRepository);
259286
boolean singleProvider = providerUrlMap.size() == 1;
260287
if (singleProvider) {
261288
// Setup auto-redirect to provider login page
@@ -336,8 +363,14 @@ private Saml2AuthenticationRequestResolver getAuthenticationRequestResolver(B ht
336363
}
337364
OpenSaml4AuthenticationRequestResolver openSaml4AuthenticationRequestResolver = new OpenSaml4AuthenticationRequestResolver(
338365
relyingPartyRegistrationRepository(http));
339-
openSaml4AuthenticationRequestResolver
340-
.setRequestMatcher(new AntPathRequestMatcher(this.authenticationRequestUri));
366+
if (this.authenticationRequestParams.length > 0) {
367+
openSaml4AuthenticationRequestResolver.setRequestMatcher(
368+
new AntPathQueryRequestMatcher(this.authenticationRequestUri, this.authenticationRequestParams));
369+
}
370+
else {
371+
openSaml4AuthenticationRequestResolver
372+
.setRequestMatcher(new AntPathRequestMatcher(this.authenticationRequestUri));
373+
}
341374
return openSaml4AuthenticationRequestResolver;
342375
}
343376

@@ -382,20 +415,28 @@ private void initDefaultLoginFilter(B http) {
382415
return;
383416
}
384417
loginPageGeneratingFilter.setSaml2LoginEnabled(true);
385-
loginPageGeneratingFilter.setSaml2AuthenticationUrlToProviderName(
386-
this.getIdentityProviderUrlMap(this.authenticationRequestUri, this.relyingPartyRegistrationRepository));
418+
loginPageGeneratingFilter
419+
.setSaml2AuthenticationUrlToProviderName(this.getIdentityProviderUrlMap(this.authenticationRequestUri,
420+
this.authenticationRequestParams, this.relyingPartyRegistrationRepository));
387421
loginPageGeneratingFilter.setLoginPageUrl(this.getLoginPage());
388422
loginPageGeneratingFilter.setFailureUrl(this.getFailureUrl());
389423
}
390424

391425
@SuppressWarnings("unchecked")
392-
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl,
426+
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl, String[] authRequestQueryParams,
393427
RelyingPartyRegistrationRepository idpRepo) {
394428
Map<String, String> idps = new LinkedHashMap<>();
395429
if (idpRepo instanceof Iterable) {
396430
Iterable<RelyingPartyRegistration> repo = (Iterable<RelyingPartyRegistration>) idpRepo;
397-
repo.forEach((p) -> idps.put(authRequestPrefixUrl.replace("{registrationId}", p.getRegistrationId()),
398-
p.getRegistrationId()));
431+
StringBuilder authRequestQuery = new StringBuilder("?");
432+
for (String authRequestQueryParam : authRequestQueryParams) {
433+
authRequestQuery.append(authRequestQueryParam + "&");
434+
}
435+
authRequestQuery.deleteCharAt(authRequestQuery.length() - 1);
436+
String authenticationRequestUriQuery = authRequestPrefixUrl + authRequestQuery;
437+
repo.forEach(
438+
(p) -> idps.put(authenticationRequestUriQuery.replace("{registrationId}", p.getRegistrationId()),
439+
p.getRegistrationId()));
399440
}
400441
return idps;
401442
}
@@ -437,4 +478,35 @@ private <C> void setSharedObject(B http, Class<C> clazz, C object) {
437478
}
438479
}
439480

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

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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,19 @@ public void authenticationRequestWhenCustomAuthenticationRequestUriRepositoryThe
343343
any(HttpServletRequest.class), any(HttpServletResponse.class));
344344
}
345345

346+
@Test
347+
public void authenticationRequestWhenCustomAuthenticationRequestPathRepositoryThenUses() throws Exception {
348+
this.spring.register(CustomAuthenticationRequestUriQuery.class).autowire();
349+
MockHttpServletRequestBuilder request = get("/custom/auth/sso");
350+
this.mvc.perform(request)
351+
.andExpect(status().isFound())
352+
.andExpect(redirectedUrl("http://localhost/custom/auth/sso?entityId=registration-id"));
353+
request.queryParam("entityId", registration.getRegistrationId());
354+
MvcResult result = this.mvc.perform(request).andExpect(status().isFound()).andReturn();
355+
String redirectedUrl = result.getResponse().getRedirectedUrl();
356+
assertThat(redirectedUrl).startsWith(registration.getAssertingPartyDetails().getSingleSignOnServiceLocation());
357+
}
358+
346359
@Test
347360
public void saml2LoginWhenLoginProcessingUrlWithoutRegistrationIdAndDefaultAuthenticationConverterThenAutowires()
348361
throws Exception {
@@ -669,6 +682,23 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
669682

670683
}
671684

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

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

Lines changed: 42 additions & 1 deletion
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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,42 @@ For example, if you were deployed to `https://rp.example.com` and you gave your
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 `+/saml2/authenticate/{registrationId}+` 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?registrationId={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?registrationId={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)