Spring Boot with SAML2 and Keycloak

Spring Boot with SAML2 and Keycloak

This article will teach you how to use SAML2 authentication with Spring Boot and Keycloak. Security Assertion Markup Language (SAML) is a standard for exchanging authentication and authorization identities between an Identity Provider (IdP) and a Service Provider. It is an XML-based protocol that uses security tokens with information about a principal. Currently, it is less popular than OICD (OpenID Connect) but is not outdated yet. In fact, many organizations still use SAML for SSO.

In our example, Keycloak will act as an Identity Provider. Keycloak supports SAML 2.0. We can also use Spring Security mechanisms supporting SAML authentication on the service provider side (our sample Spring Boot application). There are several articles about Spring Boot and SAML, but relatively few of them are up to date and use Keycloak as the IdP.

If you are interested in Keycloak and Spring Security you can read my article about microservices with Spring Cloud Gateway, OAuth2, and Keycloak. You can also take a look at the another post about best practices for securing Spring Boot microservices available here.

Source Code

If you would like to try this exercise by yourself, you may always take a look at my source code. First, you need to clone the following GitHub repository. It contains several sample Java applications for a Spring Boot security showcase. You must go to the saml directory, to proceed with exercise. The sample Spring Boot aplication is available in the callme-saml directory. Then you should follow my further instructions.

Prerequisites

Before we start the development, we must install some tools on our laptops. Of course, we should have Maven and at least Java 17 installed (Java 21 preferred). We must also have access to the container engine like Docker or Podman to run the Keycloak instance. 

Run Keycloak

We will run Keycloak as the Docker container. The repository contains the docker-compose.yml file in the saml directory and the realm manifest in the saml/config directory. Docker Compose run Keycloak in the development mode and imports the realm file on startup. Thanks to that you won’t have to create many resources in Keycloak by yourself. However, I’m describing step by step what should be done. The docker-compose.yml manifest also set the default administrator password to admin, enables HTTPS, and uses the Red Hat build of Keycloak instead of the community edition.

services:
  keycloak:
#    image: quay.io/keycloak/keycloak:24.0
    image: registry.redhat.io/rhbk/keycloak-rhel9:24-17
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin
      - KC_HTTP_ENABLED=true
      - KC_HTTPS_CERTIFICATE_KEY_FILE=/opt/keycloak/conf/localhost.key.pem
      - KC_HTTPS_CERTIFICATE_FILE=/opt/keycloak/conf/localhost.crt.pem
      - KC_HTTPS_TRUST_STORE_FILE=/opt/keycloak/conf/truststore.jks
      - KC_HTTPS_TRUST_STORE_PASSWORD=123456
    ports:
      - 8080:8080
      - 8443:8443
    volumes:
      - /Users/pminkows/IdeaProjects/sample-spring-security-microservices/oauth/localhost-key.pem:/opt/keycloak/conf/localhost.key.pem
      - /Users/pminkows/IdeaProjects/sample-spring-security-microservices/oauth/localhost-crt.pem:/opt/keycloak/conf/localhost.crt.pem
      - /Users/pminkows/IdeaProjects/sample-spring-security-microservices/oauth/truststore.jks:/opt/keycloak/conf/truststore.jks
      - ./config/:/opt/keycloak/data/import:ro
    command: start-dev --import-realm
YAML

Finally, we must run the following command to start the instance of Keycloak.

docker compose up
ShellSession

Then, we should have the running instance of Keycloak exposes on the localhost over the HTTPS 8443 port.

spring-boot-saml2-keycloak-startup

After logging to the Keycloak UI with the admin / admin crendentials you should see the spring-boot-keycloak realm.

In the spring-boot-keycloak realm details there is a link to the SAML2 provider metadata file. We can download the file and pass directly to the Spring Boot application or save the link address for the future use. Keycloak published the IdP metadata for the spring-boot-keycloak realm under the https://localhost:8443/realms/spring-boot-keycloak/protocol/saml/descriptor link. Let’s copy that address to clipboard and proceed to the Spring Boot app implementation.

spring-boot-saml2-idp

Create Spring Boot App with SAML2 Support

Let’s begin with the dependencies list. We must include the Spring Web and Spring Security modules. For the SAML2 support we must add the spring-security-saml2-service-provider dependency. That dependency uses the OpenSAML library, which is published in the dedicated Shibboleth Maven repository. Our application also requires Thymeleaf to provide a single web page with an authenticated principal details.

<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</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-thymeleaf</artifactId>
  </dependency>
  <dependency>
     <groupId>org.thymeleaf.extras</groupId>
     <artifactId>thymeleaf-extras-springsecurity6</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.security</groupId>
     <artifactId>spring-security-saml2-service-provider</artifactId>
  </dependency>
</dependencies>

<repositories>
  <repository>
    <id>shibboleth-build-releases</id>
    <name>Shibboleth Build Releases Repository</name>
    <url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>
  </repository>
</repositories>
XML

Our goal is to start with something basic. The application will publish a metadata endpoint using the saml2Metadata DSL method. We also enable authorization at the method level with the @EnableMethodSecurity annotation. We can access the resources the after authentication in Keycloak.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize -> authorize.anyRequest()
                        .authenticated())
                .saml2Login(withDefaults())
                .saml2Metadata(withDefaults());
        return http.build();
    }

}
Java

Here’s the Spring Boot application.yml file. It changes the default app HTTP port to the 8081. The SAML2 configuration must be provided within the spring.security.saml2.relyingparty.registration.{registrationId}.* properties. We must set the address of the IdP metadata file in the assertingparty.metadata-uri property. We also need to set the entity-id and the SSO service address. Both those settings are exposed in the metadata IdP file. We should also provided certificates for signing requests and verifying SAML responses. The key and certificate all already present in the repository.

server.port: 8081

spring:
  security:
    saml2:
      relyingparty:
        registration:
          keycloak:
            identityprovider:
              entity-id: https://localhost:8443/realms/spring-boot-keycloak
              verification.credentials:
                - certificate-location: classpath:rp-certificate.crt
              singlesignon.url: https://localhost:8443/realms/spring-boot-keycloak/protocol/saml
              singlesignon.sign-request: false
            signing:
              credentials:
                - private-key-location: classpath:rp-key.key
                  certificate-location: classpath:rp-certificate.crt
            assertingparty:
              metadata-uri: https://localhost:8443/realms/spring-boot-keycloak/protocol/saml/descriptor
YAML

Lets’ run our application with the following command:

mvn spring-boot:run
ShellSession

After startup, we can display a metadata endpoint exposed with the saml2Metadata DSL method. The most important element in the file is the entityID. Let’s save it for the future usage.

<md:EntityDescriptor entityID="http://localhost:8081/saml2/service-provider-metadata/keycloak">
  <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo>
        <ds:X509Data>
          <ds:X509Certificate>...</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/login/saml2/sso/keycloak" index="1"/>
  </md:SPSSODescriptor>
</md:EntityDescriptor>
XML

Configure Keycloak SAML2 Client

Let’s switch to the Keycloak UI. We must create a SAML client. The client ID must be the same as the entityID retrieved in the previous section. We should also set the application root URL and valid redirects URIs. Notice that you don’t have do anything – Keycloak imports all requeired configuration at startup from the exported realm manifest.

spring-boot-saml2-client

Don’t forget to create a test user with password. We will use to autenticate against Keycloak in the step.

This is the optional step. We can add the client scope with some mappers. This force Keycloak to pass information about user group, email, and surname in the authentication response.

Let’s include the newly created scope to the client scopes.

We can also create the admins group and assign our test user to that group.

After providing the whole configuration we can make a first test. Our application is already running. It will print the authenticated user details on the main site after signing in. Here’s the MainController class.

@Controller
public class MainController {

    @GetMapping("/")
    public String getPrincipal(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal, Model model) {
        String emailAddress = principal.getFirstAttribute("email");
        model.addAttribute("emailAddress", emailAddress);
        model.addAttribute("userAttributes", principal.getAttributes());
        return "index";
    }

}
Java

Here’s the main application site. It uses the Thymeleaf extension for Spring Security.

<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">

<head>
    <title>SAML 2.0 Login</title>
</head>

<body>
    <main role="main" class="container">
        <h1 class="mt-5">SAML 2.0 Login with Spring Security</h1>
        <p class="lead">You are successfully logged in as <span sec:authentication="name"></span></p>
        <h2 class="mt-2">User Identity Attributes</h2>
        <table class='table table-striped'>
            <thead>
            <tr>
                <th>Attribute</th>
                <th>Value</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="userAttribute : ${userAttributes}">
                <th th:text="${userAttribute.key}"></th>
                <td th:text="${userAttribute.value}"></td>
            </tr>
            </tbody>
        </table>
    </main>
