To verify SAML 2.0 Responses, Spring Security uses Saml2AuthenticationTokenConverter
to populate the Authentication
request and OpenSaml4AuthenticationProvider
to authenticate it.
You can configure this in a number of ways including:
-
Changing the way the
RelyingPartyRegistration
is Looked Up -
Setting a clock skew to timestamp validation
-
Mapping the response to a list of
GrantedAuthority
instances -
Customizing the strategy for validating assertions
-
Customizing the strategy for decrypting response and assertion elements
To configure these, you’ll use the saml2Login#authenticationManager
method in the DSL.
RelyingPartyRegistration
lookup is customized in a RelyingPartyRegistrationResolver
.
To apply a RelyingPartyRegistrationResolver
when processing <saml2:Response>
payloads, you should first publish a Saml2AuthenticationTokenConverter
bean like so:
- Java
-
@Bean Saml2AuthenticationTokenConverter authenticationConverter(InMemoryRelyingPartyRegistrationRepository registrations) { return new Saml2AuthenticationTokenConverter(new MyRelyingPartyRegistrationResolver(registrations)); }
- Kotlin
-
@Bean fun authenticationConverter(val registrations: InMemoryRelyingPartyRegistrationRepository): Saml2AuthenticationTokenConverter { return Saml2AuthenticationTokenConverter(MyRelyingPartyRegistrationResolver(registrations)); }
Recall that the Assertion Consumer Service URL is /saml2/login/sso/{registrationId}
by default.
If you are no longer wanting the registrationId
in the URL, change it in the filter chain and in your relying party metadata:
- Java
-
@Bean SecurityFilterChain securityFilters(HttpSecurity http) throws Exception { http // ... .saml2Login((saml2) -> saml2.loginProcessingUrl("/saml2/login/sso")) // ... return http.build(); }
- Kotlin
-
@Bean fun securityFilters(val http: HttpSecurity): SecurityFilterChain { http { // ... .saml2Login { loginProcessingUrl = "/saml2/login/sso" } // ... } return http.build() }
and:
- Java
-
relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml2/login/sso")
- Kotlin
-
relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml2/login/sso")
It’s not uncommon for the asserting and relying parties to have system clocks that aren’t perfectly synchronized.
For that reason, you can configure OpenSaml4AuthenticationProvider
's default assertion validator with some tolerance:
- Java
-
@EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setAssertionValidator(OpenSaml4AuthenticationProvider .createDefaultAssertionValidator(assertionToken -> { Map<String, Object> params = new HashMap<>(); params.put(CLOCK_SKEW, Duration.ofMinutes(10).toMillis()); // ... other validation parameters return new ValidationContext(params); }) ); http .authorizeHttpRequests(authz -> authz .anyRequest().authenticated() ) .saml2Login(saml2 -> saml2 .authenticationManager(new ProviderManager(authenticationProvider)) ); return http.build(); } }
- Kotlin
-
@EnableWebSecurity open class SecurityConfig { @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { val authenticationProvider = OpenSaml4AuthenticationProvider() authenticationProvider.setAssertionValidator( OpenSaml4AuthenticationProvider .createDefaultAssertionValidator(Converter<OpenSaml4AuthenticationProvider.AssertionToken, ValidationContext> { val params: MutableMap<String, Any> = HashMap() params[CLOCK_SKEW] = Duration.ofMinutes(10).toMillis() ValidationContext(params) }) ) http { authorizeRequests { authorize(anyRequest, authenticated) } saml2Login { authenticationManager = ProviderManager(authenticationProvider) } } return http.build() } }
Or, perhaps you would like to include user details from a legacy UserDetailsService
.
In that case, the response authentication converter can come in handy, as can be seen below:
- Java
-
@EnableWebSecurity public class SecurityConfig { @Autowired UserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter(responseToken -> { Saml2Authentication authentication = OpenSaml4AuthenticationProvider .createDefaultResponseAuthenticationConverter() (1) .convert(responseToken); Assertion assertion = responseToken.getResponse().getAssertions().get(0); String username = assertion.getSubject().getNameID().getValue(); UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); (2) return MySaml2Authentication(userDetails, authentication); (3) }); http .authorizeHttpRequests(authz -> authz .anyRequest().authenticated() ) .saml2Login(saml2 -> saml2 .authenticationManager(new ProviderManager(authenticationProvider)) ); return http.build(); } }
- Kotlin
-
@EnableWebSecurity open class SecurityConfig { @Autowired var userDetailsService: UserDetailsService? = null @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { val authenticationProvider = OpenSaml4AuthenticationProvider() authenticationProvider.setResponseAuthenticationConverter { responseToken: OpenSaml4AuthenticationProvider.ResponseToken -> val authentication = OpenSaml4AuthenticationProvider .createDefaultResponseAuthenticationConverter() (1) .convert(responseToken) val assertion: Assertion = responseToken.response.assertions[0] val username: String = assertion.subject.nameID.value val userDetails = userDetailsService!!.loadUserByUsername(username) (2) MySaml2Authentication(userDetails, authentication) (3) } http { authorizeRequests { authorize(anyRequest, authenticated) } saml2Login { authenticationManager = ProviderManager(authenticationProvider) } } return http.build() } }
-
First, call the default converter, which extracts attributes and authorities from the response
-
Second, call the
UserDetailsService
using the relevant information -
Third, return a custom authentication that includes the user details
Note
|
It’s not required to call OpenSaml4AuthenticationProvider 's default authentication converter.
It returns a Saml2AuthenticatedPrincipal containing the attributes it extracted from AttributeStatement s as well as the single ROLE_USER authority.
|
OpenSaml4AuthenticationProvider
validates the Issuer
and Destination
values right after decrypting the Response
.
You can customize the validation by extending the default validator concatenating with your own response validator, or you can replace it entirely with yours.
For example, you can throw a custom exception with any additional information available in the Response
object, like so:
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseValidator((responseToken) -> {
Saml2ResponseValidatorResult result = OpenSamlAuthenticationProvider
.createDefaultResponseValidator()
.convert(responseToken)
.concat(myCustomValidator.convert(responseToken));
if (!result.getErrors().isEmpty()) {
String inResponseTo = responseToken.getInResponseTo();
throw new CustomSaml2AuthenticationException(result, inResponseTo);
}
return result;
});
OpenSaml4AuthenticationProvider
performs minimal validation on SAML 2.0 Assertions.
After verifying the signature, it will:
-
Validate
<AudienceRestriction>
and<DelegationRestriction>
conditions -
Validate
<SubjectConfirmation>
s, expect for any IP address information
To perform additional validation, you can configure your own assertion validator that delegates to OpenSaml4AuthenticationProvider
's default and then performs its own.
For example, you can use OpenSAML’s OneTimeUseConditionValidator
to also validate a <OneTimeUse>
condition, like so:
- Java
-
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider(); OneTimeUseConditionValidator validator = ...; provider.setAssertionValidator(assertionToken -> { Saml2ResponseValidatorResult result = OpenSaml4AuthenticationProvider .createDefaultAssertionValidator() .convert(assertionToken); Assertion assertion = assertionToken.getAssertion(); OneTimeUse oneTimeUse = assertion.getConditions().getOneTimeUse(); ValidationContext context = new ValidationContext(); try { if (validator.validate(oneTimeUse, assertion, context) = ValidationResult.VALID) { return result; } } catch (Exception e) { return result.concat(new Saml2Error(INVALID_ASSERTION, e.getMessage())); } return result.concat(new Saml2Error(INVALID_ASSERTION, context.getValidationFailureMessage())); });
- Kotlin
-
var provider = OpenSaml4AuthenticationProvider() var validator: OneTimeUseConditionValidator = ... provider.setAssertionValidator { assertionToken -> val result = OpenSaml4AuthenticationProvider .createDefaultAssertionValidator() .convert(assertionToken) val assertion: Assertion = assertionToken.assertion val oneTimeUse: OneTimeUse = assertion.conditions.oneTimeUse val context = ValidationContext() try { if (validator.validate(oneTimeUse, assertion, context) = ValidationResult.VALID) { return@setAssertionValidator result } } catch (e: Exception) { return@setAssertionValidator result.concat(Saml2Error(INVALID_ASSERTION, e.message)) } result.concat(Saml2Error(INVALID_ASSERTION, context.validationFailureMessage)) }
Note
|
While recommended, it’s not necessary to call OpenSaml4AuthenticationProvider 's default assertion validator.
A circumstance where you would skip it would be if you don’t need it to check the <AudienceRestriction> or the <SubjectConfirmation> since you are doing those yourself.
|
Spring Security decrypts <saml2:EncryptedAssertion>
, <saml2:EncryptedAttribute>
, and <saml2:EncryptedID>
elements automatically by using the decryption Saml2X509Credential
instances registered in the RelyingPartyRegistration
.
OpenSaml4AuthenticationProvider
exposes two decryption strategies.
The response decrypter is for decrypting encrypted elements of the <saml2:Response>
, like <saml2:EncryptedAssertion>
.
The assertion decrypter is for decrypting encrypted elements of the <saml2:Assertion>
, like <saml2:EncryptedAttribute>
and <saml2:EncryptedID>
.
You can replace OpenSaml4AuthenticationProvider
's default decryption strategy with your own.
For example, if you have a separate service that decrypts the assertions in a <saml2:Response>
, you can use it instead like so:
- Java
-
MyDecryptionService decryptionService = ...; OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider(); provider.setResponseElementsDecrypter((responseToken) -> decryptionService.decrypt(responseToken.getResponse()));
- Kotlin
-
val decryptionService: MyDecryptionService = ... val provider = OpenSaml4AuthenticationProvider() provider.setResponseElementsDecrypter { responseToken -> decryptionService.decrypt(responseToken.response) }
If you are also decrypting individual elements in a <saml2:Assertion>
, you can customize the assertion decrypter, too:
- Java
-
provider.setAssertionElementsDecrypter((assertionToken) -> decryptionService.decrypt(assertionToken.getAssertion()));
- Kotlin
-
provider.setAssertionElementsDecrypter { assertionToken -> decryptionService.decrypt(assertionToken.assertion) }
Note
|
There are two separate decrypters since assertions can be signed separately from responses. Trying to decrypt a signed assertion’s elements before signature verification may invalidate the signature. If your asserting party signs the response only, then it’s safe to decrypt all elements using only the response decrypter. |
Of course, the authenticationManager
DSL method can be also used to perform a completely custom SAML 2.0 authentication.
This authentication manager should expect a Saml2AuthenticationToken
object containing the SAML 2.0 Response XML data.
- Java
-
@EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManager authenticationManager = new MySaml2AuthenticationManager(...); http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .saml2Login(saml2 -> saml2 .authenticationManager(authenticationManager) ) ; return http.build(); } }
- Kotlin
-
@EnableWebSecurity open class SecurityConfig { @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { val customAuthenticationManager: AuthenticationManager = MySaml2AuthenticationManager(...) http { authorizeRequests { authorize(anyRequest, authenticated) } saml2Login { authenticationManager = customAuthenticationManager } } return http.build() } }
With the relying party correctly configured for a given asserting party, it’s ready to accept assertions.
Once the relying party validates an assertion, the result is a Saml2Authentication
with a Saml2AuthenticatedPrincipal
.
This means that you can access the principal in your controller like so:
- Java
-
@Controller public class MainController { @GetMapping("/") public String index(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal, Model model) { String email = principal.getFirstAttribute("email"); model.setAttribute("email", email); return "index"; } }
- Kotlin
-
@Controller class MainController { @GetMapping("/") fun index(@AuthenticationPrincipal principal: Saml2AuthenticatedPrincipal, model: Model): String { val email = principal.getFirstAttribute<String>("email") model.setAttribute("email", email) return "index" } }
Tip
|
Because the SAML 2.0 specification allows for each attribute to have multiple values, you can either call getAttribute to get the list of attributes or getFirstAttribute to get the first in the list.
getFirstAttribute is quite handy when you know that there is only one value.
|