Integrating a Spring Boot App with SAML SSO (Hands-On Tutorial)

Integrating a Spring Boot App with SAML SSO (Hands-On Tutorial)
Integrating a Spring Boot App with SAML SSOIntegrating a Spring Boot App with SAML SSO

Introduction

A practical, end‑to‑end guide to enabling SAML‑based Single Sign‑On (SSO) in a Spring Boot application using Spring Security’s SAML2 Service Provider support (Spring Boot 3.x / Spring Security 6.x). Includes working code, config, metadata, and troubleshooting tips.


What you’ll build

  • A Spring Boot web app (Service Provider, SP) that authenticates users via an external Identity Provider (IdP) such as Okta, Azure AD, Keycloak, or OneLogin.
  • SAML 2.0 login, role (authority) mapping, and logout.
  • Local dev + production‑ready configurations.

Prerequisites

  • Java 17+
  • Maven or Gradle
  • Spring Boot 3.x
  • An IdP tenant (Okta Dev, Azure AD, Keycloak, OneLogin, or https://samltest.id for quick tests)
Note: Spring Security’s legacy spring-security-saml project is deprecated. Use the modern spring-security-saml2-service-provider module (built into Spring Security 5.2+).

Architecture quick view

  1. User requests a protected page in the SP.
  2. SP issues an AuthnRequest → IdP via browser redirect.
  3. IdP authenticates the user; posts a signed SAML Response with Assertion to the SP’s ACS endpoint.
  4. SP verifies signature, audience, issuer, and conditions; creates a Spring Security Authentication with user attributes & authorities.

Key endpoints (Spring defaults):

  • Login redirect: /saml2/authenticate/{registrationId}
  • ACS (Assertion Consumer Service): /login/saml2/sso/{registrationId}
  • SP metadata: /saml2/service-provider-metadata/{registrationId}

1) Start a Spring Boot project

Maven pom.xml:

<project>
  <properties>
    <java.version>17</java.version>
    <spring-boot.version>3.3.0</spring-boot.version>
  </properties>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-saml2-service-provider</artifactId>
    </dependency>
    <!-- Optional for key generation from PKCS12 keystores -->
    <dependency>
      <groupId>org.bouncycastle</groupId>
      <artifactId>bcpkix-jdk18on</artifactId>
      <version>1.78.1</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

Gradle (Kotlin DSL) build.gradle.kts:

plugins {
  id("org.springframework.boot") version "3.3.0"
  id("io.spring.dependency-management") version "1.1.5"
  kotlin("jvm") version "1.9.24" apply false // remove if not using Kotlin
}

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
  implementation("org.springframework.boot:spring-boot-starter-security")
  implementation("org.springframework.security:spring-security-saml2-service-provider")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
}

java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }

2) Generate SP signing keys

SAML usually requires the SP to sign AuthnRequests and publish its certificate in metadata.

Create a PKCS#12 keystore and certificate:

keytool -genkeypair \
  -alias sp-signing \
  -keyalg RSA -keysize 2048 -sigalg SHA256withRSA \
  -keystore sp-keystore.p12 -storetype PKCS12 \
  -storepass changeit -keypass changeit \
  -dname "CN=Demo SP, OU=Dev, O=Example, L=City, ST=State, C=US"

Place sp-keystore.p12 under src/main/resources/.

(If your IdP requires separate encryption keys, generate a second pair.)


3) Application properties (YAML)

Create src/main/resources/application.yml with one SAML registration (e.g., okta). Replace placeholders with your IdP values.

server:
  port: 8080

spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:  # ← registrationId
            signing:
              credentials:
                - private-key-location: classpath:sp-keystore.p12
                  certificate-location: classpath:sp-keystore.p12
                  private-key-password: changeit
                  # Spring can read PKCS12; for JKS use key-alias/key-store
            assertingparty:
              metadata-uri: https://dev-XXXXX.okta.com/app/XXXXXX/sso/saml/metadata
              # OR define manually instead of metadata-uri:
              # entity-id: "http://www.okta.com/XXXXXX"
              # singlesignon.url: "https://dev-XXXXX.okta.com/app/XXXXXX/sso/saml"
              # verification.credentials:
              #   - certificate-location: classpath:okta.cer

  thymeleaf:
    cache: false

# Optional: show logs during SAML debug
logging:
  level:
    org.springframework.security.saml2: DEBUG
    org.springframework.security.web: DEBUG
Tip: You can have multiple registrations (okta, azure, keycloak). Each appears under /saml2/service-provider-metadata/{registrationId} and /saml2/authenticate/{registrationId}.

4) Security configuration (Java)

SecurityConfig.java:

package com.example.saml;

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.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/", "/public", "/css/**", "/js/**").permitAll()
        .anyRequest().authenticated()
      )
      .saml2Login(Customizer.withDefaults())
      .logout(logout -> logout
        .logoutSuccessUrl("/")
      );

    return http.build();
  }
}

Attribute → authorities mapping (optional)
If your IdP sends a groups/roles attribute, map them to Spring authorities:

@Bean
AuthenticationConverter samlAuthoritiesConverter() {
  return (responseToken) -> {
    var auth = responseToken.getAuthentication();
    var attributes = responseToken.getToken().getSaml2Response(); // raw XML if needed
    // Prefer the higher-level API:
    // responseToken.getToken().getSignedAssertions(); // parse with OpenSAML if necessary
    // For simplicity, use default authorities and return
    return auth; // or enrich with mapped GrantedAuthorities
  };
}
In Spring Security 6, you typically customize authorities via Saml2AuthenticationTokenConverter and GrantedAuthoritiesMapper, or a custom AuthenticationSuccessHandler.

5) Minimal MVC to test

HomeController.java:

package com.example.saml;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

  @GetMapping("/")
  public String index() { return "index"; }

  @GetMapping("/profile")
  public String profile(Authentication authentication, Model model) {
    model.addAttribute("auth", authentication);
    return "profile";
  }
}

src/main/resources/templates/index.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Home</title></head>
<body>
  <h1>Welcome</h1>
  <p><a href="/saml2/authenticate/okta">Login with SAML (Okta)</a></p>
  <p><a href="/profile">Profile (protected)</a></p>
</body>
</html>

src/main/resources/templates/profile.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Profile</title></head>
<body>
  <h1>Profile</h1>
  <p th:text="${auth}"></p>
  <p><a href="/logout">Logout</a></p>
</body>
</html>

6) Obtain & publish metadata

6.1 Import IdP metadata into the SP

Set assertingparty.metadata-uri to your IdP’s metadata endpoint (Okta, Azure, Keycloak). Spring will pull signing certificates and endpoints automatically at startup.

6.2 Give SP metadata to the IdP

Start the app, then open:

http://localhost:8080/saml2/service-provider-metadata/okta

This XML contains the SP entityID, ACS URL(s), and certificate(s). Upload it to your IdP when configuring the app integration.

Some IdPs require a static entityID. You can set it via spring.security.saml2.relyingparty.registration.okta.entity-id.

7) IdP‑specific notes

Okta

  • Create a SAML 2.0 app integration.
  • ACS URL: http://localhost:8080/login/saml2/sso/okta
  • SP Entity ID: http://localhost:8080/saml2/service-provider-metadata/okta (or custom)
  • NameID format: emailAddress or unspecified.
  • Attribute statements: firstName, lastName, groups.

Azure AD (Enterprise App)

  • Set Identifier (Entity ID) and Reply URL (ACS) as above.
  • Download Federation Metadata XML and use its URL.
  • Azure often sends NameID = user.userprincipalname.

Keycloak

  • Create a client with Client Protocol = saml.
  • Set Valid Redirect URIs to /login/saml2/sso/*.
  • Export SAML 2.0 Identity Provider Metadata.

8) Custom attribute extraction & role mapping

Implement a GrantedAuthoritiesMapper to map SAML attributes to roles:

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;

@Bean
GrantedAuthoritiesMapper authoritiesMapper() {
  return (authorities) -> {
    var mapped = new java.util.HashSet<GrantedAuthority>(authorities);
    var samlPrincipal = authorities.stream()
      .filter(a -> a instanceof org.springframework.security.authentication.AbstractAuthenticationToken)
      .findFirst();
    // Alternatively access in a controller via Authentication.getPrincipal()
    return mapped;
  };
}

A simpler approach is to access attributes in controllers:

@GetMapping("/me")
@ResponseBody
public Map<String,Object> me(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal) {
  return Map.of(
    "name", principal.getName(),
    "attributes", principal.getAttributes()
  );
}

9) Logout & (optional) SLO

  • App logout: Spring clears session at /logout and can redirect to home.
  • IdP‑initiated or SP‑initiated Single Logout (SLO): Limited out‑of‑the‑box; many teams use a post‑logout URL at the IdP. Some IdPs support redirecting back to the app.

10) Running end‑to‑end

  1. Start the app: mvn spring-boot:run or ./mvnw spring-boot:run.
  2. Open http://localhost:8080/ and click Login with SAML (Okta).
  3. Authenticate at the IdP; you should land on /profile with an authenticated principal.
  4. Visit /me (if you added it) to inspect SAML attributes.

11) Common pitfalls & fixes

  • Clock skew: Assertions not yet valid → ensure servers are NTP‑synced; configure clockSkew at the IdP if available.
  • Audience/Entity mismatch: Make sure IdP’s Audience URI matches your SP entityId.
  • Signature failures: Rotate IdP signing certs—refresh metadata or update verification.credentials.
  • Multiple ACS endpoints: Use the exact /login/saml2/sso/{registrationId} in IdP settings.
  • HTTPS vs HTTP: Behind reverse proxies, set X-Forwarded-* headers or server.forward-headers-strategy=framework.
  • NameID format: If IdP sends a different format, adjust IdP config or map principal via a custom converter.

12) Production hardening checklist

  • Enforce HTTPS (HSTS) and secure cookies.
  • Rotate SP signing keys; store secrets in a vault.
  • Restrict allowed audiences/issuers; validate InResponseTo.
  • Limit assertion lifetimes; reduce clock skew.
  • Map & scope attributes minimally; avoid over‑privilege.
  • Configure session timeout & CSRF (enabled by default with Spring Security).

13) Bonus: Multiple IdPs (multi‑tenant)

Add additional registrations under spring.security.saml2.relyingparty.registration. Expose distinct login links:

<a href="/saml2/authenticate/okta">Login with Okta</a>
<a href="/saml2/authenticate/azure">Login with Azure AD</a>
<a href="/saml2/authenticate/keycloak">Login with Keycloak</a>

14) Directory structure (reference)

src/
  main/
    java/com/example/saml/
      SecurityConfig.java
      HomeController.java
    resources/
      application.yml
      sp-keystore.p12
      templates/
        index.html
        profile.html

15) Next steps

  • Add a custom AuthenticationSuccessHandler to redirect based on roles.
  • Persist sessions in Redis for horizontal scaling.
  • Implement attribute transformation (e.g., map groupsROLE_*).
  • Add tests with MockMvc and sample SAML responses.

Appendix A — Manual asserting party (no metadata URI)

spring:
  security:
    saml2:
      relyingparty:
        registration:
          keycloak:
            entity-id: "https://your-app.example.com/saml/sp"
            signing:
              credentials:
                - private-key-location: classpath:sp-keystore.p12
                  certificate-location: classpath:sp-keystore.p12
                  private-key-password: changeit
            assertingparty:
              entity-id: "http://keycloak/realms/demo"
              singlesignon:
                url: "https://keycloak.example.com/realms/demo/protocol/saml"
                binding: redirect
              verification:
                credentials:
                  - certificate-location: classpath:keycloak.cer

Appendix B — Using SAMLtest.id

  • Metadata URI: https://samltest.id/saml/sp
  • ACS: http://localhost:8080/login/saml2/sso/samltest
  • Create a samltest registration with the provided IdP metadata and test credentials.

You’re done! You now have a working Spring Boot SAML SSO integration you can adapt for your IdP. Copy‑paste the snippets above, swap the metadata and certificates, and ship it.