Skip to content

Commit a9f98e8

Browse files
committed
Add query parameter support for authn requests
Closes spring-projectsgh-15017
1 parent 64fe29c commit a9f98e8

File tree

5 files changed

+200
-9
lines changed

5 files changed

+200
-9
lines changed

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

Lines changed: 82 additions & 8 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;
@@ -50,6 +54,7 @@
5054
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
5155
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
5256
import org.springframework.security.web.util.matcher.OrRequestMatcher;
57+
import org.springframework.security.web.util.matcher.ParameterRequestMatcher;
5358
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
5459
import org.springframework.security.web.util.matcher.RequestMatcher;
5560
import org.springframework.security.web.util.matcher.RequestMatchers;
@@ -113,6 +118,8 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
113118

114119
private String authenticationRequestUri = Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI;
115120

121+
private String[] authenticationRequestParams = new String[0];
122+
116123
private Saml2AuthenticationRequestResolver authenticationRequestResolver;
117124

118125
private RequestMatcher loginProcessingUrl = RequestMatchers.anyOf(
@@ -198,12 +205,36 @@ public Saml2LoginConfigurer<B> authenticationRequestResolver(
198205
* @since 6.0
199206
*/
200207
public Saml2LoginConfigurer<B> authenticationRequestUri(String authenticationRequestUri) {
201-
Assert.state(authenticationRequestUri.contains("{registrationId}"),
202-
"authenticationRequestUri must contain {registrationId} path variable");
208+
return authenticationRequestUri(authenticationRequestUri, new String[0]);
209+
}
210+
211+
/**
212+
* Customize the URL that the SAML Authentication Request will be sent to.
213+
* @param authenticationRequestUri the URI to use for the SAML 2.0 Authentication
214+
* Request
215+
* @param authenticationRequestParams any parameters to match on, of the form
216+
* {@code name=value}
217+
* @return the {@link Saml2LoginConfigurer} for further configuration
218+
* @since 6.0
219+
*/
220+
public Saml2LoginConfigurer<B> authenticationRequestUri(String authenticationRequestUri,
221+
String... authenticationRequestParams) {
222+
Assert.state(authenticationRequestUri.contains("{registrationId}") || anyContains(authenticationRequestParams),
223+
"authenticationRequestUri or an authenticationRequestParam must contain {registrationId} path variable");
203224
this.authenticationRequestUri = authenticationRequestUri;
225+
this.authenticationRequestParams = authenticationRequestParams;
204226
return this;
205227
}
206228

229+
private static boolean anyContains(String[] authenticationRequestParams) {
230+
for (String param : authenticationRequestParams) {
231+
if (param.contains("{registrationId}")) {
232+
return true;
233+
}
234+
}
235+
return false;
236+
}
237+
207238
/**
208239
* Specifies the URL to validate the credentials. If specified a custom URL, consider
209240
* specifying a custom {@link AuthenticationConverter} via
@@ -255,7 +286,7 @@ public void init(B http) throws Exception {
255286
}
256287
else {
257288
Map<String, String> providerUrlMap = getIdentityProviderUrlMap(this.authenticationRequestUri,
258-
this.relyingPartyRegistrationRepository);
289+
this.authenticationRequestParams, this.relyingPartyRegistrationRepository);
259290
boolean singleProvider = providerUrlMap.size() == 1;
260291
if (singleProvider) {
261292
// Setup auto-redirect to provider login page
@@ -336,8 +367,14 @@ private Saml2AuthenticationRequestResolver getAuthenticationRequestResolver(B ht
336367
}
337368
OpenSaml4AuthenticationRequestResolver openSaml4AuthenticationRequestResolver = new OpenSaml4AuthenticationRequestResolver(
338369
relyingPartyRegistrationRepository(http));
339-
openSaml4AuthenticationRequestResolver
340-
.setRequestMatcher(new AntPathRequestMatcher(this.authenticationRequestUri));
370+
if (this.authenticationRequestParams.length > 0) {
371+
openSaml4AuthenticationRequestResolver.setRequestMatcher(
372+
new AntPathQueryRequestMatcher(this.authenticationRequestUri, this.authenticationRequestParams));
373+
}
374+
else {
375+
openSaml4AuthenticationRequestResolver
376+
.setRequestMatcher(new AntPathRequestMatcher(this.authenticationRequestUri));
377+
}
341378
return openSaml4AuthenticationRequestResolver;
342379
}
343380

@@ -383,18 +420,24 @@ private void initDefaultLoginFilter(B http) {
383420
}
384421
loginPageGeneratingFilter.setSaml2LoginEnabled(true);
385422
loginPageGeneratingFilter.setSaml2AuthenticationUrlToProviderName(
386-
this.getIdentityProviderUrlMap(this.authenticationRequestUri, this.relyingPartyRegistrationRepository));
423+
this.getIdentityProviderUrlMap(this.authenticationRequestUri, this.authenticationRequestParams, this.relyingPartyRegistrationRepository));
387424
loginPageGeneratingFilter.setLoginPageUrl(this.getLoginPage());
388425
loginPageGeneratingFilter.setFailureUrl(this.getFailureUrl());
389426
}
390427

391428
@SuppressWarnings("unchecked")
392-
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl,
429+
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl, String[] authRequestQueryParams,
393430
RelyingPartyRegistrationRepository idpRepo) {
394431
Map<String, String> idps = new LinkedHashMap<>();
395432
if (idpRepo instanceof Iterable) {
396433
Iterable<RelyingPartyRegistration> repo = (Iterable<RelyingPartyRegistration>) idpRepo;
397-
repo.forEach((p) -> idps.put(authRequestPrefixUrl.replace("{registrationId}", p.getRegistrationId()),
434+
StringBuilder authRequestQuery = new StringBuilder("?");
435+
for (String authRequestQueryParam : authRequestQueryParams) {
436+
authRequestQuery.append(authRequestQueryParam + "&");
437+
}
438+
authRequestQuery.deleteCharAt(authRequestQuery.length() - 1);
439+
String authenticationRequestUriQuery = authRequestPrefixUrl + authRequestQuery;
440+
repo.forEach((p) -> idps.put(authenticationRequestUriQuery.replace("{registrationId}", p.getRegistrationId()),
398441
p.getRegistrationId()));
399442
}
400443
return idps;
@@ -437,4 +480,35 @@ private <C> void setSharedObject(B http, Class<C> clazz, C object) {
437480
}
438481
}
439482

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

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class Saml2Dsl {
5555
var permitAll: Boolean? = null
5656
var authenticationManager: AuthenticationManager? = null
5757

58+
private var authenticationRequestUri: String? = null
59+
private var authenticationRequestQuery: Array<out String> = emptyArray()
5860
private var defaultSuccessUrlOption: Pair<String, Boolean>? = null
5961

6062
/**
@@ -65,6 +67,11 @@ class Saml2Dsl {
6567
permitAll = true
6668
}
6769

70+
fun authenticationRequestUri(uri: String, vararg query: String) {
71+
authenticationRequestUri = uri
72+
authenticationRequestQuery = query
73+
}
74+
6875
/**
6976
* Specifies where users will be redirected after authenticating successfully if
7077
* they have not visited a secured page prior to authenticating or [alwaysUse]
@@ -88,6 +95,9 @@ class Saml2Dsl {
8895
defaultSuccessUrlOption?.also {
8996
saml2Login.defaultSuccessUrl(defaultSuccessUrlOption!!.first, defaultSuccessUrlOption!!.second)
9097
}
98+
authenticationRequestUri?.also {
99+
saml2Login.authenticationRequestUri(authenticationRequestUri, *authenticationRequestQuery)
100+
}
91101
authenticationSuccessHandler?.also { saml2Login.successHandler(authenticationSuccessHandler) }
92102
authenticationFailureHandler?.also { saml2Login.failureHandler(authenticationFailureHandler) }
93103
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=simplesamlphp"));
353+
request = 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.authenticationRequestUri("/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+
var registration = TestRelyingPartyRegistrations.relyingPartyRegistration().build();
146+
var 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 = 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+
authenticationRequestUri("/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+
.authenticationRequestUri("/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+
authenticationRequestUri("/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)