September 10, 2023

Keycloak, User Federation from LDAP and Lesson Learned

LDAP

We are going to use OpenLDAP and setup in previous blog OpenLDAP Image and Custom LDIF with User and Group.

$ podman run -d --name openldap \
    -e LDAP_ROOT=dc=magnuskkarlsson,dc=se \
    -e LDAP_ADMIN_USERNAME=admin \
    -e LDAP_ADMIN_PASSWORD=changeit \
    -e LDAP_CONFIG_ADMIN_ENABLED=true \
    -e LDAP_ALLOW_ANON_BINDING=false \
    -e LDAP_CUSTOM_LDIF_DIR=/ldifs \
    -p 1389:1389 \
    -p 1636:1636 \
    -v ./ldifs:/ldifs:Z \
    docker.io/bitnami/openldap:2.6

Test it is working.

$ ldapsearch -H ldap://localhost:1389 -D cn=admin,dc=magnuskkarlsson,dc=se -w changeit -b dc=magnuskkarlsson,dc=se -s sub

Keycloak Installation

Here we will use the supported Keycloak version - RH SSO.

$ unzip rh-sso-7.6.0-server-dist.zip
$ mv rh-sso-7.6 rh-sso-7.6.0
$ cd rh-sso-7.6.0/bin/

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

Configure LDAP logging in RH SSO

$ vim rh-sso-7.6.0/standalone/configuration/standalone.xml 
...
    <profile>
        <subsystem xmlns="urn:jboss:domain:logging:8.0">
...
            </logger>
            <logger category="org.keycloak.storage.ldap">
                <level name="TRACE"/>
            </logger>            
            <root-logger>
...

Start

$ export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk

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

Kecloak Configuration User Federation with NO Import

We will start federate openldap user and roles and not import them and test what happens, to understand how user federation works.

Add User federation

Edit mode: READ_ONLY

Import Users: OFF

No Synchronization

Add Mappers:

  • password: user-attribute-ldap-mapper; password; userPassword
  • role-ldap-mapper: role-ldap-mapper;

We can verify that user is not imported, by clicking on User and View all users

Now test federation, by logging in to user portal: http://localhost:8180/auth/realms/demo/account/

john
bitnami1

kate
bitnami1

And observe server log, that user is fetch by RDN LDAP attribute: uid

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 2 ms

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 1 ms

LdapOperation: search
 baseDn: ou=Groups,dc=magnuskkarlsson,dc=se
 filter: (&(member=cn=john,ou=People,dc=magnuskkarlsson,dc=se)(objectclass=groupOfNames))
 searchScope: 1
 returningAttrs: [cn]
 resultSize: 2
took: 1 ms
bitnami1

Kecloak Configuration User Federation with Import

Now Import Users: ON

Then login (user fetced by RDN LDAP attribute: uid), close private window and login again. Now is the user fetched by UUID LDAP attribute: entryUUID

LdapOperation: search
 baseDn: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(uid=john)(objectclass=inetOrgPerson)(objectclass=organizationalPerson))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
 resultSize: 1
took: 2 ms

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 3 ms

LdapOperation: search
 baseDn: ou=Groups,dc=magnuskkarlsson,dc=se
 filter: (&(member=cn=john,ou=People,dc=magnuskkarlsson,dc=se)(objectclass=groupOfNames))
 searchScope: 1
 returningAttrs: [cn]
 resultSize: 2
took: 1 ms

...

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 4 ms

When User is imported you can search and find User in Web Console and you can see in server.log that User is updated from LDAP.

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 2 ms

LdapOperation: lookupById
 baseDN: ou=People,dc=magnuskkarlsson,dc=se
 filter: (&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))
 searchScope: 1
 returningAttrs: [uid, userPassword, modifyTimestamp, cn, mail, createTimestamp, sn]
took: 1 ms

Side effects

This online dependency has also consequence, if LDAP server is offline than User cannot log in.

But if the LDAP server comes back online, User can login again.

15:04:09,715 WARN  [org.keycloak.services] (default task-44) KC-SERVICES0013: Failed authentication: org.keycloak.models.ModelException: LDAP Query failed
...
Caused by: org.keycloak.models.ModelException: Querying of LDAP failed org.keycloak.storage.ldap.idm.query.internal.LDAPQuery@54e2e27
...
Caused by: org.keycloak.models.ModelException: Could not query server using DN [ou=People,dc=magnuskkarlsson,dc=se] and filter [(&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))]
...
Caused by: javax.naming.CommunicationException: localhost:1389 [Root exception is java.net.ConnectException: Connection refused (Connection refused)]
...
Caused by: java.net.ConnectException: Connection refused (Connection refused)
...
15:04:09,755 WARN  [org.keycloak.events] (default task-44) type=LOGIN_ERROR, realmId=demo, clientId=account-console, userId=null, ipAddress=127.0.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8180/auth/realms/demo/account/#/, code_id=618ac086-9b7f-4bd1-b8a2-0498ab2e7390, username=john, authSessionParentId=618ac086-9b7f-4bd1-b8a2-0498ab2e7390, authSessionTabId=XLmqDMVrjkc

Or if LDAP server is slow (We have set Connection and Read timeout, this is important, since default values are infinite), the User cannot login.

15:10:14,894 WARN  [org.keycloak.services] (default task-44) KC-SERVICES0013: Failed authentication: org.keycloak.models.ModelException: LDAP Query failed
...
Caused by: org.keycloak.models.ModelException: Querying of LDAP failed org.keycloak.storage.ldap.idm.query.internal.LDAPQuery@c684144
...
Caused by: org.keycloak.models.ModelException: Could not query server using DN [ou=People,dc=magnuskkarlsson,dc=se] and filter [(&(objectClass=*)(entryUUID=b8f499ce-e341-103d-9c19-dfc37858caec))]
...
Caused by: javax.naming.CommunicationException: localhost:1389 [Root exception is java.net.SocketTimeoutException: connect timed out]
...
Caused by: java.net.SocketTimeoutException: connect timed out
...
15:10:14,907 WARN  [org.keycloak.events] (default task-44) type=LOGIN_ERROR, realmId=demo, clientId=account-console, userId=null, ipAddress=127.0.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8180/auth/realms/demo/account/#/, code_id=f68f708a-82de-4c5b-957f-9c7084374922, username=john, authSessionParentId=f68f708a-82de-4c5b-957f-9c7084374922, authSessionTabId=RyZud1gnRGs

Another side effect of not periodically import all User, is that User, that have never logged in, will not be visible in RH SSO.

And another side effect, is that User in RH SSO will never be pruned, i.e. if user is deleted in LDAP, that User will never be removed from RH SSO.

And this online dependency, can not be circumvented, by disabling user federation.

Kecloak Configuration User Federation and Different Edit Modes

WRITABLE means data will be synced back to LDAP on demand.

UNSYNCED means user data will be imported, but not synced back to LDAP.

READ_ONLY is a read-only LDAP store. "You cannot change the username, email, first name, last name, and other mapped attributes. Red Hat Single Sign-On shows an error anytime a user attempts to update these fields. Password updates are not supported."

September 8, 2023

OpenLDAP Image and Custom LDIF with User and Group

Reference: https://hub.docker.com/r/bitnami/openldap

LDAP_PORT_NUMBER: The port OpenLDAP is listening for requests. Priviledged port is supported (e.g. 1389). Default: 1389 (non privileged port).

LDAP_ROOT: LDAP baseDN (or suffix) of the LDAP tree. Default: dc=example,dc=org

LDAP_ADMIN_USERNAME: LDAP database admin user. Default: admin

LDAP_ADMIN_PASSWORD: LDAP database admin password. Default: adminpassword

LDAP_CONFIG_ADMIN_ENABLED: Whether to create a configuration admin user. Default: no.

LDAP_USERS: Comma separated list of LDAP users to create in the default LDAP tree. Default: user01,user02

LDAP_PASSWORDS: Comma separated list of passwords to use for LDAP users. Default: bitnami1,bitnami2

LDAP_USER_DC: DC for the users' organizational unit. Default: users

LDAP_GROUP: Group used to group created users. Default: readers

LDAP_ALLOW_ANON_BINDING: Allow anonymous bindings to the LDAP server. Default: yes.

