OAuth2 — write a resource server with KeyCloak and Spring Security

Rui Zhou
7 min readFeb 3, 2024

--

Nowadays in a Cloud-native application system, a microservice is a kind of resource server that needs to be protected. In this article, I will give an example resource server protected by Spring Security 6(Spring Boot 3) and Authorization Server(KeyCloak)

Authorization Server

There was an Authorization Server class in Spring Security OAuth2, but now it is deprecated.

https://stackoverflow.com/questions/59273338/what-is-the-replacement-for-the-deprecated-authorizationserver-in-spring-securit

Then they announced a new Authorization Server at https://github.com/spring-projects-experimental/spring-authorization-server

If you want to write your own Authorization Server, ensure you use the correct package and dependency.

KeyCloak

We can also leverage some open-source projects, such as Keycloak and Forgerock to run as authorization server. For more details refer to here. Both of them provide similar IAM features, such as authentication, authorization, single sign-on (SSO), social media logins, multi-factor authentication (MFA), and user self-service. However, the specific implementations and capabilities may differ between the two solutions. ForgeRock is known for its comprehensive and enterprise-ready IAM platform with extensive customization and integration options, while KeyCloak is known for its simplicity and ease of use, making it popular for smaller-scale deployments and developer-friendly use cases. Keycloak has an active community of users and contributors but does not offer commercial support options.

In this case, we use keycloak as an authorization server

docker-compose.yaml file

version: '3.8'
services:
keycloak:
image: jboss/keycloak
restart: always
environment:
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: admin
ports:
- "8080:8080"
networks:
backend:
aliases:
- "keycloak"
volumes:
#- keycloak_data:/data
- keycloak_standalone_data:/opt/jboss/keycloak/standalone/data/
- keycloak_data:/opt/keycloak/data/

volumes:
db:
driver: local
keycloak_data:
keycloak_standalone_data:


networks:
backend:
driver: bridge

volume mapping will store the change in local volume.

start:

docker-compose up

Configuration

http://localhost:8080/auth/

click administration console and username/password is admin/admin

  • Add Realm

test the realm

curl http://localhost:8080/auth/realms/SpringBootKeycloak
{"realm":"SpringBootKeycloak","public_key":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbGRyZ+LkEykTBzKzqCwLxYYlkzipAGfGzlm58L2rhAlMBgBYakwHyZGMXhzA3mNO6oSlKb2NziWC5pHjFwyrt5rOIxPJK5M0zGSORPVfeQ7okOjX0ja6ozSjXXdLr8Zg3Xo/TRlYS5IzZCtyX/jmtR92wRwSTJnWhFcaenqs22A0Cle94/mK6nyMJvLkb/YWdpeeEQZekFVQ7QYhKmAWD91YkVQxh+tLkapAHh9yTvBI3fRfpzYTJKFpV5wBBUy6qbBksHWBw8EHdaOrJZ6QXcnChCNb/VNLyYtKoclS/JmW5tkLj2slPy4CA20P2qY87qykzuq+8BSC/5FDv33FwIDAQAB","token-service":"http://localhost:8080/auth/realms/SpringBootKeycloak/protocol/openid-connect","account-service":"http://localhost:8080/auth/realms/SpringBootKeycloak/account","tokens-not-before":0}

switch to the newly created realm

  • Create Client

will show =>

  • Create user user1

=>

set password to pass1

remove required user action update password

  • Create Role

I created a role name “user

assign the role

  • get configuration

http://localhost:8080/auth/realms/SpringBootKeycloak/.well-known/openid-configuration

in this example we mainly use the token_endpoint

Resource Server

I use SpringBoot to implement a Resource Server application.

Security Configuration

The KeyCloak adapter is not used in Spring boot 3, see this =>

and this =>

enable OAuth2 resource server in SecurityConfig file:

http.oauth2ResourceServer {
oauth2: OAuth2ResourceServerConfigurer<HttpSecurity?> ->
oauth2.jwt(
Customizer { jwt ->
jwt.jwtAuthenticationConverter(
keycloakJwtTokenConverter // use custom converter
)
})
}

add dependency to the project:

implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
testImplementation("org.springframework.boot:spring-boot-starter-test")

KeyCloak converter

KeyCloak stores the role information in this structure. We need a KeyCloak converter to extract the roles information from JWTheader:

code

override fun convert(@NonNull jwt: Jwt): JwtAuthenticationToken {
val accesses = Optional.of<Jwt>(jwt)
.map { token: Jwt -> token.getClaimAsMap(KEYCLOAK_RESOURCE_ACCESS) } // return Map<String, Any>
.map { claimMap: Map<String, Any> -> claimMap[RESOURCE_ID] as Map<String?, Any?>? } // return Map<String?, Any?>?
.map { resourceData: Map<String?, Any?>? -> resourceData!![KEYCLOAK_ROLES] as Collection<String?>? } // return Collection<String?>?
// it is arrayListOf("manage-account","manage-account-links","view-profile")
.stream().flatMap { x->x?.stream() } // flatMap the child:ArrayList<String>
.map { role ->
SimpleGrantedAuthority(
KEYCLOAK_ROLE_PREFIX + role
)
}

// extract realm_access as well
val realm = Optional.of<Jwt>(jwt)
.map<Map<String?, Any?>> { token: Jwt -> token.getClaimAsMap( KEYCLOAK_REALM_ACCESS) as Map<String?, Any?>?}
.map<Collection<String?>?> { resourceData: Map<String?, Any?>? -> resourceData!![KEYCLOAK_ROLES] as Collection<String?>? } // arrayListOf("manage-account","manage-account-links","view-profile")
.stream().flatMap { x->x.stream() }
.map { role ->
SimpleGrantedAuthority(
KEYCLOAK_ROLE_PREFIX + role
)
}

// concat the 3 streams
val authorities = Stream
.concat(
Stream.concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), accesses),
realm
)
.collect(Collectors.toSet())

