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

No comments: