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.
- Change username to preferred_username - jwtAuthenticationConverter.setPrincipalClaimName("preferred_username")
- 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:
Post a Comment