val principalClaimName: String = jwt.getClaimAsString(PRINCIPAL_ATTR) ?:jwt.getClaimAsString(JwtClaimNames.SUB)

return JwtAuthenticationToken(jwt, authorities, principalClaimName)
}

At the end we should get these, “ROLE_user” is the role we assigned for the client/user in the KeyCloak console. ROLE_ is the prefix and user is the role assigned in the KeyCloak console.

and this is a JWT claims example

// an example
"sub" -> "dcbfb403-75cf-4388-8c6a-97d642964239"
"resource_access" -> {LinkedTreeMap@8450} size = 1
"email_verified" -> {Boolean@8452} false
"iss" -> "http://localhost:8080/auth/realms/SpringBootKeycloak"
"typ" -> "Bearer"
"preferred_username" -> "user1"
"sid" -> "ff8bfcdd-1d91-4773-9c84-9b0a12dcd5a2"
"aud" -> {ArrayList@8462} size = 1
"acr" -> "1"
"realm_access" -> {LinkedTreeMap@8466} size = 1
"azp" -> "login-app"
"scope" -> "email profile"
"exp" -> {Instant@8426} "2024-01-28T15:04:17Z"
"session_state" -> "ff8bfcdd-1d91-4773-9c84-9b0a12dcd5a2"
"iat" -> {Instant@8425} "2024-01-28T14:59:17Z"
"jti" -> "1d73943b-cc38-4a8d-989f-f120caa59ce0"

and in the Security Configuration:

http.oauth2ResourceServer {
oauth2: OAuth2ResourceServerConfigurer<HttpSecurity?> ->
oauth2.jwt(
Customizer { jwt ->
jwt.jwtAuthenticationConverter(
keycloakJwtTokenConverter // use custom converter
)
})
}

Configured Authorization server url:

spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/auth/realms/SpringBootKeycloak

If we need to start it independently, then we can supply the jwk-set-uri property instead to point to the authorization server’s endpoint exposing public keys:

jwk-set-uri: http://localhost:8080/auth/realms/SpringBootKeycloak/protocol/openid-connect/certs

Testing

  • get access token from the KeyCloak server:

or