LDAP_PASSWORD_HASH: Hash to be used in generation of user passwords. Must be one of {SSHA}, {SHA}, {SMD5}, {MD5}, {CRYPT}, and {CLEARTEXT}. Default: {SSHA}.

LDAP_CUSTOM_LDIF_DIR: Location of a directory that contains LDIF files that should be used to bootstrap the database. Only files ending in .ldif will be used. Default LDAP tree based on the LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP will be skipped when LDAP_CUSTOM_LDIF_DIR is used. When using this it will override the usage of LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP. You should set LDAP_ROOT to your base to make sure the olcSuffix configured on the database matches the contents imported from the LDIF files. Default: /ldifs

LDAP_PASSWORD_HASH: Hash to be used in generation of user passwords. Must be one of {SSHA}, {SHA}, {SMD5}, {MD5}, {CRYPT}, and {CLEARTEXT}. Default: {SSHA}.

Create a new directory ldif with custom LDIF for Users and Groups

dn: dc=magnuskkarlsson,dc=se
objectClass: dcObject
objectClass: organization
dc: magnuskkarlsson
o: Magnus K Karlsson

dn: ou=People,dc=magnuskkarlsson,dc=se
objectClass: organizationalUnit
ou: People

dn: ou=Groups,dc=magnuskkarlsson,dc=se
objectClass: organizationalUnit
ou: Groups

## Users

dn: cn=john,ou=People,dc=magnuskkarlsson,dc=se
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: john
userPassword:: Yml0bmFtaTE=
cn: John
sn: Doe
mail: john.doe@domain.com
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/john

dn: cn=kate,ou=People,dc=magnuskkarlsson,dc=se
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: kate
userPassword:: Yml0bmFtaTE=
cn: Kate
sn: Doe
mail: kate.doe@domain.com
uidNumber: 1001
gidNumber: 1001
homeDirectory: /home/kate

## Groups

dn: cn=USER,ou=Groups,dc=magnuskkarlsson,dc=se
cn: USER
objectClass: groupOfNames
member: cn=john,ou=People,dc=magnuskkarlsson,dc=se
member: cn=kate,ou=People,dc=magnuskkarlsson,dc=se

dn: cn=ADMIN,ou=Groups,dc=magnuskkarlsson,dc=se
cn: ADMIN
objectClass: groupOfNames
member: cn=john,ou=People,dc=magnuskkarlsson,dc=se
$ podman run -d --name openldap \
    -e LDAP_ROOT=dc=magnuskkarlsson,dc=se \
    -e LDAP_ADMIN_USERNAME=admin \
    -e LDAP_ADMIN_PASSWORD=changeit \
    -e LDAP_CONFIG_ADMIN_ENABLED=true \
    -e LDAP_ALLOW_ANON_BINDING=false \
    -e LDAP_CUSTOM_LDIF_DIR=/ldifs \
    -p 1389:1389 \
    -p 1636:1636 \
    -v ./ldifs:/ldifs:Z \
    docker.io/bitnami/openldap:2.6
$ podman logs --follow openldap
...
 13:30:55.23 INFO  ==> Loading custom LDIF files...
 13:30:55.23 WARN  ==> Ignoring LDAP_USERS, LDAP_PASSWORDS, LDAP_USER_DC and LDAP_GROUP environment variables...
 13:30:56.35 INFO  ==> ** LDAP setup finished! **

And later to stop

$ podman stop openldap; podman rm openldap

Now verify ldap and it's entries. First install ldap client

$ sudo dnf install openldap-clients
$ ldapsearch -h
...
  -H URI     LDAP Uniform Resource Identifier(s)
  -D binddn  bind DN
  -x         Simple authentication  
  -w passwd  bind password (for simple authentication)
  -b basedn  base dn for search  
  -s scope   one of base, one, sub or children (search scope)
...

$ ldapsearch -H ldap://localhost:1389 -D cn=admin,dc=magnuskkarlsson,dc=se -w changeit -b dc=magnuskkarlsson,dc=se -s sub

GUI Administration Tools. Apache Directory Studio Eclipse-based LDAP tools

https://magnus-k-karlsson.blogspot.com/2015/02/understanding-ldap-and-ldap.html

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