</body>

</html>
HTML

Once we access the application main site over the http://localhost:8081 URL, Spring should redirect us to the Keycloak login site. Let’s provide our test user credentials.

Success! We are logged in to the application. Our Spring Boot app prints the details taken from SAML2 authentication token.

spring-boot-saml2-auth

REST Methods Authorization

Let’s consider the following @RestController in our application. We have already enabled authorization at the method level with the @EnableMethodSecurity annotation. Our controller creates two REST endpoints. The GET /greetings/user endpoint requires the ROLE_USER authority granted, while the GET /greetings/admin requires the ROLE_ADMIN authority granted.

@RestController
@RequestMapping("/greetings")
public class GreetingController {

    @GetMapping("/user")
    @PreAuthorize("hasAuthority('ROLE_USER')")
    public String greeting() {
        return "I'm SAML user!";
    }

    @GetMapping("/admin")
    @PreAuthorize("hasAuthority('ROLE_ADMINS')")
    public String admin() {
        return "I'm SAML admin!";
    }

}
Java

Let’s call the GET /greetings/user endpoint. It returns the expected response.

Then, let’s call the GET /greetings/admin endpoint. Unfortunately, we don’t have the access to that endpoint. That’s because it requires the user to have the ROLE_ADMINS authority.

Previosuly, we used a default implementation the SAML2 authentication provider in Spring Boot. It sets only the ROLE_USER as the granted authorities. We can override that behaviour with the setResponseAuthenticationConverter method SAML2 AuthenticationProvider implementation. Here’s the current implementation. Our method tries to find the member attrobute in the SAML2 response token. Then, it maps the group name taken from that attribute to the Spring Security GrantedAuthority name. Finally, it returns a new Saml2Authentication object with a new list of GrantedAuthority objects.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private static final Logger LOG = LoggerFactory
        .getLogger(SecurityConfig.class);

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        OpenSaml4AuthenticationProvider provider = 
            new OpenSaml4AuthenticationProvider();

        provider.setResponseAuthenticationConverter(token -> {
            var auth = OpenSaml4AuthenticationProvider
                .createDefaultResponseAuthenticationConverter()
                .convert(token);
            LOG.info("AUTHORITIES: {}", auth.getAuthorities());

            var attrValues = token.getResponse().getAssertions().stream()
                    .flatMap(as -> as.getAttributeStatements().stream())
                    .flatMap(attrs -> attrs.getAttributes().stream())
                        .filter(attrs -> attrs.getName().equals("member"))
                        .findFirst().orElseThrow().getAttributeValues();

            if (!attrValues.isEmpty()) {
                var member = ((XSStringImpl) attrValues.getFirst()).getValue();
                LOG.info("MEMBER: {}", member);
                List<GrantedAuthority> authoritiesList = List.of(
                        new SimpleGrantedAuthority("ROLE_USER"),
                        new SimpleGrantedAuthority("ROLE_" + 
                            member.toUpperCase().replaceFirst("/", ""))
                );

                LOG.info("NEW AUTHORITIES: {}", authoritiesList);
                return new Saml2Authentication(
                    (AuthenticatedPrincipal) auth.getPrincipal(), 
                    auth.getSaml2Response(), 
                    authoritiesList);
            } else return auth;
        });

        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize -> authorize.anyRequest()
                        .authenticated())
                .saml2Login(saml2 -> saml2
                        .authenticationManager(new ProviderManager(provider))
                )
                .saml2Metadata(withDefaults());
        return http.build();
    }

}
Java

Let’s run a new version of our application.

mvn spring-boot:run
ShellSession

Once we open the http://localhost:8081 site once again we can take a look at the logs. I highlighted the part of the SAML response message that contains the member attribute. As you on the bottom, we created a new granted authorities list containing ROLE_USER and ROLE_ADMINS.

spring-boot-saml2-logs

Fibnally, we can call the GET /greetings/admin endpoint once again. It works!

spring-boot-saml2-admin

Final Thoughts

This article shows how to simply start with Spring Boot, SAML2 and Keycloak. It also provides more advanced solution with the custom authentication provider implementation that maps a user group name to the granted authority name. Although SAML2 is rather a mature technology, there are no many examples with Spring Boot, SAML2 and Keycloak. I hope that my article will fill this gap 🙂

Leave a Reply