August 23, 2023

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

No comments: