August 30, 2023

Spring Boot 3 with X509 Authentication and JDBC Storage

Reference

Maven

Using same pom, but different artifactId as previous blog: Basic Auth and JDBC Password Storage

MySQL

Using same docker mysql container wiht same data as in previous blog: Basic Auth and JDBC Password Storage

Server and User Certificate and Truststore

Using same root ca, certs and truststore as in previous blog: Using Java 17 keytool as Root CA to create Server and User Certificate

Applicaiton

src/main/resources/application.properties

server.port = 8443

# https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-logging
logging.level.org.springframework.security = TRACE

# https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
#server.ssl.bundle
#server.ssl.certificate
#server.ssl.certificate-private-key
#server.ssl.ciphers
server.ssl.client-auth = want
server.ssl.enabled = true
#server.ssl.enabled-protocols
server.ssl.key-alias = localhost
server.ssl.key-password = changeit
server.ssl.key-store = localhost.p12
server.ssl.key-store-password = changeit
#server.ssl.key-store-provider
server.ssl.key-store-type = PKCS12
#server.ssl.protocol
#server.ssl.trust-certificate
#server.ssl.trust-certificate-private-key =
server.ssl.trust-store = truststore.jks
server.ssl.trust-store-password = changeit
#server.ssl.trust-store-provider 
server.ssl.trust-store-type = JKS

#server.servlet.session.cookie.domain
server.servlet.session.cookie.http-only = true
server.servlet.session.cookie.max-age = 3m
#server.servlet.session.cookie.name
#server.servlet.session.cookie.path
server.servlet.session.cookie.same-site = strict
server.servlet.session.cookie.secure = true
server.servlet.session.persistent = false
#server.servlet.session.store-dir
server.servlet.session.timeout = 3m
server.servlet.session.tracking-modes = cookie

spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=user
spring.datasource.password=changeit

Spring Java configuration

package se.mkk.springboot3x509jdbc;

import javax.sql.DataSource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class X509JdbcSecurityConfig {
    private final Logger log = LoggerFactory.getLogger(this.getClass());

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .sessionManagement(
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
                        // https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
                        Customizer.withDefaults())
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
                        .requestMatchers("/login", "/logout").permitAll() //
                        .anyRequest().authenticated()) //
                .x509(x509 -> x509 //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html
                        .subjectPrincipalRegex("CN=(.*?),") //
                        .userDetailsService(this.userDetailsService())) //
                .csrf(csrf -> csrf //
                        // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-token-repository-cookie
                        .ignoringRequestMatchers("/logout", "/api"))
                .logout(logout -> logout //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#clear-all-site-data
                        .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))));
        return http.build();
    }

//    private UserDetailsService userDetailsService() {
//        return username -> {
//            log.info("X509 {}", username);
//            return User.withUsername(username).password("DUMMY").roles("USER", "ADMIN").build();
//        };
//    }

    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html
    @Autowired
    private DataSource dataSource;

    private UserDetailsService userDetailsService() {
        JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
        return users;
    }
}

Test REST endpoint

package se.mkk.springboot3x509jdbc;

import java.security.Principal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:4200")
public class UserController {

    @GetMapping
    public Map<String, String> getUser(HttpServletRequest request, HttpSession session, Principal principal) {
        Map<String, String> rtn = new LinkedHashMap<>();
        rtn.put("session_getMaxInactiveInterval_sec", session.getMaxInactiveInterval() + "s");
        rtn.put("session_getLastAccessedTime",
                new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(new Date(session.getLastAccessedTime())));
        rtn.put("request_getRemoteUser", request.getRemoteUser());
        rtn.put("request_isUserInRole_USER", Boolean.toString(request.isUserInRole("USER")));
        rtn.put("request_getUserPrincipal_getClass", request.getUserPrincipal().getClass().getName());
        rtn.put("principal_getClass_getName", principal.getClass().getName());
        rtn.put("principal_getName", principal.getName());
        if (principal instanceof Authentication authentication) {
            List<String> authorities = authentication.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority()).toList();
            rtn.put("JwtAuthenticationToken.getAuthorities()", authorities.toString());
        }
        return rtn;
    }
}

Test

We need to convert p12 to pem files for curl.

$ openssl pkcs12 -in john.p12 -out john.pem -nodes
$ curl -v -X GET   --cacert RootCA.cert.pem   --cert john.cert.pem   --key john.key.pem   https://localhost:8443/api/users
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1:8443...
* Connected to localhost (127.0.0.1) port 8443 (#0)
* ALPN: offers h2
* ALPN: offers http/1.1
*  CAfile: RootCA.cert.pem
*  CApath: none
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS header, Finished (20):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.2 (OUT), TLS header, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: C=SE; O=Server; OU=Localhost_Server; CN=localhost
*  start date: Aug 30 09:35:00 2023 GMT
*  expire date: Nov 28 09:35:00 2023 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: C=SE; O=CertificateAuthority; OU=Root_CertificateAuthority; CN=RootCA
*  SSL certificate verify ok.
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET /api/users HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.85.0
> Accept: */*
> 
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Set-Cookie: JSESSIONID=5358CF4B62B84A8E99C4DB183CB2BDD2; Max-Age=180; Expires=Wed, 30 Aug 2023 10:07:14 GMT; Path=/; Secure; HttpOnly; SameSite=Strict
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< Strict-Transport-Security: max-age=31536000 ; includeSubDomains
< X-Frame-Options: DENY
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Wed, 30 Aug 2023 10:04:14 GMT
< 
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* Connection #0 to host localhost left intact
{"session_getMaxInactiveInterval_sec":"180s","session_getLastAccessedTime":"2023-08-30 12:04:14 +0200","request_getRemoteUser":"john","request_isUserInRole_USER":"true","request_getUserPrincipal_getClass":"org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken","principal_getClass_getName":"org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken","principal_getName":"john","JwtAuthenticationToken.getAuthorities()":"[ROLE_ADMIN, ROLE_USER]"}
$ curl -s -X GET   --cacert RootCA.cert.pem   --cert john.cert.pem   --key john.key.pem   https://localhost:8443/api/users | jq -r
{
  "session_getMaxInactiveInterval_sec": "180s",
  "session_getLastAccessedTime": "2023-08-30 12:04:44 +0200",
  "request_getRemoteUser": "john",
  "request_isUserInRole_USER": "true",
  "request_getUserPrincipal_getClass": "org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken",
  "principal_getClass_getName": "org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken",
  "principal_getName": "john",
  "JwtAuthenticationToken.getAuthorities()": "[ROLE_ADMIN, ROLE_USER]"
}

Using Java 17 keytool as Root CA to create Server and User Certificate

Reference

Root CA

Generate Self Signed Root CA

$ keytool -genkeypair -alias RootCA -dname "cn=RootCA, ou=Root_CertificateAuthority, o=CertificateAuthority, c=SE" -validity 3650 -keyalg RSA -keysize 4096 -ext bc:c -keystore RootCA.p12 -storetype PKCS12 -storepass changeit -keypass changeit

Export Root CA certificate and truststore with Root CA

$ keytool -exportcert -alias RootCA -keystore RootCA.p12 -storetype PKCS12 -storepass changeit -rfc -file RootCA.cert.pem

$ keytool -importcert -alias RootCA -trustcacerts -noprompt -keystore truststore.jks -storetype JKS -storepass changeit -keypass changeit -file RootCA.cert.pem 

Server certificate

Generate Server certificate keypair.

$ keytool -genkeypair -alias localhost -dname "cn=localhost, ou=Localhost_Server, o=Server, c=SE" -validity 730 -keyalg RSA -keysize 2048 -keystore localhost.p12 -storetype PKCS12 -storepass changeit -keypass changeit

Generate CSR and sign with Root CA

$ keytool -certreq -alias localhost -keystore localhost.p12 -storetype PKCS12 -storepass changeit | \
    keytool -gencert -alias RootCA -keystore RootCA.p12 -storetype PKCS12 -storepass changeit -ext ku:c=dig,keyEnc -ext "san=dns:localhost,ip:127.0.0.1" -ext eku=serverAuth -rfc > localhost.cert.pem

Create certificate chain

$ cat RootCA.cert.pem >> localhost.cert.pem

Import/replace self signed certificate with Root signed

$ keytool -importcert -alias localhost -trustcacerts -noprompt -keystore localhost.p12 -storetype PKCS12 -storepass changeit -file localhost.cert.pem

Print and verify

$ keytool -list -keystore localhost.p12 -storepass changeit -v

User Certificate

Generate User certificate keypair

$ keytool -genkeypair -alias john -dname "cn=john, ou=John_User, o=User, c=SE" -validity 730 -keyalg RSA -keysize 2048 -keystore john.p12 -storetype PKCS12 -storepass changeit -keypass changeit

Generate CSR and sign with Root CA

$ keytool -certreq -alias john -keystore john.p12 -storetype PKCS12 -storepass changeit | \
    keytool -gencert -alias RootCA -keystore RootCA.p12 -storetype PKCS12 -storepass changeit -ext ku:c=digitalSignature,nonRepudiation,keyEncipherment -ext eku=clientAuth,emailProtection -rfc > john.cert.pem

Create certificate chain

$ cat RootCA.cert.pem >> john.cert.pem

Import/replace self signed certificate with Root signed

$ keytool -importcert -alias john -trustcacerts -noprompt -keystore john.p12 -storetype PKCS12 -storepass changeit -file john.cert.pem

Print and verify

$ keytool -list -keystore john.p12 -storepass changeit -v

August 29, 2023

Spring Boot 3 with Basic Authentication and JDBC Password Storage

Create new Maven Project

Create new project with Spring Initializr (https://start.spring.io/) and add dependency: Spring Data JDBC and MySQL

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

Add Spring Data JDBC properties src/main/resources/application.properties

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.data

    spring.datasource.url=jdbc:mysql://localhost:3306/mydb
    spring.datasource.username=user
    spring.datasource.password=changeit

Start a local mysql server with a container image.

https://catalog.redhat.com/software/containers/rhel9/mysql-80/61a60915c17162a20c1c6a34

https://hub.docker.com/_/mysql

$ podman run -d --name mysqld \
    -e MYSQL_USER=user \
    -e MYSQL_PASSWORD=changeit \
    -e MYSQL_DATABASE=mydb \
    -e MYSQL_ROOT_PASSWORD=changeit \
    -p 3306:3306 \
    docker.io/library/mysql:8.0

$ podman logs --follow mysqld

Create and populate DB. Use below test method to generated hashed password.

        @Test
        public void test() throws Exception {
            PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
            System.out.println(passwordEncoder.encode("changeit"));
        }

Now connect to mysql container interactive and execute sql commands to creae tables and populate them with data.

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html

$ podman exec -it mysqld /bin/bash

bash-4.4# mysql -u root -p mydb

create table users(username varchar(50) not null primary key, password varchar(500) not null, enabled boolean not null);
create table authorities (username varchar(50) not null, authority varchar(50) not null, constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username, authority);

INSERT INTO users (username, password, enabled) VALUES ('john', '{bcrypt}$2a$10$aohc8ylx1YcZx6p/L2BRv.I4oQfDin9Ed2CNTy0ZXQ3ZpdiMalLp6', true);

INSERT INTO authorities (username, authority) VALUES ('john', 'ROLE_USER');
INSERT INTO authorities (username, authority) VALUES ('john', 'ROLE_ADMIN');

Application

package se.mkk.springboot3basicjdbc;

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class BasicJdbcSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .sessionManagement(
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
                        // https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
                        Customizer.withDefaults())
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
                        .requestMatchers("/login", "/logout").permitAll() //
                        .anyRequest().authenticated()) //
                .httpBasic( //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html
                        Customizer.withDefaults()) //
                .csrf(csrf -> csrf //
                        // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-token-repository-cookie
                        .ignoringRequestMatchers("/logout", "/api"))
                .logout(logout -> logout //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#clear-all-site-data
                        .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))));
        return http.build();
    }

    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/jdbc.html
    @Bean
    public UserDetailsManager jdbcUserDetailsManager(DataSource dataSource) {
        JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
        return users;
    }
}
package se.mkk.springboot3basicjdbc;

import java.security.Principal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:4200")
public class UserController {

    @GetMapping
    public Map<String, String> getUser(HttpServletRequest request, HttpSession session, Principal principal) {
        Map<String, String> rtn = new LinkedHashMap<>();
        rtn.put("session_getMaxInactiveInterval_sec", session.getMaxInactiveInterval() + "s");
        rtn.put("session_getLastAccessedTime",
                new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(new Date(session.getLastAccessedTime())));
        rtn.put("request_getRemoteUser", request.getRemoteUser());
        rtn.put("request_isUserInRole_USER", Boolean.toString(request.isUserInRole("USER")));
        rtn.put("request_getUserPrincipal_getClass", request.getUserPrincipal().getClass().getName());
        rtn.put("principal_getClass_getName", principal.getClass().getName());
        rtn.put("principal_getName", principal.getName());
        if (principal instanceof Authentication authentication) {
            List<String> authorities = authentication.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority()).toList();
            rtn.put("JwtAuthenticationToken.getAuthorities()", authorities.toString());
        }
        return rtn;
    }
}

Test

$ curl -v -X GET -u "john:changeit" http://localhost:8080/api/users 
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
* Server auth using Basic with user 'john'
> GET /api/users HTTP/1.1
> Host: localhost:8080
> Authorization: Basic am9objpjaGFuZ2VpdA==
> User-Agent: curl/7.85.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Set-Cookie: JSESSIONID=56FAD7EB738EB12B558F02EBC04122BE; Max-Age=180; Expires=Tue, 29 Aug 2023 21:02:41 GMT; Path=/; Secure; HttpOnly; SameSite=Strict
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 29 Aug 2023 20:59:41 GMT
< 
* Connection #0 to host localhost left intact
{"session_getMaxInactiveInterval_sec":"180s","session_getLastAccessedTime":"2023-08-29 22:59:41 +0200","request_getRemoteUser":"john","request_isUserInRole_USER":"true","request_getUserPrincipal_getClass":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principal_getClass_getName":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principal_getName":"john","JwtAuthenticationToken.getAuthorities()":"[ROLE_ADMIN, ROLE_USER]"}

$ curl -s -X GET -u "john:changeit" http://localhost:8080/api/users | jq -r
{
  "session_getMaxInactiveInterval_sec": "180s",
  "session_getLastAccessedTime": "2023-08-29 22:58:32 +0200",
  "request_getRemoteUser": "john",
  "request_isUserInRole_USER": "true",
  "request_getUserPrincipal_getClass": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
  "principal_getClass_getName": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
  "principal_getName": "john",
  "JwtAuthenticationToken.getAuthorities()": "[ROLE_ADMIN, ROLE_USER]"
}

August 23, 2023

Oauth 2.0 Client Credentials Grant

Used for machine-to-machine communication

Client needs to hold secrets, since Access Token is directly exposed

https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2

     POST /token HTTP/1.1
     Host: server.example.com
     Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
     Content-Type: application/x-www-form-urlencoded

     grant_type=client_credentials

   grant_type
         REQUIRED.  Value MUST be set to "client_credentials".

   scope
         OPTIONAL.  The scope of the access request as described by
         Section 3.3.
$ curl -s -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -u 'spring-boot3-oauth2-login:jO09Uwhi8oxTL3QnTKtYZ20ByQvB2qA0' \
  http://localhost:8180/auth/realms/demo/protocol/openid-connect/token \
  -d "grant_type=client_credentials&scope=openid&client_id=spring-boot3-oauth2-login" | jq -r
{
  "access_token": "eyJhbGci...tYfo6rEPEakNg",
  "expires_in": 180,
  "refresh_expires_in": 0,
  "token_type": "Bearer",
  "id_token": "eyJhbGci...KaoQ",
  "not-before-policy": 0,
  "scope": "openid email profile"
}

Parsed Access Token

{
  "exp": 1692791418,
  "iat": 1692791238,
  "jti": "a30d999e-2b26-4720-92a1-d907161675a0",
  "iss": "http://localhost:8180/auth/realms/demo",
  "aud": "account",
  "sub": "0fb7c670-ae44-412c-9da1-cf150eb0c327",
  "typ": "Bearer",
  "azp": "spring-boot3-oauth2-login",
  "acr": "1",
  "allowed-origins": [
    "http://localhost:8080"
  ],
  "realm_access": {
    "roles": [
      "offline_access",
      "default-roles-demo",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid email profile",
  "email_verified": false,
  "clientId": "spring-boot3-oauth2-login",
  "clientHost": "127.0.0.1",
  "preferred_username": "service-account-spring-boot3-oauth2-login",
  "clientAddress": "127.0.0.1"
}

Compare with OAuth 2.0 Resource Owner Password Credentials Grant where the Access Token is request for a logged in user

curl -s -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -u 'spring-boot3-oauth2-login:jO09Uwhi8oxTL3QnTKtYZ20ByQvB2qA0' \
  http://localhost:8180/auth/realms/demo/protocol/openid-connect/token \
  -d "grant_type=password&username=john&password=changeit" | jq -r

Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant) and mTLS Access Token Request

Introduction

Using BASIC authentication when requesting Access Token in OAUth 2 Authorization Code Flow is not the safest way. Especially if this password is rarely changed.

See Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant)

Reference: Spring Security OAuth2 Authorization Code Requesting an Access Token

Prerequisite

Application

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>se.mkk</groupId>
    <artifactId>spring-boot-3-oauth2-login-keycloak-mtls</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-3-oauth2-login-keycloak-mtls</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <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-oauth2-client</artifactId>
        </dependency>
        <!-- org.springframework.http.client.HttpComponentsClientHttpRequestFactory -->
        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
        </dependency>
        <!-- dev tools -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <!-- test support -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-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>

src/main/resources/application.properties

To make mTLS work as authenticated when requesting Access Token, we need to send client_id in body, as is done when having public client

"client_id - REQUIRED, if the client is not authenticating with the authorization server"https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3

# OAuth2 Log In Spring Boot 2.x Property Mappings
# https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings
spring.security.oauth2.client.registration.keycloak.client-id = spring-boot3-oauth2-login
#spring.security.oauth2.client.registration.keycloak.client-secret = CHANGEME!!!
spring.security.oauth2.client.registration.keycloak.client-authentication-method = none
# org.springframework.security.oauth2.core.AuthorizationGrantType
spring.security.oauth2.client.registration.keycloak.authorization-grant-type = authorization_code
#spring.security.oauth2.client.registration.keycloak.redirect-uri =
spring.security.oauth2.client.registration.keycloak.scope = openid
#spring.security.oauth2.client.registration.keycloak.client-name =

#spring.security.oauth2.client.provider.keycloak.authorization-uri
#spring.security.oauth2.client.provider.keycloak.token-uri
#spring.security.oauth2.client.provider.keycloak.jwk-set-uri
spring.security.oauth2.client.provider.keycloak.issuer-uri = https://localhost:8543/auth/realms/demo
#spring.security.oauth2.client.provider.keycloak.user-info-uri
#spring.security.oauth2.client.provider.keycloak.user-info-authentication-method
spring.security.oauth2.client.provider.keycloak.user-name-attribute = preferred_username

Spring Security Java Config

package se.mkk.springboot3oauth2loginkeycloak;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;

import javax.net.ssl.SSLContext;

import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.client.RestTemplate;

import com.nimbusds.jose.util.JSONObjectUtils;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2LoginSecurityConfig {
    @Value("${javax.net.ssl.keyStore}")
    private String keyStore;
    @Value("${javax.net.ssl.keyStorePassword}")
    private String keyStorePassword;
    @Value("${javax.net.ssl.keyStoreType:PKCS12}")
    private String keyStoreType;
    @Value("${javax.net.ssl.trustStore}")
    private String trustStore;
    @Value("${javax.net.ssl.trustStorePassword}")
    private String trustStorePassword;
    @Value("${javax.net.ssl.trustStoreType:jks}")
    private String trustStoreType;

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-provide-securityfilterchain-bean
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .authorizeHttpRequests(authorize -> authorize //
                        .anyRequest().authenticated()) //
                .oauth2Login(oauth2 -> oauth2 //
                        .userInfoEndpoint(userInfo -> userInfo //
                                .oidcUserService(this.oidcUserService())))
                .oauth2Client(oauth2Client -> oauth2Client //
                        .authorizationCodeGrant(authorizationCodeGrant -> authorizationCodeGrant //
                                .accessTokenResponseClient(this.accessTokenResponseClient())));
        return http.build();
    }

    private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
        DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();

        // org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient
        RestTemplate restTemplate = new RestTemplate(
                Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        try {
            // https://github.com/apache/httpcomponents-client/blob/5.2.x/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientCustomSSL.java
            SSLContext sslContext = new SSLContextBuilder() //
                    .loadKeyMaterial(Path.of(keyStore), keyStorePassword.toCharArray(), keyStorePassword.toCharArray()) //
                    .loadTrustMaterial(Path.of(trustStore), trustStorePassword.toCharArray()) //
                    .build();

            final SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() //
                    .setSslContext(sslContext) //
                    .build();

            final HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() //
                    .setSSLSocketFactory(sslSocketFactory) //
                    .build();

            CloseableHttpClient httpClient = HttpClients.custom() //
                    .setConnectionManager(cm) //
                    .build();

            // "uses https://hc.apache.org/httpcomponents-client-ga Apache HttpComponents HttpClient to create requests"
            ClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
            restTemplate.setRequestFactory(requestFactory);
        } catch (Exception e) {
            e.printStackTrace();
        }
        accessTokenResponseClient.setRestOperations(restTemplate);
        return accessTokenResponseClient;
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities-oauth2userservice
    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            // Delegate to the default implementation for loading a user
            OidcUser oidcUser = delegate.loadUser(userRequest);

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Collection<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
            try {
                String[] chunks = accessToken.getTokenValue().split("\\.");
                Base64.Decoder decoder = Base64.getUrlDecoder();
                String header = new String(decoder.decode(chunks[0]));
                String payload = new String(decoder.decode(chunks[1]));

                Map<String, Object> claims = JSONObjectUtils.parse(payload);
                mappedAuthorities = new KeycloakAuthoritiesConverter().convert(claims);
            } catch (Exception e) {
                e.printStackTrace();
            }

            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
                    "preferred_username");

            return oidcUser;
        };
    }

    // Spring OAuth2 uses default Scopes Not Roles for Authorization
    // org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
    private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            return convert(jwt.getClaims());
        }

        public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : getAuthorities(claims)) {
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
            }
            return grantedAuthorities;
        }

        private Collection<String> getAuthorities(Map<String, Object> claims) {
            Object realm_access = claims.get("realm_access");
            if (realm_access instanceof Map) {
                Map<String, Object> map = castAuthoritiesToMap(realm_access);
                Object roles = map.get("roles");
                if (roles instanceof Collection) {
                    return castAuthoritiesToCollection(roles);
                }
            }
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        private Map<String, Object> castAuthoritiesToMap(Object authorities) {
            return (Map<String, Object>) authorities;
        }

        @SuppressWarnings("unchecked")
        private Collection<String> castAuthoritiesToCollection(Object authorities) {
            return (Collection<String>) authorities;
        }
    }
}

Run

When running the app you need to set the above Java System Properties or better set keystore properties in Spring application.properties file, so you can not list keystore password when listing processes and it's arguments from OS.

-Djavax.net.ssl.keyStore=client.p12 \
    -Djavax.net.ssl.keyStorePassword=CHANGEME!!! \
    -Djavax.net.ssl.keyStoreType=PKCS12 \
    -Djavax.net.ssl.trustStore=truststore.jks \
    -Djavax.net.ssl.trustStorePassword=changeit \
    -Djavax.net.ssl.trustStoreType=JKS

Source Code

https://github.com/magnuskkarlsson/spring-boot-3-oauth2-login-keycloak-mtls

Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant) for Angular SPA and Resource Server (JWT) for REST integration

Introduction

See Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant) for Angular SPA

See Spring Boot 3 and Keycloak with Oauth2 Resource Server (JWT) for REST integration

Here we will combine these two, to let same REST API service both a Angular app and for direct REST integrations.

Prerequisite

Application

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>se.mkk</groupId>
    <artifactId>spring-boot-3-oauth2-login-resource-server-keycloak-angular</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-3-oauth2-login-resource-server-keycloak-angular</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <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-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <!-- Developer Tools -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <!-- Test Support -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!-- https://github.com/40devweb/mvn-ng-sb/blob/main/pom.xml -->
            <!-- build Angular frontend resources -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>front-end install</id>
                        <goals>
                            <goal>exec</goal>
                        </goals>
                        <phase>prepare-package</phase>
                        <configuration>
                            <executable>npm</executable>
                            <arguments>
                                <argument>install</argument>
                            </arguments>
                        </configuration>
                    </execution>
                    <execution>
                        <id>front-end build</id>
                        <goals>
                            <goal>exec</goal>
                        </goals>
                        <phase>prepare-package</phase>
                        <configuration>
                            <executable>npm</executable>
                            <arguments>
                                <argument>run</argument>
                                <argument>build</argument>
                            </arguments>
                        </configuration>
                    </execution>
                </executions>
                <configuration>
                    <workingDirectory>${basedir}/frontend</workingDirectory>
                </configuration>
            </plugin>
            <!-- Copy Angular resources to Spring Boot resource files -->
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>copy front-end assets</id>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <phase>prepare-package</phase>
                        <configuration>
                            <outputDirectory>${basedir}/src/main/resources/static</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>frontend/dist/frontend</directory>
                                    <!--<excludes>
                                        <exclude>index.html</exclude>
                                    </excludes>-->
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                    <execution>
                        <id>copy front-end assets to target</id>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <phase>prepare-package</phase>
                        <configuration>
                            <outputDirectory>${basedir}/target/classes/static</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>frontend/dist/frontend</directory>
                                    <!--<excludes>
                                        <exclude>index.html</exclude>
                                    </excludes>-->
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <!-- Clean resources templates -->
            <plugin>
                <artifactId>maven-clean-plugin</artifactId>
                <version>3.2.0</version>
                <configuration>
                    <filesets>
                        <fileset>
                            <directory>${basedir}/src/main/resources/static</directory>
                            <followSymlinks>false</followSymlinks>
                        </fileset>
                        <fileset>
                            <directory>${basedir}/src/main/resources/templates</directory>
                            <includes>
                                <include>index.html</include>
                            </includes>
                            <followSymlinks>false</followSymlinks>
                        </fileset>
                        <fileset>
                            <directory>${basedir}/frontend/dist</directory>
                            <includes>
                                <include>**/*</include>
                            </includes>
                            <followSymlinks>false</followSymlinks>
                        </fileset>
                    </filesets>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

src/main/resources/application.properties

# OAuth2 Log In Spring Boot 2.x Property Mappings
# https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings
spring.security.oauth2.client.registration.keycloak.client-id=spring-boot3-oauth2-login
spring.security.oauth2.client.registration.keycloak.client-secret=jO09Uwhi8oxTL3QnTKtYZ20ByQvB2qA0
#spring.security.oauth2.client.registration.keycloak.client-authentication-method = 
# org.springframework.security.oauth2.core.AuthorizationGrantType
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
#spring.security.oauth2.client.registration.keycloak.authorization-grant-type=urn:ietf:params:oauth:grant-type:jwt-bearer
#spring.security.oauth2.client.registration.keycloak.redirect-uri =
spring.security.oauth2.client.registration.keycloak.scope=openid
#spring.security.oauth2.client.registration.keycloak.client-name =

#spring.security.oauth2.client.provider.keycloak.authorization-uri
#spring.security.oauth2.client.provider.keycloak.token-uri
#spring.security.oauth2.client.provider.keycloak.jwk-set-uri
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8180/auth/realms/demo
#spring.security.oauth2.client.provider.keycloak.user-info-uri
#spring.security.oauth2.client.provider.keycloak.user-info-authentication-method
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

# https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
# https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.security
spring.security.oauth2.resourceserver.jwt.issuer-uri = http://localhost:8180/auth/realms/demo
#spring.security.oauth2.resourceserver.jwt.jwk-set-uri = http://localhost:8180/auth/realms/demo/protocol/openid-connect/certs

# https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
#server.servlet.session.cookie.domain
server.servlet.session.cookie.http-only = true
server.servlet.session.cookie.max-age = 3m
#server.servlet.session.cookie.name
#server.servlet.session.cookie.path
server.servlet.session.cookie.same-site = strict
server.servlet.session.cookie.secure = true
server.servlet.session.persistent = false
#server.servlet.session.store-dir
server.servlet.session.timeout = 3m
server.servlet.session.tracking-modes = cookie

Spring Security Java Config

package se.mkk.springboot3oauth2loginresourceserverkeycloakangular;

import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import com.nimbusds.jose.util.JSONObjectUtils;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2LoginSecurityConfig {
    private final Logger log = LoggerFactory.getLogger(this.getClass());
    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .sessionManagement(
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
                        // https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
                        Customizer.withDefaults())
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
                        .requestMatchers("/login", "/logout").permitAll() //
//                        .requestMatchers("/api/users/roles").hasRole("USER") //
                        .anyRequest().authenticated()) //
                .oauth2ResourceServer(oauth2 -> oauth2 //
                        .jwt(jwt -> jwt //
                                .jwtAuthenticationConverter(this.jwtAuthenticationConverter())))
                .oauth2Login(oauth2 -> oauth2 //
                        .userInfoEndpoint(userInfo -> userInfo //
                                .oidcUserService(this.oidcUserService())))
                .csrf(csrf -> csrf //
                        // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-token-repository-cookie
                        .ignoringRequestMatchers("/logout", "/api"))
                .logout(logout -> logout //
                        .addLogoutHandler(new KeycloakLogoutHandler(restTemplateBuilder.build())) //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#clear-all-site-data
                        .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))));
        return http.build();
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html#oauth2resourceserver-jwt-authorization-extraction
    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setPrincipalClaimName("preferred_username");
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakAuthoritiesConverter());
        return jwtAuthenticationConverter;
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities-oauth2userservice
    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            // Delegate to the default implementation for loading a user
            OidcUser oidcUser = delegate.loadUser(userRequest);

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Collection<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
            try {
                String[] chunks = accessToken.getTokenValue().split("\\.");
                Base64.Decoder decoder = Base64.getUrlDecoder();
                String header = new String(decoder.decode(chunks[0]));
                String payload = new String(decoder.decode(chunks[1]));

                Map<String, Object> claims = JSONObjectUtils.parse(payload);
                mappedAuthorities = new KeycloakAuthoritiesConverter().convert(claims);
            } catch (Exception e) {
                log.error("Failed to map Authorities", e);
            }

            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
                    "preferred_username");

            return oidcUser;
        };
    }

    // Spring OAuth2 uses default Scopes Not Roles for Authorization
    // org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
    private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            return convert(jwt.getClaims());
        }

        public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : getAuthorities(claims)) {
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
            }
            return grantedAuthorities;
        }

        private Collection<String> getAuthorities(Map<String, Object> claims) {
            Object realm_access = claims.get("realm_access");
            log.info("Retrieved realm_access {}", realm_access);
            if (realm_access instanceof Map) {
                Map<String, Object> map = castAuthoritiesToMap(realm_access);
                Object roles = map.get("roles");
                if (roles instanceof Collection) {
                    return castAuthoritiesToCollection(roles);
                }
            }
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        private Map<String, Object> castAuthoritiesToMap(Object authorities) {
            return (Map<String, Object>) authorities;
        }

        @SuppressWarnings("unchecked")
        private Collection<String> castAuthoritiesToCollection(Object authorities) {
            return (Collection<String>) authorities;
        }
    }

    // OpenID Connect 1.0 Logout Does Not work for Angular app, since redirect will violate CORS (Reason: CORS header
    // ‘Access-Control-Allow-Origin’ missing) and OidcClientInitiatedLogoutSuccessHandler ignores Spring Security CORS
    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout
    // https://github.com/simasch/vaadin-keycloak/blob/main/src/main/java/ch/martinelli/demo/keycloak/security/KeycloakLogoutHandler.java
    private class KeycloakLogoutHandler implements LogoutHandler {
        private final RestTemplate restTemplate;

        public KeycloakLogoutHandler(RestTemplate restTemplate) {
            this.restTemplate = restTemplate;
        }

        @Override
        public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
            logoutFromKeycloak((OidcUser) auth.getPrincipal());
        }

        private void logoutFromKeycloak(OidcUser user) {
            // https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.6/html-single/securing_applications_and_services_guide/index#logout
            String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
            UriComponentsBuilder builder = UriComponentsBuilder //
                    .fromUriString(endSessionEndpoint) //
                    .queryParam("id_token_hint", user.getIdToken().getTokenValue());

            ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
            if (logoutResponse.getStatusCode().is2xxSuccessful()) {
                log.info("Successfully logged out from Keycloak");
            } else {
                log.error("Could not propagate logout to Keycloak");
            }
        }
    }
}

Source Code

See https://github.com/magnuskkarlsson/spring-boot-3-oauth2-login-resource-server-keycloak-angular

August 21, 2023

Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant) for Angular SPA

Introduction

See https://magnus-k-karlsson.blogspot.com/2023/08/spring-boot-3-spring-security-6.html

Prerequisite

Application

Spring Security Session Management

"The main point to take on board here is that security is stateful. You can’t have a secure, stateless application."

"TCP and SSL are stateful so your system is stateful whether you knew it or not"

Rob Winch Spring Exchange 2014 https://spring.io/guides/tutorials/spring-security-and-angular-js/

https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html

The most important configuration is done in src/main/resources/application.properties

# https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
#server.servlet.session.cookie.domain
server.servlet.session.cookie.http-only = true
server.servlet.session.cookie.max-age = 3m
#server.servlet.session.cookie.name
#server.servlet.session.cookie.path
server.servlet.session.cookie.same-site = strict
server.servlet.session.cookie.secure = true
server.servlet.session.persistent = false
#server.servlet.session.store-dir
server.servlet.session.timeout = 3m
server.servlet.session.tracking-modes = cookie

Application Logout and OpenID Connect 1.0 Logout

Application logout POST /logout

https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html

OpenID Connect 1.0 Logout does Not work for SPA application since sending 302 with different Location other than Server will render CORS Error.

https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout

Soo you need to implement custom LogoutHandler

package se.mkk.springboot3oauth2loginkeycloakangular;

import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter;
import org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter.Directive;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import com.nimbusds.jose.util.JSONObjectUtils;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2LoginSecurityConfig {
    private final Logger log = LoggerFactory.getLogger(this.getClass());

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .sessionManagement(
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
                        // https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.server
                        Customizer.withDefaults())
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //
                        .requestMatchers("/login", "/logout").permitAll() //
//                        .requestMatchers("/api/users/roles").hasRole("USER") //
                        .anyRequest().authenticated()) //
                .oauth2Login(oauth2 -> oauth2 //
                        .userInfoEndpoint(userInfo -> userInfo //
                                .oidcUserService(this.oidcUserService())))
                .csrf(csrf -> csrf //
                        // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-token-repository-cookie
                        .ignoringRequestMatchers("/logout", "/api"))
                .logout(logout -> logout //
                        .addLogoutHandler(new KeycloakLogoutHandler(restTemplateBuilder.build())) //
                        // https://docs.spring.io/spring-security/reference/servlet/authentication/logout.html#clear-all-site-data
                        .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL))));
        return http.build();
    }

    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    // OpenID Connect 1.0 Logout Does Not work for Angular app, since redirect will violate CORS (Reason: CORS header
    // ‘Access-Control-Allow-Origin’ missing) and OidcClientInitiatedLogoutSuccessHandler ignores Spring Security CORS
    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout
    // https://github.com/simasch/vaadin-keycloak/blob/main/src/main/java/ch/martinelli/demo/keycloak/security/KeycloakLogoutHandler.java
    private class KeycloakLogoutHandler implements LogoutHandler {
        private final RestTemplate restTemplate;

        public KeycloakLogoutHandler(RestTemplate restTemplate) {
            this.restTemplate = restTemplate;
        }

        @Override
        public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
            logoutFromKeycloak((OidcUser) auth.getPrincipal());
        }

        private void logoutFromKeycloak(OidcUser user) {
            // https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.6/html-single/securing_applications_and_services_guide/index#logout
            String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
            UriComponentsBuilder builder = UriComponentsBuilder //
                    .fromUriString(endSessionEndpoint) //
                    .queryParam("id_token_hint", user.getIdToken().getTokenValue());

            ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
            if (logoutResponse.getStatusCode().is2xxSuccessful()) {
                log.info("Successfully logged out from Keycloak");
            } else {
                log.error("Could not propagate logout to Keycloak");
            }
        }
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities-oauth2userservice
    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            // Delegate to the default implementation for loading a user
            OidcUser oidcUser = delegate.loadUser(userRequest);

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Collection<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
            try {
                String[] chunks = accessToken.getTokenValue().split("\\.");
                Base64.Decoder decoder = Base64.getUrlDecoder();
                String header = new String(decoder.decode(chunks[0]));
                String payload = new String(decoder.decode(chunks[1]));

                Map<String, Object> claims = JSONObjectUtils.parse(payload);
                mappedAuthorities = new KeycloakAuthoritiesConverter().convert(claims);
            } catch (Exception e) {
                log.error("Failed to map Authorities", e);
            }

            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
                    "preferred_username");

            return oidcUser;
        };
    }

    // Spring OAuth2 uses default Scopes Not Roles for Authorization
    // org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
    private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            return convert(jwt.getClaims());
        }

        public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : getAuthorities(claims)) {
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
            }
            return grantedAuthorities;
        }

        private Collection<String> getAuthorities(Map<String, Object> claims) {
            Object realm_access = claims.get("realm_access");
            log.info("Retrieved realm_access {}", realm_access);
            if (realm_access instanceof Map) {
                Map<String, Object> map = castAuthoritiesToMap(realm_access);
                Object roles = map.get("roles");
                if (roles instanceof Collection) {
                    return castAuthoritiesToCollection(roles);
                }
            }
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        private Map<String, Object> castAuthoritiesToMap(Object authorities) {
            return (Map<String, Object>) authorities;
        }

        @SuppressWarnings("unchecked")
        private Collection<String> castAuthoritiesToCollection(Object authorities) {
            return (Collection<String>) authorities;
        }
    }
}

Source code

https://github.com/magnuskkarlsson/spring-boot-3-oauth2-login-keycloak-angular

August 19, 2023

Spring Boot 3 and Keycloak with Oauth2 Resource Server (JWT) for REST integration

Introduction

Spring Security OAuth2 Login does NOT support authentication with Access Token that you might first think.

https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html

The JWT Bearer is something different, that I have seen rarely used

     POST /token.oauth2 HTTP/1.1
     Host: as.example.com
     Content-Type: application/x-www-form-urlencoded

     grant_type=authorization_code&
     code=n0esc3NRze7LTCu7iYzS6a5acc3f0ogp4&
     client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3A
     client-assertion-type%3Ajwt-bearer&
     client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0.
     eyJpc3Mi[...omitted for brevity...].
     cC4hiUPo[...omitted for brevity...]

What you normally do, to access a OAuth2 protected resources is

GET / HTTP/1.1
Authorization: Bearer some-token-value # Resource Server will process this

And to setup backend for that you need Spring Security 6 OAuth2 Resource Server.

Prerequisite

  • Java 17
  • Maven 3.6.3 or later
  • Spring 3.1.2
  • Spring Security 6.1.2 Resource Server
  • Keycloak. I will use the commercial version RH SSO 7.6.0
  • OAuth2 Resource Owner Password Credentials Grant https://datatracker.ietf.org/doc/html/rfc6749#section-4.3
  • Not neccessary but convenient jq - Command-line JSON processor (on Fedora $ sudo dnf install jq)

Application

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>se.mkk</groupId>
    <artifactId>spring-boot-3-oauth2-resource-server-keycloak</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-3-oauth2-resource-server-keycloak</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <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-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-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>

src/main/resources/application.properties

jwk-set-uri is not neccessary since spring security reads http://localhost:8180/auth/realms/demo/.well-known/openid-configuration.

# https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
# https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#appendix.application-properties.security
spring.security.oauth2.resourceserver.jwt.issuer-uri = http://localhost:8180/auth/realms/demo
#spring.security.oauth2.resourceserver.jwt.jwk-set-uri = http://localhost:8180/auth/realms/demo/protocol/openid-connect/certs

Java code

package se.mkk.springboot3oauth2resourceserverkeycloak;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Simple REST endpoint

package se.mkk.springboot3oauth2resourceserverkeycloak;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

//import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    public Map<String, String> getUser(HttpServletRequest request, Principal principal) {
        Map<String, String> rtn = new LinkedHashMap<>();
        rtn.put("request.getRemoteUser()", request.getRemoteUser());
        rtn.put("request.isUserInRole(\"USER\")", Boolean.toString(request.isUserInRole("USER")));
        rtn.put("request.getUserPrincipal().getClass()", request.getUserPrincipal().getClass().getName());
        rtn.put("principal.getClass().getName()", principal.getClass().getName());
        rtn.put("principal.getName()", principal.getName());
        if (principal instanceof JwtAuthenticationToken token) {
            List<String> authorities = token.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority()).toList();
            rtn.put("JwtAuthenticationToken.getAuthorities()", authorities.toString());
        }
        return rtn;
    }
}

The OAuth2 Resource Server code. To make it work with Keycloak we need 2 adjustment.

  1. Change username to preferred_username - jwtAuthenticationConverter.setPrincipalClaimName("preferred_username")
  2. Read Roles Not Scopes - jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakAuthoritiesConverter());
package se.mkk.springboot3oauth2resourceserverkeycloak;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2ResourceServerSecurityConfig {

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html#oauth2resourceserver-jwt-sansboot
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .authorizeHttpRequests(authorize -> authorize //
                        .anyRequest().authenticated()) //
                .oauth2ResourceServer(oauth2 -> oauth2 //
                        .jwt(jwt -> jwt //
                                .jwtAuthenticationConverter(this.jwtAuthenticationConverter())));
        return http.build();
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html#oauth2resourceserver-jwt-authorization-extraction
    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setPrincipalClaimName("preferred_username");
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakAuthoritiesConverter());
        return jwtAuthenticationConverter;
    }

    // Spring OAuth2 uses default Scopes Not Roles for Authorization
    // org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
    private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            return convert(jwt.getClaims());
        }

        public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : getAuthorities(claims)) {
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
            }
            return grantedAuthorities;
        }

        private Collection<String> getAuthorities(Map<String, Object> claims) {
            Object realm_access = claims.get("realm_access");
            if (realm_access instanceof Map) {
                Map<String, Object> map = castAuthoritiesToMap(realm_access);
                Object roles = map.get("roles");
                if (roles instanceof Collection) {
                    return castAuthoritiesToCollection(roles);
                }
            }
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        private Map<String, Object> castAuthoritiesToMap(Object authorities) {
            return (Map<String, Object>) authorities;
        }

        @SuppressWarnings("unchecked")
        private Collection<String> castAuthoritiesToCollection(Object authorities) {
            return (Collection<String>) authorities;
        }
    }
}

Test

Get Access Token from Keycloak

$ ACCESS_TOKEN=$(curl -s -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -u 'spring-boot3-oauth2-login:jO09Uwhi8oxTL3QnTKtYZ20ByQvB2qA0' \
  http://localhost:8180/auth/realms/demo/protocol/openid-connect/token \
  -d "grant_type=password&username=john&password=changeit" | jq -r .access_token)

Call REST api

$ curl -v -X GET -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  http://localhost:8080/api/users | jq .
...
{
  "request.getRemoteUser()": "john",
  "request.isUserInRole(\"USER\")": "true",
  "request.getUserPrincipal().getClass()": "org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken",
  "principal.getClass().getName()": "org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken",
  "principal.getName()": "john",
  "JwtAuthenticationToken.getAuthorities()": "[ROLE_offline_access, ROLE_default-roles-demo, ROLE_uma_authorization, ROLE_USER]"
}