curl --request POST --url http://localhost:8080/auth/realms/SpringBootKeycloak/protocol/openid-connect/token?= --header "Content-Type: application/x-www-form-urlencoded" --data client_id=login-app  --data username=user1  --data password=pass1  --data grant_type=password
# return token =>
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJEQzA3ZTZGSVF5NzVqZFdXOGdNNmM5M1BDNjk5OEpNZVJwS0dfaE1JZU1vIn0.eyJleHAiOjE3MDY5NzAyODAsImlhdCI6MTcwNjk2OTk4MCwianRpIjoiZTlkOGQ2M2QtYTljMy00NTBmLThiMGYtMTI0YmYxMWI3YWYxIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL1NwcmluZ0Jvb3RLZXljbG9hayIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiJkY2JmYjQwMy03NWNmLTQzODgtOGM2YS05N2Q2NDI5NjQyMzkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJsb2dpbi1hcHAiLCJzZXNzaW9uX3N0YXRlIjoiM2RmYjEzNDEtNzJlMS00ZTkzLWIyM2MtYWRkYWNjZDAwM2FlIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsImRlZmF1bHQtcm9sZXMtc3ByaW5nYm9vdGtleWNsb2FrIiwidW1hX2F1dGhvcml6YXRpb24iLCJ1c2VyIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiM2RmYjEzNDEtNzJlMS00ZTkzLWIyM2MtYWRkYWNjZDAwM2FlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ1c2VyMSJ9.OT3do4T0vJo70oGK42Cw8TLej6y_wlCV2VsdlZlKsIZTw0ql5WGigq0YktMP80pUBc14dXbsd10g7PPGUoVqLDTZk-lCaskGsETjUwihyHU1pUbQE9-OARkUfeyiG__JDXOwe-KepyHL9ZKZ_8UL9VYVvm63PtWQfj3_CTW3jfgpynMSa3mnB8QBhDv23fW-VLvXpK7Ab1EcPaYW75J2pFtYb7W7dx73SCLKzEr7cxPJyEpb9D1iz0XuKW2F8l-0LwrIWyGvEaf81bQ9dg-RC_CmhjcGvDHDcP1JFG5G9ad-cZDlgBgDTM2wM8Pw8uR7ZwPnn5fJ9bFq3Iz9zcd_sQ",
"expires_in": 299,
"refresh_expires_in": 1799,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzMzM0YTg3YS0xZTRiLTQxOTQtYTVhZi00NjRmMTNhZDBhOGUifQ.eyJleHAiOjE3MDY5NzE3ODAsImlhdCI6MTcwNjk2OTk4MCwianRpIjoiMzhiZGJjZTgtMTEzOC00MDVlLWEyMzUtOGY0YTM0NmZiNGNmIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL1NwcmluZ0Jvb3RLZXljbG9hayIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hdXRoL3JlYWxtcy9TcHJpbmdCb290S2V5Y2xvYWsiLCJzdWIiOiJkY2JmYjQwMy03NWNmLTQzODgtOGM2YS05N2Q2NDI5NjQyMzkiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoibG9naW4tYXBwIiwic2Vzc2lvbl9zdGF0ZSI6IjNkZmIxMzQxLTcyZTEtNGU5My1iMjNjLWFkZGFjY2QwMDNhZSIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6IjNkZmIxMzQxLTcyZTEtNGU5My1iMjNjLWFkZGFjY2QwMDNhZSJ9.wkYEhoi9bSlTT6HQ4R8eIOoj8L2BJc1f4oKHWAXkt8E",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "3dfb1341-72e1-4e93-b23c-addaccd003ae",
"scope": "email profile"
}
  • access the public endpoint without any token
curl http://localhost:8081/public/api1
# =>
public api1
  • accesse private endpoint with token
# put your actual token return from keycloak
curl --request GET --url http://localhost:8081/private/api1 --header "Authorization: Bearer you_token_from_keycloak"
# =>
return private api1
  • accesse the private endpoint without token
curl -i http://localhost:8081/private/api1
# => return
HTTP/1.1 401
Set-Cookie: JSESSIONID=DF74D9FAD79621451E27977506C0F0F7; Path=/; HttpOnly
WWW-Authenticate: Bearer
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-Length: 0
Date: Sat, 03 Feb 2024 14:33:25 GMT

or with insufficient role permission (/private/admin need “admin” role)

>curl -i --request GET --url http://localhost:8081/private/admin --header "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJEQzA3ZTZGSVF5NzVqZFdXOGdNNmM5M1BDNjk5OEpNZVJwS0dfaE1JZU1vIn0.eyJleHAiOjE3MDY5NzQ2MjEsImlhdCI6MTcwNjk3NDMyMSwianRpIjoiMzliYzIwZDQtOWM1Yi00YjVjLWFkYzctY2RmNTc5MmUxMDRmIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL1NwcmluZ0Jvb3RLZXljbG9hayIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiJkY2JmYjQwMy03NWNmLTQzODgtOGM2YS05N2Q2NDI5NjQyMzkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJsb2dpbi1hcHAiLCJzZXNzaW9uX3N0YXRlIjoiNDZkNWRlNmMtMjZiYy00MjI4LWEyZDQtM2MxM2U3NzFiNGIyIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsImRlZmF1bHQtcm9sZXMtc3ByaW5nYm9vdGtleWNsb2FrIiwidW1hX2F1dGhvcml6YXRpb24iLCJ1c2VyIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiNDZkNWRlNmMtMjZiYy00MjI4LWEyZDQtM2MxM2U3NzFiNGIyIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ1c2VyMSJ9.IrZaC_IqmXxT9GV6mAMw0YXInN6Dge9iRaF7NUiErNt5zKk_7tPGVr5s4Z1RdctSk-Xv7S9XW9JmSpSnttmlxGuE_G3bhED1rCEpuVp5674nOghoq-80gbVmQC4rl9Uk0JIgk6JDFB03uC4Lg6tDuAUYfnJ3QbuPUsVc97Dml-5dPKoVfP7Q-D6NvzvO87EtBm5Io6evZkDE30bccmpBRLUtb4dA5DexCV4xEtFxDwSJmoCBwb8Bs_0CuxT7ZFw1C7QZJvFEbppNMtAf7YiUwVPW_pMNAKxCJP47GZrSvhzz41PCE7tYrUEoXiPKRPp1WRN55de5p8VJE2l8_TMGCA"
# return =>
HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
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-Length: 0
Date: Sat, 03 Feb 2024 15:32:30 GMT

Reference

Happy Coding!

--

--