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

No comments: