OAuth2 — write a resource server with KeyCloak and Spring Security
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.
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
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 JWT
header:
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
- https://dzone.com/articles/spring-boot-3-keycloak (src code: https://github.com/Pask423/keycloak-springboot/tree/master/base-integration-spring-boot-3)
- https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html
- https://www.baeldung.com/spring-boot-keycloak
- https://www.keycloak.org/2023/03/adapter-deprecation-update
- https://www.keycloak.org/2022/02/adapter-deprecation.html
- https://www.baeldung.com/spring-security-method-security
- https://github.com/softarts/resource-server (source code)
Happy Coding!