Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

WhereValue
principal name (the NameID)admin@example.org: NanoIDP uses the user's email
roles attributeADMIN, USER
groups attributeADMINISTRATORS
other attributesemail, identity_class, entitlements
authorities, with the converterROLE_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 happensResult with this example
user opens /admin403: the login works, the role is missing
a wrong passwordNanoIDP shows its form again; no SAMLResponse reaches the SP

Limits to know

  • No single logout. NanoIDP's metadata has no SingleLogoutService, so saml2Logout() has nothing to talk to. Log out locally with Spring's ordinary logout().

Takeaways

  • Spring Boot 4 needs the spring-boot-starter-security-saml2 starter 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 Audience with the SP's entity ID: NanoIDP takes it from the AuthnRequest's Issuer, 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.