Test a Spring Boot SAML service provider without a real IdP
A Spring Boot application that logs its users in over SAML 2.0 needs an identity provider to test against: one that serves metadata, answers an AuthnRequest with a signed assertion, and sends the attributes your authorization rules read. A corporate IdP is rarely available to a developer machine or a CI job, and a hosted one means an account and a network dependency.
NanoIDP is that IdP on localhost: SAML SSO over the HTTP-POST and
HTTP-Redirect bindings, signed responses, and users with roles and groups
from a YAML file. This page connects a Spring Boot 4 service provider to
it, maps the roles to Spring authorities, and tests the login without a
browser.
Everything here is in
examples/spring-boot-saml/,
and the repository's CI builds the SP and runs the tests below against it.
1. Start NanoIDP with the preset
pip install nanoidp requests pytest # requests and pytest for the tests below
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/spring-boot-saml
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - Spring Boot SAML Integration
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/spring-boot-saml.html
server:
host: "127.0.0.1"
# host: "0.0.0.0" # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
port: 8000
oauth:
issuer: "http://localhost:8000"
audience: "spring-boot-app"
saml:
# entity_id / sso_url: absent = derived from the effective issuer as
# <issuer>/saml and <issuer>/saml/sso (follows issuer_from_request); set
# them only to pin a fixed value
# entity_id: "http://localhost:8000/saml"
# sso_url: "http://localhost:8000/saml/sso"
# Used only when a request names no ACS URL; Spring always names one
default_acs_url: "http://localhost:8080/login/saml2/sso/nanoidp"
# Export roles/groups as SAML attributes (off by default in NanoIDP)
export_roles: true
export_groups: true
roles_attr_name: "roles"
groups_attr_name: "groups"
# Authority prefix mapping for Spring Security GrantedAuthority
authority_prefixes:
roles: "ROLE_"
groups: "GROUP_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
# Allowed identity classes
allowed_identity_classes:
- "INTERNAL"
- "EXTERNAL"
- "CONTRACTOR"
NanoIDP does not send roles and groups over SAML unless export_roles and
export_groups are on. The users are admin / admin (roles ADMIN,
USER, group ADMINISTRATORS), user / user (role USER) and
readonly / readonly (role VIEWER).
2. Point Spring Boot at NanoIDP
Spring Boot 4 has a starter for SAML. Its OpenSAML dependency is not on
Maven Central, so the build needs the Shibboleth repository as well; without
it, the build fails with Could not find artifact org.opensaml:opensaml-saml-api:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-saml2</artifactId>
</dependency>
</dependencies>
<repositories>
<!-- OpenSAML, which Spring Security's SAML support needs, is not on Maven Central -->
<repository>
<id>shibboleth-releases</id>
<url>https://build.shibboleth.net/maven/releases/</url>
</repository>
</repositories>
The registration needs one URL, NanoIDP's metadata, which carries the SSO endpoint, both bindings and the signing certificate:
spring:
security:
saml2:
relyingparty:
registration:
nanoidp:
assertingparty:
# Everything about the IdP (SSO URL, bindings, signing
# certificate) comes from NanoIDP's metadata
metadata-uri: http://localhost:8000/saml/metadata
Spring's default entity ID, {baseUrl}/saml2/service-provider-metadata/nanoidp,
is what the SP sends as the AuthnRequest's Issuer, and NanoIDP puts that
value in the assertion's Audience (SAML Profiles §4.1.4.2), so the two
agree without configuration. Up to 3.3.0 every assertion carried
oauth.audience instead, and the SP's entity-id had to be set to it.
3. Turn SAML roles into Spring authorities
A SAML login in Spring Security carries no role by default: hasRole fails
for everyone, and /admin answers 403 even for admin. The response
converter below reads the roles attribute and grants ROLE_ADMIN,
ROLE_USER and so on:
package example;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.schema.XSAny;
import org.opensaml.core.xml.schema.XSString;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AttributeStatement;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// By default a SAML login carries no role; turn the assertion's
// "roles" attribute into ROLE_ authorities so hasRole() works
var converter = new OpenSaml5AuthenticationProvider.ResponseAuthenticationConverter();
converter.setGrantedAuthoritiesConverter(SecurityConfig::rolesFromAssertion);
var provider = new OpenSaml5AuthenticationProvider();
provider.setResponseAuthenticationConverter(converter);
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.saml2Login(saml -> saml.authenticationManager(provider::authenticate))
.saml2Metadata(Customizer.withDefaults());
return http.build();
}
static Collection<GrantedAuthority> rolesFromAssertion(Assertion assertion) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (AttributeStatement statement : assertion.getAttributeStatements()) {
for (Attribute attribute : statement.getAttributes()) {
if (!"roles".equals(attribute.getName())) {
continue;
}
for (XMLObject value : attribute.getAttributeValues()) {
// NanoIDP sends values without xsi:type: OpenSAML reads them as XSAny
String role = value instanceof XSString s ? s.getValue()
: value instanceof XSAny any ? any.getTextContent() : null;
if (role != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
}
}
return authorities;
}
}
NanoIDP sends attribute values without an xsi:type, which OpenSAML reads
as XSAny, not XSString; a converter that only accepts XSString finds
no role at all. The one above accepts both.
4. What arrives in Spring
For admin, the application sees:
| Where | Value |
|---|---|
principal name (the NameID) | admin@example.org: NanoIDP uses the user's email |
roles attribute | ADMIN, USER |
groups attribute | ADMINISTRATORS |
| other attributes | email, identity_class, entitlements |
| authorities, with the converter | ROLE_ADMIN, ROLE_USER, and FACTOR_SAML_RESPONSE, which Spring Security 7 adds to every SAML login |
tenant and source_acl are not in the login assertion; the SAML
reference lists what each SAML surface sends in
The two SAML surfaces.
5. Test the login without a browser
The SAML login is four HTTP exchanges, and they can be scripted: the SP's
auto-submitting form with the AuthnRequest, NanoIDP's login form, NanoIDP's
auto-submitting form with the SAMLResponse, and the SP's answer. The tests
below do what a browser does, against the SP on :8080 and NanoIDP on
:8000:
"""SAML login to the Spring Boot SP through NanoIDP, the way a browser does it.
Needs NanoIDP on :8000 with this preset and the SP (sp/) on :8080.
"""
import html
import re
import requests
SP = "http://localhost:8080"
TIMEOUT = 10 # seconds: a stuck server fails the test instead of hanging it
def form(page, url=None):
"""The action and the fields of the first form on a page."""
action = re.search(r'<form[^>]*action="([^"]+)"', page)
fields = {html.unescape(name): html.unescape(value) for name, value in
re.findall(r'<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"', page)}
return (html.unescape(action[1]) if action else url), fields
def saml_login(path, username, password):
"""Open `path` on the SP, log in at NanoIDP, return (browser, final page)."""
browser = requests.Session()
# 1. The SP answers with a form that posts an AuthnRequest to NanoIDP
action, fields = form(browser.get(SP + path, timeout=TIMEOUT).text)
# 2. NanoIDP shows its login form (it posts back to the same URL)
login_page = browser.post(action, data=fields, timeout=TIMEOUT)
action, fields = form(login_page.text, login_page.url)
fields.update(username=username, password=password)
# 3. NanoIDP answers with a form that posts the SAMLResponse to the SP's ACS
answer = browser.post(action, data=fields, timeout=TIMEOUT)
if "SAMLResponse" not in answer.text:
return browser, answer
action, fields = form(answer.text)
# 4. The SP validates the response and sends the browser where it started
return browser, browser.post(action, data=fields, timeout=TIMEOUT)
def test_admin_reaches_the_admin_area():
browser, page = saml_login("/admin", "admin", "admin")
assert page.status_code == 200, page.text
assert page.text == "admin area for admin@example.org\n"
def test_roles_become_authorities_and_attributes_arrive():
browser, _ = saml_login("/", "admin", "admin")
home = browser.get(SP + "/", timeout=TIMEOUT).text
assert "user=admin@example.org" in home # NameID: the email
assert "ROLE_ADMIN" in home and "ROLE_USER" in home # from the roles attribute
assert "roles=[ADMIN, USER]" in home
assert "groups=[ADMINISTRATORS]" in home
def test_a_user_without_the_admin_role_is_refused():
_, page = saml_login("/admin", "user", "user")
assert page.status_code == 403
def test_a_wrong_password_gets_no_saml_response():
_, page = saml_login("/", "admin", "wrong")
assert "SAMLResponse" not in page.text
assert page.url.startswith("http://localhost:8000/saml/sso")
The login form fields (username, password) are NanoIDP's own; the rest
is the standard SAML Web Browser SSO profile over the HTTP-POST binding.
What must fail
| What happens | Result with this example |
|---|---|
user opens /admin | 403: the login works, the role is missing |
| a wrong password | NanoIDP shows its form again; no SAMLResponse reaches the SP |
Limits to know
- No single logout. NanoIDP's metadata has no
SingleLogoutService, sosaml2Logout()has nothing to talk to. Log out locally with Spring's ordinarylogout().
Takeaways
- Spring Boot 4 needs the
spring-boot-starter-security-saml2starter and the Shibboleth Maven repository; the IdP side is one metadata URL. - Roles are not authorities until you map them. Map the attribute your IdP
sends, and accept values without
xsi:type. - An SP that rejects every response with "validation errors" is most often
an audience mismatch. Compare the assertion's
Audiencewith the SP's entity ID: NanoIDP takes it from the AuthnRequest'sIssuer, so the two differ only when the SP sends one identifier and expects another. - The login is scriptable end to end, so SAML can be tested in CI like anything else.