Source Code

https://github.com/magnuskkarlsson/spring-boot-3-oauth2-resource-server-keycloak

August 18, 2023

Spring Boot 3 and Keycloak with Oauth2 Log in (Authorization Code Grant)

Prerequisite

RH SSO/Keycloak

Download, unzip and create initial admin user and finally start at http://127.0.0.1:8180/. You could also use a Docker container.

$ ./add-user-keycloak.sh -u admin

$ ./standalone.sh -Djboss.socket.binding.port-offset=100

In Keycloak create

  • New Realm demo
  • Role USER
  • User john with password
  • Assign role USER to user john
  • Create OIDC client with Client ID spring-boot3-oauth2-login and Root URL http://localhost:8080/

Application

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>se.mkk</groupId>
    <artifactId>spring-boot-3-oauth2-login-keycloak</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-3-oauth2-login-keycloak</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <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-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-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>

src/main/resources/application.properties

# OAuth2 Log In Spring Boot 2.x Property Mappings
# https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-boot-property-mappings
spring.security.oauth2.client.registration.keycloak.client-id = spring-boot3-oauth2-login
spring.security.oauth2.client.registration.keycloak.client-secret = CHANGEME!!!
#spring.security.oauth2.client.registration.keycloak.client-authentication-method = 
spring.security.oauth2.client.registration.keycloak.authorization-grant-type = authorization_code
#spring.security.oauth2.client.registration.keycloak.redirect-uri =
spring.security.oauth2.client.registration.keycloak.scope = openid
#spring.security.oauth2.client.registration.keycloak.client-name =

#spring.security.oauth2.client.provider.keycloak.authorization-uri
#spring.security.oauth2.client.provider.keycloak.token-uri
#spring.security.oauth2.client.provider.keycloak.jwk-set-uri
spring.security.oauth2.client.provider.keycloak.issuer-uri = http://localhost:8180/auth/realms/demo
#spring.security.oauth2.client.provider.keycloak.user-info-uri
#spring.security.oauth2.client.provider.keycloak.user-info-authentication-method
spring.security.oauth2.client.provider.keycloak.user-name-attribute = preferred_username

Simple REST API

package se.mkk.springboot3oauth2loginkeycloak;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
package se.mkk.springboot3oauth2loginkeycloak;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    public Map<String, String> getUser(HttpServletRequest request, Principal principal) {
        Map<String, String> rtn = new LinkedHashMap<>();
        rtn.put("request.getRemoteUser()", request.getRemoteUser());
        rtn.put("request.isUserInRole(\"USER\")", Boolean.toString(request.isUserInRole("USER")));
        rtn.put("request.getUserPrincipal().getClass()", request.getUserPrincipal().getClass().getName());
        rtn.put("principal.getClass().getName()", principal.getClass().getName());
        rtn.put("principal.getName()", principal.getName());
        if (principal instanceof OAuth2AuthenticationToken token) {
            List<String> authorities = token.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority()).toList();
            rtn.put("OAuth2AuthenticationToken.getAuthorities()", authorities.toString());
        }
        return rtn;
    }
}

OAuth2 Log in

ppackage se.mkk.springboot3oauth2loginkeycloak;

import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;

import com.nimbusds.jose.util.JSONObjectUtils;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2LoginSecurityConfig {

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html#oauth2login-provide-securityfilterchain-bean
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http //
                .authorizeHttpRequests(authorize -> authorize //
                        .anyRequest().authenticated()) //
                .oauth2Login(oauth2 -> oauth2 //
                        .userInfoEndpoint(userInfo -> userInfo //
                                .oidcUserService(this.oidcUserService())));
        return http.build();
    }

    // https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-map-authorities-oauth2userservice
    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        final OidcUserService delegate = new OidcUserService();

        return (userRequest) -> {
            // Delegate to the default implementation for loading a user
            OidcUser oidcUser = delegate.loadUser(userRequest);

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Collection<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // 1) Fetch the authority information from the protected resource using accessToken
            // 2) Map the authority information to one or more GrantedAuthority's and add it to mappedAuthorities
            try {
                String[] chunks = accessToken.getTokenValue().split("\\.");
                Base64.Decoder decoder = Base64.getUrlDecoder();
                String header = new String(decoder.decode(chunks[0]));
                String payload = new String(decoder.decode(chunks[1]));

                Map<String, Object> claims = JSONObjectUtils.parse(payload);
                mappedAuthorities = new KeycloakAuthoritiesConverter().convert(claims);
            } catch (Exception e) {
                e.printStackTrace();
            }

            // 3) Create a copy of oidcUser but use the mappedAuthorities instead
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
                    "preferred_username");

            return oidcUser;
        };
    }

    // Spring OAuth2 uses default Scopes Not Roles for Authorization
    // org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
    private class KeycloakAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            return convert(jwt.getClaims());
        }

        public Collection<GrantedAuthority> convert(Map<String, Object> claims) {
            Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : getAuthorities(claims)) {
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + authority));
            }
            return grantedAuthorities;
        }

        private Collection<String> getAuthorities(Map<String, Object> claims) {
            Object realm_access = claims.get("realm_access");
            if (realm_access instanceof Map) {
                Map<String, Object> map = castAuthoritiesToMap(realm_access);
                Object roles = map.get("roles");
                if (roles instanceof Collection) {
                    return castAuthoritiesToCollection(roles);
                }
            }
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        private Map<String, Object> castAuthoritiesToMap(Object authorities) {
            return (Map<String, Object>) authorities;
        }

        @SuppressWarnings("unchecked")
        private Collection<String> castAuthoritiesToCollection(Object authorities) {
            return (Collection<String>) authorities;
        }
    }
}

And now run

$ mvn clean install spring-boot:run

And login and call REST endpoint

request.getRemoteUser()	"john"
request.isUserInRole("USER")	"true"
request.getUserPrincipal().getClass()	"org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken"
principal.getClass().getName()	"org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken"
principal.getName()	"john"
OAuth2AuthenticationToken.getAuthorities()	"[ROLE_USER, ROLE_default-roles-demo, ROLE_offline_access, ROLE_uma_authorization]"

Summary

You also want to configure Spring Session, Logout and CSRF.

Source code https://github.com/magnuskkarlsson/spring-boot-3-oauth2-login-keycloak