Bài Basics 35 biến catalogue thành issuer của chính nó: POST /api/auth/login kiểm tra mật khẩu, một NimbusJwtEncoder ký token RS256 sống 15 phút bằng private key trong src/main/resources/certs, và cùng application đó verify token bằng oauth2ResourceServer. Cách này ổn khi chỉ có một application với một bảng user. Khi có thêm application thứ hai, một mobile client hay một dịch vụ của đối tác cần cùng tập user, mỗi bên đều phải kiểm tra mật khẩu và giữ signing key. OAuth2 và OpenID Connect chuyển cả hai sang một authorization server riêng: các application nhận token từ nó và không bao giờ thấy mật khẩu.
Bài này nối hai application Spring Boot với Keycloak: một web client cho user login bằng OAuth2 Login, và cũng làm được y như vậy với Google hay GitHub, cùng một API nhận JWT của Keycloak với vai trò resource server. Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với Keycloak 26 chạy ở dev mode. Web client chạy ở port 8213, API ở 9213 và Keycloak ở 8313, nên đó là các port bạn sẽ thấy trong các lệnh và redirect bên dưới.
![]()
Phần lab đi trước, sau đó là các thuật ngữ gắn vào lab. Luồng login được chạy qua web client bằng curl, rồi API validate chính các token đó, và các phần cuối nối hai application lại với nhau.
Lab: Keycloak 26.7.4 và hai application Spring Boot
Hai project và các starter OAuth2
Web client và API là hai project Initializr riêng, tạo từ thư mục gốc của lab:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=web&name=web&packageName=com.example.web&dependencies=web,security,oauth2-client" -o web.zip
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=api&name=api&packageName=com.example.api&dependencies=web,security,oauth2-resource-server" -o api.zipId oauth2-client ghi dòng này vào web client, bên cạnh starter security, starter web và một starter -test tương ứng:
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client'<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>API nhận spring-boot-starter-security-oauth2-resource-server, đúng starter mà bài Basics 35 đã dùng. BOM của Boot 4.1.1 vẫn quản lý các tên của Boot 3 là spring-boot-starter-oauth2-client và spring-boot-starter-oauth2-resource-server, và POM 4.1.1 của chúng khai báo đúng các dependency như starter mới. Phần mô tả của chúng nói rõ chúng là gì:
curl -s https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-oauth2-client/4.1.1/spring-boot-starter-oauth2-client-4.1.1.pom | grep '<description>' <description>Starter for using Spring Security's OAuth2/OpenID Connect client features (deprecated in favor of spring-boot-starter-security-oauth2-client)</description>Starter client kéo theo spring-security-oauth2-client 7.1.1, spring-security-oauth2-jose 7.1.1 và oauth2-oidc-sdk 11.38.2 của Nimbus. Các tutorial dựa trên WebSecurityConfigurerAdapter, trên @EnableResourceServer của project spring-security-oauth2 đã ngừng phát triển, hay trên adapter Spring riêng của Keycloak đều không áp dụng được: Spring Security 6 đã xoá WebSecurityConfigurerAdapter, còn keycloak-spring-security-adapter, với KeycloakWebSecurityConfigurerAdapter extend class đó, đã deprecated và bản phát hành cuối trên Maven Central là 25.0.3.
Realm import từ file JSON
Realm là một file, nên có thể dựng lại setup bất cứ lúc nào. catalogue chứa ba client, một realm role và hai user:
{
"realm": "catalogue",
"enabled": true,
"roles": {
"realm": [ { "name": "ADMIN", "description": "Catalogue administrator" } ]
},
"users": [
{
"id": "0140b1ce-67b1-432d-af8e-f021ea7cea40",
"username": "alice",
"enabled": true,
"email": "alice@example.com",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Liddell",
"credentials": [ { "type": "password", "value": "Wonderland-2026", "temporary": false } ]
},
{
"id": "dfc3c0df-f01c-43c3-a4a5-a212699bbe09",
"username": "bob",
"enabled": true,
"email": "bob@example.com",
"emailVerified": true,
"firstName": "Bob",
"lastName": "Builder",
"credentials": [ { "type": "password", "value": "Builder-2026", "temporary": false } ],
"realmRoles": [ "ADMIN" ]
}
],
"clients": [
{
"clientId": "web-client",
"secret": "web-client-secret",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [ "http://localhost:8213/login/oauth2/code/keycloak" ],
"attributes": { "post.logout.redirect.uris": "http://localhost:8213/" },
"protocolMappers": [
{
"name": "catalogue-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "catalogue-api", "access.token.claim": "true" }
}
]
},
{
"clientId": "reporting-service",
"secret": "reporting-service-secret",
"publicClient": false,
"standardFlowEnabled": false,
"serviceAccountsEnabled": true,
"protocolMappers": [
{
"name": "catalogue-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "catalogue-api", "access.token.claim": "true" }
}
]
},
{
"clientId": "partner-service",
"secret": "partner-service-secret",
"publicClient": false,
"standardFlowEnabled": false,
"serviceAccountsEnabled": true
}
]
}web-clientlà confidential client: nó có secret, được dùng authorization code flow, và Keycloak chỉ redirect về đúng một callback URL được liệt kê.post.logout.redirect.urislà địa chỉ Keycloak được phép đưa browser về sau khi logout.- Audience mapper thêm
catalogue-apivào claimaudcủa các access token cấp choweb-clientvàreporting-service. Sau này API sẽ kiểm tra giá trị đó;partner-servicekhông có mapper nên token của nó trượt ở bước kiểm tra ấy. reporting-servicechỉ có service account: nó tự lấy token cho mình bằng client credentials grant, không có user nào tham gia.idcủa user được cố định để claimsubgiống nhau ở mọi lần import.bobcó realm roleADMIN;alicekhông có role nào.- User được import nhận đúng các realm role được liệt kê. Role
default-roles-cataloguecủa Keycloak không được gán cho alice hay bob, nên token của alice hoàn toàn không có claimrealm_access, trong khi các service account do Keycloak tự tạo thì nhận các role mặc định, như một token giải mã ở phần sau cho thấy.
Secret và mật khẩu là giá trị dùng cho lab. File thứ hai import realm other với client reporting-service riêng của nó, dùng ở phần sau để có một token từ issuer khác:
{
"realm": "other",
"enabled": true,
"clients": [
{
"clientId": "reporting-service",
"secret": "reporting-service-secret",
"publicClient": false,
"standardFlowEnabled": false,
"serviceAccountsEnabled": true,
"protocolMappers": [
{
"name": "catalogue-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "catalogue-api", "access.token.claim": "true" }
}
]
}
]
}Chạy Keycloak ở dev mode
docker run -d --name sba-a13-keycloak --memory 1536m -p 8313:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$PWD/keycloak:/opt/keycloak/data/import:ro" \
quay.io/keycloak/keycloak:26.7.4 start-dev --import-realm --http-access-log-enabled=truestart-devchạy qua HTTP thường với một database H2 nằm trong container, nên không cần database riêng.--import-realmđọc mọi file trong/opt/keycloak/data/import.--http-access-log-enabled=trueghi log mọi request Keycloak nhận được; các phần sau dùng nó để đếm số lần các application gọi tới.- Bootstrap admin thuộc về realm
mastervà admin console, thứ mà bài 14 dùng tới. Bài này không cần đến nó.
INFO [org.keycloak.exportimport.dir.DirImportProvider] (main) Importing from directory /opt/keycloak/bin/../data/import
INFO [org.keycloak.exportimport.util.ImportUtils] (main) Realm 'catalogue' imported
INFO [org.keycloak.exportimport.util.ImportUtils] (main) Realm 'other' imported
INFO [org.keycloak.services] (main) KC-SERVICES0032: Import finished successfully
INFO [io.quarkus] (main) Keycloak 26.7.4 on JVM (powered by Quarkus 3.33.3.2) started in 6.414s. Listening on: http://0.0.0.0:8080Việc import chạy với strategy IGNORE_EXISTING: sau một lần docker restart, log ghi Realm 'catalogue' already exists. Import skipped. Vì vậy khi file realm thay đổi thì cần một container mới (docker rm -f sba-a13-keycloak rồi chạy lại đúng lệnh docker run), việc này cũng sinh ra signing key mới.
Discovery document
Mọi OpenID Provider công bố các endpoint của mình ở một path cố định dưới issuer:
curl -s http://localhost:8313/realms/catalogue/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, end_session_endpoint, jwks_uri, grant_types_supported, code_challenge_methods_supported}'{
"issuer": "http://localhost:8313/realms/catalogue",
"authorization_endpoint": "http://localhost:8313/realms/catalogue/protocol/openid-connect/auth",
"token_endpoint": "http://localhost:8313/realms/catalogue/protocol/openid-connect/token",
"userinfo_endpoint": "http://localhost:8313/realms/catalogue/protocol/openid-connect/userinfo",
"end_session_endpoint": "http://localhost:8313/realms/catalogue/protocol/openid-connect/logout",
"jwks_uri": "http://localhost:8313/realms/catalogue/protocol/openid-connect/certs",
"grant_types_supported": [
"authorization_code",
"client_credentials",
"implicit",
"password",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:uma-ticket",
"urn:openid:params:grant-type:ciba"
],
"code_challenge_methods_supported": [
"plain",
"S256"
]
}Document đầy đủ có 56 member. Cả hai application chỉ cần một property là URL của issuer; Spring Security đọc phần còn lại từ đây. jwks_uri trả về hai public key: một key có "use": "sig" và "alg": "RS256" dùng để ký token, và một key có "use": "enc" với "alg": "RSA-OAEP" dành cho mã hoá, không token nào ở đây dùng tới.
Thuật ngữ OAuth2 và OpenID Connect, gắn vào lab
| Vai trò trong spec | Trong lab này | Nắm giữ gì |
|---|---|---|
| Resource owner | hai user alice và bob | một mật khẩu mà chỉ Keycloak kiểm tra |
| Client | web client ở 8213, đăng ký là web-client; danh tính dịch vụ reporting-service | một client secret, và một session cho mỗi user đã login |
| Authorization server, trong OIDC gọi là OpenID Provider | Keycloak, realm catalogue, issuer http://localhost:8313/realms/catalogue | user, client, role và các signing key |
| Resource server | API ở 9213 | URL của issuer và audience nó chờ đợi, không biết gì về user |
- Authorization code grant là cách user login vào client. Browser chỉ mang một authorization code dùng một lần; client đổi nó lấy token qua một lời gọi back-channel trực tiếp, xác thực bằng secret của mình.
- PKCE (Proof Key for Code Exchange, RFC 7636) buộc authorization code đó vào một verifier ngẫu nhiên mà chỉ client biết, nên một code lấy được từ URL không dùng được một mình.
- OpenID Connect là một lớp nằm trên OAuth2. Xin scope
openidsẽ có thêm ID token, một JWT dành cho client cho biết ai vừa login, cùng với userinfo endpoint, discovery và logout. - Access token dành cho API. Nó cho biết client nào đang hành động, thay cho user nào và với những scope nào, và client chỉ chuyển tiếp nó mà không đọc.
- Client credentials grant cho client một token của chính nó, không có user.
OAuth2 Login với Keycloak
Đăng ký Keycloak bằng issuer-uri
spring.application.name=web
server.port=8213
logging.pattern.console=%logger{0}: %msg%n
spring.security.oauth2.client.registration.keycloak.client-id=web-client
spring.security.oauth2.client.registration.keycloak.client-secret=web-client-secret
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8313/realms/cataloguespring:
application:
name: web
security:
oauth2:
client:
registration:
keycloak:
client-id: web-client
client-secret: web-client-secret
scope: openid,profile,email
provider:
keycloak:
issuer-uri: http://localhost:8313/realms/catalogue
server:
port: 8213
logging:
pattern:
console: '%logger{0}: %msg%n'keycloaklà registration id. Nó xuất hiện trong hai path mà Spring Security phục vụ:/oauth2/authorization/keycloakbắt đầu một lần login, và/login/oauth2/code/keycloaknhận authorization code, theo template redirect URI mặc định{baseUrl}/{action}/oauth2/code/{registrationId}.client-idvàclient-secretlà các giá trị trong file realm.scopephải chứaopenidđể có một lần login OpenID Connect kèm ID token.issuer-urithay cho bốn URL endpoint, Spring Security lấy chúng từ discovery document.
Chain vẫn là SecurityFilterChain như bài Basics 33, chỉ có oauth2Login thay cho form login:
package com.example.web.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}Một controller cho thấy application biết gì về user sau khi login:
package com.example.web.account;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AccountController {
@GetMapping("/")
public String home() {
return "Catalogue web client";
}
@GetMapping("/me")
public AccountResponse me(@AuthenticationPrincipal OidcUser user, Authentication authentication) {
return new AccountResponse(
user.getClass().getSimpleName(),
authentication.getName(),
user.getClaims(),
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList());
}
@GetMapping("/admin")
public String admin(Authentication authentication) {
return "Admin area for " + authentication.getName();
}
}package com.example.web.account;
import java.util.List;
import java.util.Map;
public record AccountResponse(String principal, String name, Map<String, Object> claims, List<String> authorities) {
}Khi bật DEBUG cho DefaultSecurityFilterChain, log khởi động liệt kê các filter mà oauth2Login thêm vào:
DefaultSecurityFilterChain: Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilterOAuth2AuthorizationRequestRedirectFilter trả lời /oauth2/authorization/{registrationId} bằng redirect sang Keycloak, còn OAuth2LoginAuthenticationFilter xử lý callback. Session và CsrfFilter vẫn ở đó: đây là một application cho browser.
issuer-uri làm gì lúc khởi động
Access log của Keycloak trong lúc web client khởi động chứa đúng một request:
"GET /realms/catalogue/.well-known/openid-configuration HTTP/1.1" 200 6628Spring Boot resolve issuer trong lúc dựng InMemoryClientRegistrationRepository, mỗi registration một lần: sau khi thêm registration thứ hai ở phần sau, lúc khởi động có hai request như vậy. Nó không tải key. Lời gọi này chạy ngay lập tức, nên application phụ thuộc vào Keycloak để khởi động được. Khi container đã dừng, java -jar thoát với status 1; phần cuối của chuỗi nguyên nhân:
Caused by: java.lang.IllegalArgumentException: Unable to resolve Configuration with the provided Issuer of "http://localhost:8313/realms/catalogue"
Caused by: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8313/realms/catalogue/.well-known/openid-configuration": Connection refused
Caused by: java.net.ConnectException: Connection refusedException ngoài cùng là một UnsatisfiedDependencyException cho bean securityFilterChain, đi qua một bean tên OAuth2AuthorizedClientManager mà Spring Security đăng ký cho client. Với Docker Compose hay Kubernetes, hãy khởi động web client sau khi Keycloak đã healthy, hoặc khai báo endpoint cho provider một cách tường minh (authorization-uri, token-uri, jwk-set-uri, user-info-uri) thay vì issuer-uri.
Luồng login, từng bước bằng curl
Browser đi theo redirect mà không cho thấy chúng. Script này đóng vai browser với một cookie jar và dừng lại ở từng bước, lưu mọi response:
#!/bin/zsh
# usage: flow.sh <username> <password> <dir>
# The OAuth2 login of the web client, driven with curl one hop at a time; every response is saved.
USER_NAME=$1; PASSWORD=$2; DIR=$3
mkdir -p $DIR; JAR=$DIR/jar; rm -f $JAR
loc() { grep -i '^Location:' $1 | sed 's/^Location: //I' | tr -d '\r'; }
curl -s -i -c $JAR -b $JAR http://localhost:8213/me > $DIR/1-me.txt
curl -s -i -c $JAR -b $JAR "$(loc $DIR/1-me.txt)" > $DIR/2-authorization.txt
curl -s -i -c $JAR -b $JAR "$(loc $DIR/2-authorization.txt)" > $DIR/3-keycloak-login-page.txt
ACTION=$(grep -o 'action="[^"]*"' $DIR/3-keycloak-login-page.txt | head -1 | sed 's/action="//; s/"$//; s/&/\&/g')
curl -s -i -c $JAR -b $JAR --data-urlencode "username=$USER_NAME" --data-urlencode "password=$PASSWORD" \
--data-urlencode credentialId= "$ACTION" > $DIR/4-keycloak-login-post.txt
curl -s -i -c $JAR -b $JAR "$(loc $DIR/4-keycloak-login-post.txt)" > $DIR/5-callback.txt
curl -s -i -c $JAR -b $JAR "$(loc $DIR/5-callback.txt)" > $DIR/6-me.txt
for f in $DIR/[1-6]-*.txt; do echo "$(basename $f): $(head -1 $f | tr -d '\r') $(loc $f | cut -c1-120)"; done./flow.sh alice Wonderland-2026 out/flowCác response dưới đây là của alice, bỏ bớt những security header mà bài Basics 33 đã đưa đầy đủ. Bước 1, trang cần bảo vệ:
HTTP/1.1 302
Set-Cookie: JSESSIONID=98FDE59B4FF470163378DA265D1D9CDF; Path=/; HttpOnly
Location: http://localhost:8213/oauth2/authorization/keycloakExceptionTranslationFilter lưu request tới /me vào session mới và đưa browser tới login entry point. Khi chỉ có một registration dùng authorization code grant, đó là path authorization của registration ấy chứ không phải một trang login.
Bước 2, authorization request:
HTTP/1.1 302
Location: http://localhost:8313/realms/catalogue/protocol/openid-connect/auth?response_type=code&client_id=web-client&scope=openid%20profile%20email&state=-F09KmWBnUjvu5nRLnx0OECqanJf95azinS4-aRyql0%3D&redirect_uri=http://localhost:8213/login/oauth2/code/keycloak&nonce=qXI1MCVMcR2tIqxkQqKGqelhbfv6ieuZhwMLPEx8jQ0&code_challenge=8UTXb4PtdQDFCF9k8YRjo1TkYa_TIBq34Xgcb2aZ82Y&code_challenge_method=S256| Parameter | Giá trị | Mục đích |
|---|---|---|
response_type | code | xin một authorization code |
client_id | web-client | client nào đang xin |
scope | openid profile email | openid biến request thành một lần login OpenID Connect |
state | -F09KmWB…yql0= | giá trị ngẫu nhiên, lưu trong session, so khớp ở callback để chống cross-site request forgery |
redirect_uri | http://localhost:8213/login/oauth2/code/keycloak | phải trùng một URI đã đăng ký cho client |
nonce | qXI1MCVM…8jQ0 | giá trị ngẫu nhiên phải quay lại bên trong ID token, chống replay token |
code_challenge | 8UTXb4Pt…Z82Y | SHA-256 của một code_verifier ngẫu nhiên, mã hoá Base64URL |
code_challenge_method | S256 | cách tạo ra challenge |
OAuth2AuthorizationRequestRedirectFilter lưu toàn bộ request, kể cả code_verifier, vào HTTP session; không có gì bí mật rời khỏi server.
Bước 3, trang login của Keycloak, 200 OK với <title>Sign in to catalogue. Nó đặt AUTH_SESSION_ID, KC_AUTH_SESSION_HASH và KC_RESTART, đều với Path=/realms/catalogue/, và form của nó post tới:
http://localhost:8313/realms/catalogue/login-actions/authenticate?session_code=0pbpKaQoaj15n8Vo-x0VKkUFMiWP2qcvcxQdGN2yVjY&execution=d3cc8a17-39aa-4282-b845-7ce18fae3084&client_id=web-client&tab_id=2QxW60S8ymE&client_data=eyJydSI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODIxMy9sb2dpbi9vYXV0aDIvY29kZS9rZXljbG9hayIsInJ0IjoiY29kZSIsInN0IjoiLUYwOUttV0JuVWp2dTVuUkxueDBPRUNxYW5KZjk1YXppblM0LWFSeXFsMD0ifQMật khẩu đi tới trang của chính Keycloak, không bao giờ tới web client.
Bước 4, post form với username, password và credentialId rỗng:
HTTP/1.1 302 Found
Set-Cookie: KEYCLOAK_IDENTITY=eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2NmM4YjlmZC0wZTliLTQxZWQtOGZjMy03MjllYWZiMzY3MDYifQ…;Version=1;Path=/realms/catalogue/;Secure;HttpOnly;SameSite=None
Set-Cookie: KEYCLOAK_SESSION=9WKq1MeUcea8kHBNYkf1-ZMaZLwSjOlwTHYN5jTpP6t5u3-BqWzqwqzV7v2tISM9;Version=1;Path=/realms/catalogue/;Max-Age=36000;Secure;SameSite=None
Location: http://localhost:8213/login/oauth2/code/keycloak?state=-F09KmWBnUjvu5nRLnx0OECqanJf95azinS4-aRyql0%3D&session_state=GZM2n7fhTBuMOXZ38iSLEVWW&iss=http%3A%2F%2Flocalhost%3A8313%2Frealms%2Fcatalogue&code=b1d8170d-d742-9150-c542-f4df81d6c986.GZM2n7fhTBuMOXZ38iSLEVWW.09c03f81-03c2-48d1-8478-b40f62afe290Callback mang đúng state cũ, code, session_state của Keycloak và iss, tức issuer theo RFC 9207 để một client làm việc với nhiều provider biết provider nào đã trả lời. Hai cookie kia là single sign-on session của Keycloak cho realm này.
Bước 5, callback về web client:
HTTP/1.1 302
Set-Cookie: JSESSIONID=DBD16E3F828AA2D5C590FD0D9FABF48C; Path=/; HttpOnly
Location: http://localhost:8213/me?continueTrước khi trả lời, web client tự gọi Keycloak ba lần. Access log của Keycloak trong giây đó:
"POST /realms/catalogue/protocol/openid-connect/token HTTP/2" 200 -
"GET /realms/catalogue/protocol/openid-connect/certs HTTP/1.1" 200 2933
"GET /realms/catalogue/protocol/openid-connect/userinfo HTTP/1.1" 200 193POST …/tokenđổi authorization code,code_verifiervà client secret lấy một access token, một ID token và một refresh token.GET …/certstải các public key để verify chữ ký của ID token, trước khi kiểm traiss,aud,expvànoncecủa nó.GET …/userinfotải các claim của user bằng access token. Ở 7.1.1,OidcUserRequestUtils.shouldRetrieveUserInfochỉ hỏi provider có userinfo endpoint hay không và grant có phảiauthorization_codehay không;OidcUserServicecủa Spring Security 6.5 vẫn còn một tậpaccessibleScopesgồmprofile,email,addressvàphonecho quyết định đó.
Session id đổi từ 98FDE59B… sang DBD16E3F…: cơ chế chống session fixation, như sau mọi lần login. Redirect quay về request đã lưu, /me, kèm dấu continue của request cache.
Bước 6, GET /me?continue, trả 200 với JSON mà phần về OidcUser sẽ mổ xẻ.

PKCE có bật cho confidential client không?
Có. web-client xác thực bằng secret, vậy mà Spring Security 7.1.1 vẫn gửi code_challenge và code_challenge_method=S256 dù không cấu hình gì, với cả Google và GitHub, như một phần sau cho thấy. Verifier bảo vệ authorization code ngay cả trước người biết luôn secret. Để thấy điều đó, bốn bước đầu của một lần login được chạy mà không có bước thứ năm, và code trong header Location được gửi thẳng tới token endpoint, kèm secret của client nhưng không có verifier:
curl -s -i -u web-client:web-client-secret -d grant_type=authorization_code -d "code=$CODE" \
-d redirect_uri=http://localhost:8213/login/oauth2/code/keycloak \
http://localhost:8313/realms/catalogue/protocol/openid-connect/tokenHTTP/1.1 400 Bad Request
{"error":"invalid_grant","error_description":"PKCE code verifier not specified"}Keycloak ghi log type="CODE_TO_TOKEN_ERROR" với error="code_verifier_missing". Nó cũng tính lần thử đó là một lần dùng code: khi browser sau đó giao đúng code ấy cho web client, Keycloak ghi Code '3c1ac8dd-a3cf-351e-a1e6-4da78fbc1880' already used, và web client trả 302 tới /login?error. Một authorization code bị chặn trên đường quay về là vô dụng, và lần thử đó khiến user thật mất lượt login này.
Browser không bao giờ giữ token
Sau khi login, cookie jar của curl chứa các cookie sau, theo tên và path:
| Cookie | Path | Do ai đặt |
|---|---|---|
JSESSIONID | / | web client, HttpOnly |
AUTH_SESSION_ID, KC_AUTH_SESSION_HASH | /realms/catalogue/ | Keycloak |
KEYCLOAK_IDENTITY, KEYCLOAK_SESSION | /realms/catalogue/ | Keycloak |
Bản thân các token nằm trong session của web client. Với một lần login của bob, chữ ký của access token và của ID token, lấy từ log của web client, được tìm trong cả sáu response đã lưu và trong cookie jar: mỗi cái 0 kết quả. KEYCLOAK_IDENTITY trông như một JWT, nhưng header của nó là {"alg":"HS512","typ" : "JWT",…} và payload ghi "typ":"Serialized-ID": một session cookie ký bằng HMAC mà chỉ Keycloak verify được, sống 36.000 giây, tức single sign-on session mười tiếng. Nó chỉ được gửi tới các path của Keycloak, và chính nó giúp Keycloak nhận ra user lần sau, điều quan trọng khi logout.
ID token và access token: hai JWT mà Keycloak cấp
Để nhìn vào bên trong, một controller chỉ dùng cho lab trong web client ghi cả hai token ra log của server. Nó không trả gì cho browser, và không thuộc về một application thật:
package com.example.web.lab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
// Lab only: writes the tokens to the server log so they can be decoded; never ship this.
@RestController
public class TokenLogController {
private static final Logger log = LoggerFactory.getLogger(TokenLogController.class);
@GetMapping("/lab/tokens")
public ResponseEntity<Void> logTokens(@AuthenticationPrincipal OidcUser user,
@RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client) {
log.info("id_token={}", user.getIdToken().getTokenValue());
log.info("access_token={}", client.getAccessToken().getTokenValue());
log.info("access_token scopes={} expiresAt={} refresh_token={}", client.getAccessToken().getScopes(),
client.getAccessToken().getExpiresAt(), client.getRefreshToken() != null);
return ResponseEntity.noContent().build();
}
}@RegisteredOAuth2AuthorizedClient("keycloak") resolve OAuth2AuthorizedClient mà lần login đã lưu: access token, scope và thời hạn của nó, cùng refresh token. Với bob, dòng thứ ba ghi access_token scopes=[openid, profile, email] expiresAt=2026-09-18T07:20:40.092071Z refresh_token=true. Refresh token và việc rotate chúng là chủ đề của bài 15. Hai token, giải mã bằng hàm b64url_decode của bài Basics 35:
grep '^TokenLogController: id_token=' web.log | tail -1 | sed 's/.*id_token=//' > id.jwt
grep '^TokenLogController: access_token=' web.log | tail -1 | sed 's/.*access_token=//' > access.jwt
cut -d. -f2 < id.jwt | b64url_decode | jq .
cut -d. -f2 < access.jwt | b64url_decode | jq .{
"exp": 1789716040,
"iat": 1789715740,
"auth_time": 1789715740,
"jti": "fb265f05-d6dd-ef7e-7532-b84527f66e25",
"iss": "http://localhost:8313/realms/catalogue",
"aud": "web-client",
"sub": "dfc3c0df-f01c-43c3-a4a5-a212699bbe09",
"typ": "ID",
"azp": "web-client",
"nonce": "-yGkh_CcVGywOUJz5RsmkyQfCtKGKgzpWvjseQRKpcY",
"sid": "2iMZty-p32L9z9UcttfQX67X",
"at_hash": "wfQkTQ1piUYK3urueWKUEA",
"acr": "1",
"email_verified": true,
"name": "Bob Builder",
"preferred_username": "bob",
"given_name": "Bob",
"family_name": "Builder",
"email": "bob@example.com"
}{
"exp": 1789716040,
"iat": 1789715740,
"auth_time": 1789715740,
"jti": "onrtac:1d07b246-2e7d-a7d6-7f5b-ce6996036943",
"iss": "http://localhost:8313/realms/catalogue",
"aud": "catalogue-api",
"sub": "dfc3c0df-f01c-43c3-a4a5-a212699bbe09",
"typ": "Bearer",
"azp": "web-client",
"sid": "2iMZty-p32L9z9UcttfQX67X",
"acr": "1",
"allowed-origins": [
"http://localhost:8213"
],
"realm_access": {
"roles": [
"ADMIN"
]
},
"scope": "openid email profile",
"email_verified": true,
"name": "Bob Builder",
"preferred_username": "bob",
"given_name": "Bob",
"family_name": "Builder",
"email": "bob@example.com"
}Cả hai header đều là {"alg":"RS256","typ" : "JWT","kid" : "RI3cgatLapwIZsckaiZMdOZE4ah68oWi9i5vaMTyiNk"}: cùng một key của realm ký cả hai.
| Claim | ID token | Access token |
|---|---|---|
typ | ID | Bearer |
aud | web-client: dành cho client | catalogue-api: dành cho API, do audience mapper thêm vào |
azp | web-client | web-client: client đã lấy được nó |
nonce | giá trị ở bước 2 | không có |
at_hash | wfQkTQ1piUYK3urueWKUEA | không có |
scope | không có | openid email profile |
realm_access | không có | {"roles":["ADMIN"]} |
allowed-origins | không có | ["http://localhost:8213"], cho CORS |
| thời hạn | 300 s | 300 s, mặc định của Keycloak |
| ai đọc | web client, một lần, lúc login | API, ở mọi request |
at_hash buộc ID token vào access token được cấp cùng nó: đó là Base64URL của nửa trái SHA-256 tính trên chuỗi access token.
printf '%s' "$(cat access.jwt)" | openssl dgst -sha256 -binary | head -c 16 | b64urlwfQkTQ1piUYK3urueWKUEADòng quan trọng là realm_access: role của bob chỉ nằm trong access token. Token của alice hoàn toàn không có realm_access, vì lần import không cho cô realm role nào.
OidcUser principal và các authority của nó
GET /me của alice, trước mọi thay đổi với config ở trên:
{"principal":"DefaultOidcUser","name":"0140b1ce-67b1-432d-af8e-f021ea7cea40","claims":{"at_hash":"rMSmeXcXb14YU7G0bbxvZA","sub":"0140b1ce-67b1-432d-af8e-f021ea7cea40","email_verified":true,"iss":"http://localhost:8313/realms/catalogue","typ":"ID","preferred_username":"alice","given_name":"Alice","nonce":"ahg0n9AdRAuB9PzVmKdYHsHmr_YGuEwCKw8CJSvGAXA","sid":"jH_TrllUcP-XTZ5tYMb0K6go","aud":["web-client"],"acr":"1","azp":"web-client","auth_time":"2026-09-18T07:12:24Z","name":"Alice Liddell","exp":"2026-09-18T07:17:30Z","family_name":"Liddell","iat":"2026-09-18T07:12:30Z","email":"alice@example.com","jti":"5192ca65-4557-5820-f76b-4f22cc457480"},"authorities":["OIDC_USER","SCOPE_email","SCOPE_openid","SCOPE_profile"]}- Principal là một
DefaultOidcUser, và các claim của nó là claim của ID token gộp với response của userinfo, đã được chuyển kiểu:expvàiatlàInstant,audlà một list. - Các authority là
OIDC_USERcộng một authoritySCOPE_cho mỗi scope được cấp. Không có authorityFACTOR_nào:FACTOR_PASSWORDcủa bài Basics 33 vàFACTOR_BEARERcủa bài Basics 35 không có tương đương trong một lần login OAuth2 ở 7.1.1. - Name là claim
sub, một UUID. Đó là mặc định khi provider được lấy từissuer-uri, và là thứ màauthentication.getName(), log và mọi cộtcreatedBysẽ hiển thị.
Dùng preferred_username làm name
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8313/realms/catalogue
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username provider:
keycloak:
issuer-uri: http://localhost:8313/realms/catalogue
user-name-attribute: preferred_usernameSau khi restart, /me trả "name":"alice" với đúng các claim cũ. sub vẫn là khoá ổn định nên lưu; username có thể đổi trong Keycloak.
Map realm role của Keycloak thành authority ROLE_
Bob có realm role ADMIN, và /admin đòi hasRole("ADMIN"):
curl -s -i -b out/flow-bob/jar http://localhost:8213/adminHTTP/1.1 403
Content-Type: application/json
{"timestamp":"2026-09-18T07:15:40.195Z","status":403,"error":"Forbidden","path":"/admin"}Các authority của bob là ["SCOPE_openid","SCOPE_email","OIDC_USER","SCOPE_profile"]: Spring Security không biết gì về claim realm_access của Keycloak. Một bean GrantedAuthoritiesMapper viết lại các authority một lần lúc login, và oauth2Login tự nhận nó mà không cần cấu hình thêm:
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
@Bean
GrantedAuthoritiesMapper keycloakRealmRolesMapper() {
return authorities -> {
Set<GrantedAuthority> mapped = new HashSet<>(authorities);
for (GrantedAuthority authority : authorities) {
if (authority instanceof OidcUserAuthority oidc
&& oidc.getIdToken().getClaim("realm_access") instanceof Map<?, ?> realmAccess
&& realmAccess.get("roles") instanceof Collection<?> roles) {
roles.forEach(role -> mapped.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
}
return mapped;
};
} OidcUserAuthoritychính là authorityOIDC_USER; nó mang ID token và các claim của userinfo, nơi một mapper tìm dữ liệu về user.instanceofvới pattern matching bỏ qua việc map khi claim không có hoặc có hình dạng không như mong đợi, như trường hợp của alice.- Kết quả giữ nguyên các authority ban đầu và thêm
ROLE_ADMINcho bob.
Sau khi restart và login lại, bob vẫn nhận đúng cái 403 đó. Mapper đọc ID token, còn các token đã giải mã ở trên cho thấy role nằm ở đâu: trong access token. Access token gửi cho API; client nên coi nó là một chuỗi opaque, không đọc nội dung bên trong, và không tự xây authorization trên đó. Cách sửa nằm ở Keycloak: một protocol mapper ghi thêm realm role vào ID token cấp cho web-client.
"protocolMappers": [
{
"name": "catalogue-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "catalogue-api", "access.token.claim": "true" }
},
{
"name": "realm roles in ID token",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": { "claim.name": "realm_access.roles", "multivalued": "true", "id.token.claim": "true", "access.token.claim": "false" }
}
]claim.name có dấu chấm tạo ra cấu trúc lồng realm_access.roles. access.token.claim là false vì scope roles mặc định của realm đã ghi đúng claim đó vào access token. Vì việc import bỏ qua realm đã tồn tại, container được tạo lại bằng đúng lệnh docker run. Lần login tiếp theo của bob và /admin của anh, gồm status line và body:
./flow.sh bob Builder-2026 out/flow-bob
tail -1 out/flow-bob/6-me.txt | jq -c '{name, authorities, realm: .claims.realm_access}'
curl -s -i -b out/flow-bob/jar http://localhost:8213/admin{"name":"bob","authorities":["SCOPE_openid","SCOPE_email","ROLE_ADMIN","SCOPE_profile","OIDC_USER"],"realm":{"roles":["ADMIN"]}}
HTTP/1.1 200
Admin area for bobID token của alice không có realm_access, và /admin của cô vẫn là 403. Cùng mapper đó có thể đọc claim từ oidc.getUserInfo() khi một provider đặt role vào response của userinfo thay vì ID token.
Logout: web client và Keycloak
/logout thông thường để lại gì
Với oauth2Login, Spring Security vẫn sinh một trang logout ở GET /logout, form của nó post _csrf tới /logout. Bob logout theo cách đó:
CSRF=$(curl -s -b jar -c jar http://localhost:8213/logout | grep -o 'name="_csrf" type="hidden" value="[^"]*"' | sed 's/.*value="//; s/"$//')
curl -s -i -b jar -c jar -d "_csrf=$CSRF" http://localhost:8213/logoutHTTP/1.1 302
Location: http://localhost:8213/login?logoutSession của web client đã mất, và GET /me tiếp theo bắt đầu một lần login. Đi theo nó với cùng cookie jar, từng bước một:
2-me.txt: HTTP/1.1 302 -> http://localhost:8213/oauth2/authorization/keycloak
3-authz.txt: HTTP/1.1 302 -> http://localhost:8313/realms/catalogue/protocol/openid-connect/auth?response_type=code&client_id=web-client&scope=openid%20profile%20email&state=pA-Vk
4-keycloak.txt: HTTP/1.1 302 Found -> http://localhost:8213/login/oauth2/code/keycloak?state=pA-Vkm3pZgxHJWVvDSZ9XBvfdcbbPD_7j0XyFWXstjA%3D&session_state=FAO9KxHtxRhvyFYj_6HgCEg6&iss=http%
5-callback.txt: HTTP/1.1 302 -> http://localhost:8213/me?continue
6-me.txt: HTTP/1.1 200 ->Keycloak trả lời authorization request bằng 302 kèm code ngay lập tức, không có form login, vì KEYCLOAK_IDENTITY vẫn còn hiệu lực. ID token mới có "auth_time":"2026-09-18T07:16:28Z", thời điểm nhập mật khẩu ban đầu, và "iat":"2026-09-18T07:16:49Z". Trên một máy tính dùng chung, bấm "Log out" rồi truy cập lại bất kỳ trang nào sẽ login lại đúng user đó.
RP-initiated logout với OidcClientInitiatedLogoutSuccessHandler
OpenID Connect RP-Initiated Logout đưa browser tới end_session_endpoint của provider sau khi logout tại chỗ. Spring Security có sẵn success handler cho việc này:
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) {
SecurityFilterChain securityFilterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrations) {
OidcClientInitiatedLogoutSuccessHandler logoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrations);
logoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults());
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(logoutSuccessHandler));
return http.build();
}Cùng POST /logout đó giờ trả về:
HTTP/1.1 302
Location: http://localhost:8313/realms/catalogue/protocol/openid-connect/logout?id_token_hint=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDQmg2elcyczUyY3hoMDFPRHRKZE4wbjlFem9WNk9CT2tqd2dITmJRWS13In0.eyJleHAiOjE3ODk3MTY0MTUs…&post_logout_redirect_uri=http://localhost:8213/end_session_endpointlấy từ discovery document màissuer-uriđã tải lúc khởi động.id_token_hintlà toàn bộ ID token của bob, 1.189 ký tự trong URL. Nó cho Keycloak biết cần kết thúc session nào và client nào đang yêu cầu.post_logout_redirect_urilà{baseUrl}/đã được thay giá trị, và Keycloak chỉ chấp nhận nó vì file realm liệt kê nó trongpost.logout.redirect.uris.
Đi theo redirect đó:
HTTP/1.1 302 Found
Set-Cookie: KEYCLOAK_IDENTITY=;Version=1;Path=/realms/catalogue/;Max-Age=0
Set-Cookie: KEYCLOAK_SESSION=;Version=1;Path=/realms/catalogue/;Max-Age=0
Location: http://localhost:8213/Keycloak kết thúc SSO session, xoá cookie của mình và đưa browser về trang chủ; với một id_token_hint hợp lệ, nó không hỏi xác nhận. GET /me tiếp theo đi lại bước 1 và bước 2, và bước 3 trả 200 OK với Sign in to catalogue: lại phải nhập mật khẩu. Logout không thu hồi access token đã cấp; phần về resource server cho thấy một token còn hiệu lực cho tới exp, còn thu hồi token là chủ đề của bài 15.
Google và GitHub với CommonOAuth2Provider
Với các provider phổ biến, Spring Security giữ sẵn endpoint trong enum CommonOAuth2Provider của spring-security-config 7.1.1, với các hằng GOOGLE, GITHUB, FACEBOOK, X và OKTA. Spring Boot áp dụng nó khi registration id trùng tên, nên một registration chỉ cần thông tin xác thực. Lab chạy web client với profile social chứa giá trị giả:
spring.security.oauth2.client.registration.google.client-id=dummy-google-client-id
spring.security.oauth2.client.registration.google.client-secret=dummy-google-client-secret
spring.security.oauth2.client.registration.github.client-id=dummy-github-client-id
spring.security.oauth2.client.registration.github.client-secret=dummy-github-client-secretspring:
security:
oauth2:
client:
registration:
google:
client-id: dummy-google-client-id
client-secret: dummy-google-client-secret
github:
client-id: dummy-github-client-id
client-secret: dummy-github-client-secretjava -Xmx512m -jar web/build/libs/web-0.0.1-SNAPSHOT.jar --spring.profiles.active=social
curl -s -i http://localhost:8213/oauth2/authorization/google | grep '^Location'
curl -s -i http://localhost:8213/oauth2/authorization/github | grep '^Location'Location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=dummy-google-client-id&scope=openid%20profile%20email&state=reuQhz7YOnfSlCy8ysVJJ7dxoiGL9CyQNljLvTCfIik%3D&redirect_uri=http://localhost:8213/login/oauth2/code/google&nonce=fvHIJ4LuMDE9bTrfEQlOSWVxMHv0hQzpvUJietyb2RY&code_challenge=Ygcew9i2RgcMx5F-ziz-OPw0EZ9mwOJYrB3p9Utxmt0&code_challenge_method=S256
Location: https://github.com/login/oauth/authorize?response_type=code&client_id=dummy-github-client-id&scope=read:user&state=Wa9JNTf9jcIPPqEtJoNmLDMkZah0WkMyDx2rOUSqEng%3D&redirect_uri=http://localhost:8213/login/oauth2/code/github&code_challenge=Z8QK2pMK6WqrnxmJE5jPHtbF9rs90M3hnl4_PUdBP3o&code_challenge_method=S256Trang /login được sinh ra giờ liệt kê ba link: GitHub, Google và, cho registration Keycloak, URL issuer của nó làm tên. Hai redirect khác nhau ở những chỗ quan trọng:
| GitHub | ||
|---|---|---|
| Authorization endpoint | https://accounts.google.com/o/oauth2/v2/auth | https://github.com/login/oauth/authorize |
| Scope mặc định | openid profile email | read:user |
nonce trong request | có | không |
| Token endpoint | https://www.googleapis.com/oauth2/v4/token | https://github.com/login/oauth/access_token |
| User info | https://www.googleapis.com/oauth2/v3/userinfo | https://api.github.com/user |
issuer-uri, JWK set | https://accounts.google.com, https://www.googleapis.com/oauth2/v3/certs | không có |
| Thuộc tính làm name | sub | id |
| Principal sau khi login | OidcUser, authority OIDC_USER | OAuth2User, authority OAUTH2_USER |
Các giá trị là hằng chuỗi trong bytecode của CommonOAuth2Provider$1 và $2, còn tên authority là hằng của OidcUserAuthority và OAuth2UserAuthority. GitHub là OAuth2 thuần, không phải OpenID Connect: không có scope openid thì không có ID token và không có nonce, và user là bất cứ thứ gì https://api.github.com/user trả về cho access token, do DefaultOAuth2UserService tải. Code phục vụ cả hai loại provider nhận OAuth2User, interface mà OidcUser extend.
Bài này không chạy một lần login Google hay GitHub đầy đủ: việc đó cần một application đăng ký thật với từng provider. Với các id giả, Google redirect tới trang lỗi của nó với một authError giải mã ra invalid_client và The OAuth client was not found., còn GitHub redirect tới trang đăng nhập của nó. Để chạy thật: trong Google Cloud console, tạo một OAuth client loại "Web application" ở mục APIs & Services, Credentials, với authorized redirect URI http://localhost:8213/login/oauth2/code/google; trên GitHub, tạo một OAuth App ở Settings, Developer settings, với authorization callback URL http://localhost:8213/login/oauth2/code/github; rồi đặt client id và secret của từng bên vào các property ở trên, lấy từ biến môi trường thay vì ghi trong file.
Resource server tin token của Keycloak
issuer-uri trên API
API thay public-key-location của bài Basics 35 bằng issuer:
spring.application.name=api
server.port=9213
logging.pattern.console=%logger{0}: %msg%n
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8313/realms/cataloguespring:
application:
name: api
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8313/realms/catalogue
server:
port: 9213
logging:
pattern:
console: '%logger{0}: %msg%n'Chain là API chain của bài Basics 35, stateless và không bảo vệ CSRF, với ProblemDetailSecurityHandler chép nguyên xi: nó uỷ quyền cho BearerTokenAuthenticationEntryPoint với realm name catalogue, rồi ghi body ProblemDetail.
package com.example.api.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}Hai endpoint: một cái báo lại API đã thấy gì trong token, cái kia chỉ dành cho quản trị viên.
package com.example.api.account;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CallerController {
@GetMapping("/api/me")
public CallerResponse me(@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
return new CallerResponse(
jwt.getSubject(),
jwt.getClaimAsString("preferred_username"),
jwt.getClaimAsString("azp"),
jwt.getAudience(),
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList());
}
}package com.example.api.account;
import java.util.List;
public record CallerResponse(String subject, String username, String clientId, List<String> audience,
List<String> authorities) {
}package com.example.api.report;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ReportController {
@GetMapping("/api/reports/stock")
@PreAuthorize("hasRole('ADMIN')")
public StockReport stock() {
return new StockReport(3, 128);
}
}StockReport là một record với int products và int unitsInStock. Với --debug, conditions report cho thấy JwtDecoderConfiguration#jwtDecoderByIssuerUri matched và decoder public key của bài Basics 35 không match.
API tải key của Keycloak khi nào?
Access log của Keycloak trả lời câu hỏi đó, từng request một:
| Thời điểm | Request API gửi tới Keycloak |
|---|---|
| API khởi động | không có |
| request đầu tiên có token | GET …/.well-known/openid-configuration, rồi GET …/certs |
| request thứ hai, cùng key | không có |
một token có kid không nằm trong key set đã cache | GET …/certs thêm một lần |
Boot bọc issuer decoder trong một SupplierJwtDecoder: discovery chạy ở request đầu tiên cần tới nó, còn key set được cache và tải lại khi gặp một kid lạ, đó là cách việc rotate key của Keycloak tới được API mà không cần restart. Việc khởi động lười có cái giá của nó. Khi container Keycloak đã dừng, API vẫn khởi động bình thường, và request đầu tiên với một token hoàn toàn hợp lệ nhận về:
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
{"detail":"Valid credentials are required to access this resource.","instance":"/error","status":401,"title":"Unauthorized"}Log cho thấy chuyện gì đã xảy ra: JwtDecoderInitializationException: Failed to lazily resolve the supplied JwtDecoder instance, gây ra bởi Connection refused ở URL discovery. Exception thoát khỏi filter thành một lỗi server, Tomcat forward nó tới /error, và ERROR dispatch ẩn danh vấp phải anyRequest().authenticated(): đúng cái bẫy bài Basics 33 đã mô tả, giờ biến một sự cố thành một 401 với "instance":"/error" và không có error trong challenge. Khi Keycloak chạy lại, cùng request đó trả 200: supplier thử lại cho tới khi thành công. Cho phép /error, hoặc cảnh báo khi gặp exception đó, giữ cho sự cố không bị che khuất.
Token cho dịch vụ với client credentials grant
curl -s -u reporting-service:reporting-service-secret -d grant_type=client_credentials \
http://localhost:8313/realms/catalogue/protocol/openid-connect/token{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5…",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "Bearer",
"not-before-policy": 0,
"scope": "email profile"
}Không có refresh token: một client lúc nào cũng có thể xin lại bằng secret của mình. Access token, sau khi giải mã:
{
"exp": 1789716165,
"iat": 1789715865,
"jti": "trrtcc:5e62dd0b-b784-c9de-1e32-0096b4cab67f",
"iss": "http://localhost:8313/realms/catalogue",
"aud": [
"catalogue-api",
"account"
],
"sub": "d9c52585-38d7-422f-9499-f10825b14634",
"typ": "Bearer",
"azp": "reporting-service",
"acr": "1",
"realm_access": {
"roles": [
"offline_access",
"uma_authorization",
"default-roles-catalogue"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "email profile",
"email_verified": false,
"clientHost": "192.168.65.1",
"preferred_username": "service-account-reporting-service",
"clientAddress": "192.168.65.1",
"client_id": "reporting-service"
}sub là user service account mà Keycloak tạo cho client, tên service-account-reporting-service. User đó nhận các role mặc định của realm, và các client role của account đưa account vào aud bên cạnh catalogue-api. GET /api/me với token này:
{"subject":"d9c52585-38d7-422f-9499-f10825b14634","username":"service-account-reporting-service","clientId":"reporting-service","audience":["catalogue-api","account"],"authorities":["SCOPE_email","FACTOR_BEARER","SCOPE_profile"]}Token của user từ lần login
Access token của bob, lấy từ log của web client bằng controller dành cho lab, trên cùng endpoint:
{"subject":"dfc3c0df-f01c-43c3-a4a5-a212699bbe09","username":"bob","clientId":"web-client","audience":["catalogue-api"],"authorities":["SCOPE_openid","SCOPE_email","FACTOR_BEARER","SCOPE_profile"]}API không có cách nào biết token được lấy ra sao ngoài các claim của nó: token của user có user ở sub và client ở azp, token của dịch vụ có service account ở sub.
Token bị từ chối và header WWW-Authenticate của chúng
Ba token phải thất bại, mỗi cái lấy từ Keycloak đang chạy:
curl -s -u reporting-service:reporting-service-secret -d grant_type=client_credentials http://localhost:8313/realms/other/protocol/openid-connect/token | jq -r .access_token > other-realm.jwt
curl -s -u reporting-service:reporting-service-secret -d grant_type=client_credentials http://127.0.0.1:8313/realms/catalogue/protocol/openid-connect/token | jq -r .access_token > ip-issuer.jwt
SIG=$(cut -d. -f3 < service.jwt)
printf '%s' "$(cut -d. -f1,2 < service.jwt).$(printf '%s' "$SIG" | cut -c1-20)AAAAAAAA$(printf '%s' "$SIG" | cut -c29-)" > tampered.jwt
for f in other-realm ip-issuer tampered; do curl -s -i -H "Authorization: Bearer $(cat $f.jwt)" http://localhost:9213/api/me | grep -E '^HTTP|^WWW-Authenticate'; doneHTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Another algorithm expected, or no matching key(s) found", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The iss claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"Mỗi response cũng mang body ProblemDetail của bài Basics 35.
- Token từ realm
other("iss":"http://localhost:8313/realms/other") thất bại trước khi claim nào được đọc. Header của nó ghikidJJJ0huHP…, không có trong key set củacatalogue; decoder tải lại…/certs, như bảng ở trên cho thấy, và không tìm được key nào để verify. Mỗi realm có key riêng. - Token xin qua
127.0.0.1được ký bằng đúng key, nhưng Keycloak suy ra issuer từ host mà request đi tới, nên nó mang"iss":"http://127.0.0.1:8313/realms/catalogue". Chữ ký hợp lệ vàJwtIssuerValidatormàissuer-urithêm vào đã từ chối nó. Đây là lỗi kinh điển với container: một application lấy token từhttp://keycloak:8080nhận về token mà một API cấu hìnhhttp://localhost:8313từ chối. Hãy cấu hình một hostname công khai duy nhất cho Keycloak, optionhostnamekhi chạy production. - Chữ ký bị sửa, thay tám ký tự, trượt ở bước verify RSA của Nimbus.
Bắt buộc audience
Tới giờ API chấp nhận mọi token do issuer của nó ký, kể cả một token cấp cho partner-service, một client không có lý do gì để gọi nó:
{"subject":"77af2979-8d30-4510-a224-f1620dfb4b1c","username":"service-account-partner-service","clientId":"partner-service","audience":["account"],"authorities":["SCOPE_email","FACTOR_BEARER","SCOPE_profile"]}Claim aud cho biết token dành cho ai. Property của Spring Boot để kiểm tra nó là spring.security.oauth2.resourceserver.jwt.audiences, một list, có trong configuration metadata của 4.1.1:
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8313/realms/catalogue
spring.security.oauth2.resourceserver.jwt.audiences=catalogue-api resourceserver:
jwt:
issuer-uri: http://localhost:8313/realms/catalogue
audiences: catalogue-apiSau khi restart, token của partner nhận về:
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"Token của dịch vụ (["catalogue-api","account"]) và token của bob (catalogue-api) vẫn trả 200: khớp một giá trị là đủ. Phía Keycloak, giá trị này đến từ oidc-audience-mapper trong file realm; không có nó, access token của Keycloak nhiều lắm chỉ mang account, và bật property lên sẽ khoá ngoài mọi client.
Token hết hạn và độ lệch 60 giây
Token dịch vụ ở trên hết hạn lúc 07:22:45 UTC. Một vòng lặp gửi nó mỗi năm giây quanh thời điểm đó:
#!/bin/zsh
# Sends the same client-credentials token every 5 s from just before its exp to 75 s after it.
TOKEN=$(cat out/svc.jwt)
EXP=$(cut -d. -f2 <<< "$TOKEN" | tr '_-' '/+' | awk '{ while (length($0) % 4) $0 = $0 "="; print }' | base64 -d | jq .exp)
echo "exp = $(date -u -r $EXP +%H:%M:%S)"
while (( $(date +%s) < EXP - 5 )); do sleep 1; done
while (( $(date +%s) <= EXP + 75 )); do
echo "$(date -u +%H:%M:%S) now-exp=$(( $(date +%s) - EXP ))s -> $(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" http://localhost:9213/api/me)"
sleep 5
done
curl -s -i -H "Authorization: Bearer $TOKEN" http://localhost:9213/api/me | grep -E "^HTTP|^WWW-Authenticate|^Date"exp = 07:22:45
07:22:40 now-exp=-5s -> 200
07:22:45 now-exp=0s -> 200
07:22:50 now-exp=5s -> 200
07:22:56 now-exp=11s -> 200
07:23:01 now-exp=16s -> 200
07:23:06 now-exp=21s -> 200
07:23:11 now-exp=26s -> 200
07:23:16 now-exp=31s -> 200
07:23:21 now-exp=36s -> 200
07:23:26 now-exp=41s -> 200
07:23:31 now-exp=46s -> 200
07:23:36 now-exp=51s -> 200
07:23:41 now-exp=56s -> 200
07:23:46 now-exp=61s -> 401
07:23:51 now-exp=66s -> 401
07:23:56 now-exp=71s -> 401
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-09-18T07:22:45Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:9213/.well-known/oauth-protected-resource"
Date: Fri, 18 Sep 2026 07:24:01 GMTIssuer decoder giữ JwtTimestampValidator mặc định với clock skew 60 giây mà bài Basics 35 đã đo: token vẫn dùng được 56 giây sau exp và thất bại từ giây thứ 61. Với một authorization server riêng, có đồng hồ không phải của API, khoảng dung sai đó mới đúng là chỗ dùng của nó.

Map realm_access.roles cho @PreAuthorize
Token của bob có "realm_access":{"roles":["ADMIN"]}, và /api/reports/stock đòi hasRole('ADMIN'):
HTTP/1.1 403
Content-Type: application/problem+json
{"detail":"You are not allowed to perform this operation.","instance":"/api/reports/stock","status":403,"title":"Forbidden"}Bài Basics 35 map một claim roles ở cấp cao nhất bằng hai property. Chính hai property đó trỏ vào claim lồng, truyền qua command line:
java -Xmx512m -jar api/build/libs/api-0.0.1-SNAPSHOT.jar \
--spring.security.oauth2.resourceserver.jwt.authorities-claim-name=realm_access.roles \
--spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_ \
--logging.level.org.springframework.security.oauth2.server.resource.authentication=TRACECác authority của bob chỉ còn ["FACTOR_BEARER"], và report vẫn 403. Log TRACE nói lý do:
JwtGrantedAuthoritiesConverter: Returning no authorities since could not find any claims that might contain scopesJwtGrantedAuthoritiesConverter tìm tên claim như một key của map các claim, và không claim nào tên là realm_access.roles. Với authorities-claim-name=realm_access nó tìm thấy claim, một JSON object chứ không phải list hay chuỗi, và cũng chẳng trả về gì. Các authority SCOPE_ cũng biến mất, vì converter giờ chỉ đọc claim được cấu hình.
Spring Boot 4.1 thêm một property cho claim lồng là spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions; metadata của 4.0.0 không có nó. Mỗi phần tử là một biểu thức SpEL được đánh giá trên map các claim, do ExpressionJwtGrantedAuthoritiesConverter của Spring Security xử lý:
java -Xmx512m -jar api/build/libs/api-0.0.1-SNAPSHOT.jar \
"--spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions=['realm_access']['roles']" \
--spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_| Biểu thức | Authority của bob | /api/reports/stock |
|---|---|---|
['realm_access']['roles'] | FACTOR_BEARER, ROLE_ADMIN | 200 {"products":3,"unitsInStock":128} |
[realm_access][roles] | FACTOR_BEARER, ROLE_ADMIN | 200 |
realm_access.roles | FACTOR_BEARER | 403 |
Dạng cuối thất bại trong im lặng: root object là một Map, và ở mức TRACE converter ghi EL1008E: Property or field 'realm_access' cannot be found on object of type 'java.util.Collections$UnmodifiableMap'. Property này loại trừ lẫn nhau với authorities-claim-name, và các authority SCOPE_ lại mất. Để giữ chúng và thêm role, một bean converter kết hợp cả hai:
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.security.oauth2.server.resource.authentication.DelegatingJwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
ExpressionJwtGrantedAuthoritiesConverter realmRoles = new ExpressionJwtGrantedAuthoritiesConverter(
new SpelExpressionParser().parseExpression("['realm_access']['roles']"));
realmRoles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
new DelegatingJwtGrantedAuthoritiesConverter(new JwtGrantedAuthoritiesConverter(), realmRoles));
return converter;
} oauth2.jwt(Customizer.withDefaults()) dùng bean JwtAuthenticationConverter, và converter riêng của Boot lùi lại khi đã có một bean như vậy. Ba caller cũ sau khi restart:
| Token | Authority ở API | /api/reports/stock |
|---|---|---|
| bob | FACTOR_BEARER, SCOPE_openid, SCOPE_email, ROLE_ADMIN, SCOPE_profile | 200 |
alice, không có claim realm_access | SCOPE_openid, SCOPE_email, FACTOR_BEARER, SCOPE_profile | 403 |
reporting-service | ROLE_offline_access, SCOPE_email, ROLE_uma_authorization, FACTOR_BEARER, ROLE_default-roles-catalogue, SCOPE_profile | 403 |
Token của alice không có claim đó không gây lỗi, chỉ là không có role. Service account cho thấy cái giá của việc map mọi realm role: các role mặc định của Keycloak đến dưới dạng ROLE_offline_access và các role cùng loại, vô hại chừng nào không rule nào dùng những cái tên đó. Rule dựa trên permission thay cho role là chủ đề của bài 15.
Web client gọi API
Chuyển tiếp access token của user bằng OAuth2ClientHttpRequestInterceptor
OAuth2ClientHttpRequestInterceptor, trong org.springframework.security.oauth2.client.web.client của spring-security-oauth2-client 7.1.1, thêm Authorization: Bearer vào mọi request của một RestClient, lấy token từ một OAuth2AuthorizedClientManager. Web client không có bean RestClient.Builder, vì các starter của nó không gồm spring-boot-starter-restclient, nên nó dùng RestClient.builder():
package com.example.web.catalogue;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.web.client.RestClient;
@Configuration
public class CatalogueApiConfig {
@Bean
RestClient catalogueApi(OAuth2AuthorizedClientManager authorizedClientManager,
@Value("${catalogue-api.base-url}") String baseUrl) {
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
interceptor.setClientRegistrationIdResolver(request -> "keycloak");
return RestClient.builder()
.baseUrl(baseUrl)
.requestInterceptor(interceptor)
.build();
}
}package com.example.web.catalogue;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
public class CatalogueController {
private final RestClient catalogueApi;
public CatalogueController(@Qualifier("catalogueApi") RestClient catalogueApi) {
this.catalogueApi = catalogueApi;
}
@GetMapping("/catalogue/caller")
public ApiCaller caller() {
return catalogueApi.get().uri("/api/me").retrieve().body(ApiCaller.class);
}
}OAuth2AuthorizedClientManagerlà bean mà Spring Security đăng ký cho client, chính cái tên xuất hiện trong lỗi khởi động ở trên. Nó tìm authorized client của user đang login, và làm mới access token bằng refresh token khi token sắp hết hạn.setClientRegistrationIdResolvercố định registration; resolver mặc định đọc nó từ một request attribute đặt cho từng lời gọi.- Principal mặc định lấy từ
SecurityContextHolder, nên mỗi request mang token của đúng user có request đang được phục vụ. ApiCallerlà một record có năm thành phần giốngCallerResponsecủa API, vàcatalogue-api.base-url=http://localhost:9213nằm trongapplication.properties.
GET /catalogue/caller của bob trả về những gì API đã thấy:
{"subject":"dfc3c0df-f01c-43c3-a4a5-a212699bbe09","username":"bob","clientId":"web-client","audience":["catalogue-api"],"authorities":["SCOPE_openid","SCOPE_email","FACTOR_BEARER","ROLE_ADMIN","SCOPE_profile"]}API nhận được danh tính và role của bob, còn browser vẫn chưa bao giờ có token.
Service-to-service với client credentials
Với các lời gọi application thực hiện cho chính nó, web client có thêm một registration thứ hai, dùng cùng provider:
spring.security.oauth2.client.registration.reporting.provider=keycloak
spring.security.oauth2.client.registration.reporting.client-id=reporting-service
spring.security.oauth2.client.registration.reporting.client-secret=reporting-service-secret
spring.security.oauth2.client.registration.reporting.authorization-grant-type=client_credentials
catalogue-api.base-url=http://localhost:9213spring:
security:
oauth2:
client:
registration:
reporting:
provider: keycloak
client-id: reporting-service
client-secret: reporting-service-secret
authorization-grant-type: client_credentials
catalogue-api:
base-url: http://localhost:9213Registration client_credentials không xuất hiện trên trang login. RestClient của nó dùng một manager khác:
package com.example.web.reporting;
import static org.springframework.security.oauth2.client.web.client.RequestAttributePrincipalResolver.principal;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.security.oauth2.client.web.client.RequestAttributePrincipalResolver;
import org.springframework.web.client.RestClient;
@Configuration
public class ReportingApiConfig {
@Bean
RestClient reportingApi(ClientRegistrationRepository clientRegistrations,
OAuth2AuthorizedClientService authorizedClientService,
@Value("${catalogue-api.base-url}") String baseUrl) {
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrations, authorizedClientService);
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "reporting");
interceptor.setPrincipalResolver(new RequestAttributePrincipalResolver());
return RestClient.builder()
.baseUrl(baseUrl)
.defaultRequest(request -> request.attributes(principal("reporting-service")))
.requestInterceptor(interceptor)
.build();
}
}AuthorizedClientServiceOAuth2AuthorizedClientManagerlàm việc được bên ngoài một HTTP request, từ một scheduled job cũng như từ controller, và lưu token trongOAuth2AuthorizedClientServicemà Boot cấu hình, một bản in-memory. Provider mặc định của nó, đọc từ static initialiser, chỉ xử lýclient_credentials.- Principal được cố định thành tên
reporting-servicequa một request attribute. Service lưu token theo registration và tên principal; tên cố định khiến token thuộc về application thay vì thuộc về user nào vô tình kích hoạt lời gọi.
Một controller ở GET /reports/caller gọi /api/me qua client này, giống CatalogueController. Để xem mỗi client xin token từ Keycloak bao nhiêu lần, một script đếm các dòng POST …/token trong access log của Keycloak quanh mỗi loạt lời gọi:
#!/bin/zsh
# Counts POSTs to Keycloak's token endpoint (from its HTTP access log) around each batch of calls.
tokens() { docker logs sba-a13-keycloak 2>&1 | grep -c 'POST /realms/catalogue/protocol/openid-connect/token'; }
step() { local label=$1; shift; local before=$(tokens); local out=$("$@" | tail -1); sleep 1; echo "$label: $out| $(( $(tokens) - before )) token request(s)"; }
five() { for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code} ' -b $1 -c $1 http://localhost:8213$2; done; }
step "log in as bob" ./flow.sh bob Builder-2026 out/count-bob
step "bob: 5 x /catalogue/caller (user token relay)" five out/count-bob/jar /catalogue/caller
step "bob: 5 x /reports/caller (client credentials)" five out/count-bob/jar /reports/caller
step "log in as alice" ./flow.sh alice Wonderland-2026 out/count-alice
step "alice: 5 x /reports/caller (client credentials)" five out/count-alice/jar /reports/callerTrên một web client vừa khởi động:
log in as bob: 6-me.txt: HTTP/1.1 200 | 1 token request(s)
bob: 5 x /catalogue/caller (user token relay): 200 200 200 200 200 | 0 token request(s)
bob: 5 x /reports/caller (client credentials): 200 200 200 200 200 | 1 token request(s)
log in as alice: 6-me.txt: HTTP/1.1 200 | 1 token request(s)
alice: 5 x /reports/caller (client credentials): 200 200 200 200 200 | 0 token request(s)- Việc chuyển tiếp không xin token nào: nó dùng lại access token từ lần login của bob.
- Token dịch vụ được xin đúng một lần cho mười lời gọi từ session của hai user.
GET /reports/callertrả"username":"service-account-reporting-service"và"clientId":"reporting-service"bất kể user nào hỏi.
Cùng script đó chạy với một bản build không có hai dòng về principal, khi principal lấy từ SecurityContextHolder, in ra 1 token request(s) cho loạt của alice nữa: mỗi user có một token dịch vụ riêng, nghĩa là trên production mỗi session user một lần xin token thay vì mỗi application một lần.
Việc dùng lại kéo dài tới khi token sắp hết hạn. Access token của lần login của bob và của dịch vụ hết hạn lúc 07:29:40 và 07:29:42; mỗi client gọi một lần lúc 07:28:31 và 07:28:32 không xin token nào, còn lúc 07:28:52 và 07:28:53 mỗi bên xin một lần. ClientCredentialsOAuth2AuthorizedClientProvider và RefreshTokenOAuth2AuthorizedClientProvider đều có clockSkew 60 giây trong bytecode và làm mới token khi đã gần exp tới mức đó; với lần login của bob, provider duy nhất làm mới được mà không cần browser là provider refresh token. Refresh token, việc rotate và thu hồi chúng là chủ đề của bài 15.

Vì sao giữ token trong web client thay vì trong browser?
Web client trong bài là một backend for frontend: browser giữ một session cookie HttpOnly, token nằm trong session của server, và chỉ server gọi API. Một single-page application tự chạy luồng OAuth2 phải giữ access token, thường kèm cả refresh token, ở nơi JavaScript đọc được, nên chỉ một lỗi cross-site scripting là trao cả hai cho kẻ tấn công, kẻ đó có thể gọi API từ bất cứ đâu cho tới khi token hết hạn. Nó cũng phải là public client không có secret, chỉ dựa vào PKCE. Với backend for frontend, kẻ tấn công chạy được script trong trang vẫn gửi được request qua browser của nạn nhân trong lúc trang còn mở, nhưng không mang token đi được. Cái giá là state trên server và việc bảo vệ CSRF cho session, thứ mà chain của web client vẫn bật, như bài Basics 36 đã giải thích cho các chain dựa trên session.
Issuer bên ngoài thay đổi gì so với JWT tự cấp?
Token của bài Basics 35 được ký và kiểm tra bởi cùng một application, với cặp key nằm trong resources của nó, và chỉ phục vụ application đó. Khi Keycloak là issuer, việc kiểm tra mật khẩu và private key rời khỏi các application: API chỉ giữ một URL, và key mới tới được nó qua JWKS mà không cần deploy lại, như lần tải lại khi gặp kid lạ đã cho thấy. Một lần login phục vụ nhiều client qua single sign-on, và một lần logout ở provider kết thúc single sign-on session đó. Token cho biết client nào hành động (azp) và dành cho ai (aud), nên token của đối tác có thể bị từ chối. Các dịch vụ có token của riêng mình qua client credentials. Và cùng một đoạn code chạy được với Google hay GitHub thay cho một bảng user. Điều bài Basics 35 làm được và Keycloak cũng làm là ký JWT; mọi thứ bao quanh chữ ký mới là phần authorization server mang lại.
FAQ
Spring Security 7 có dùng PKCE cho confidential client không?
Có. Với Spring Security 7.1.1, authorization request của một confidential client có secret chứa code_challenge và code_challenge_method=S256 mà không cần cấu hình gì, với Keycloak, Google và GitHub đều như nhau. Keycloak sau đó từ chối một authorization code gửi tới mà không có verifier, trả invalid_grant và PKCE code verifier not specified, dù client secret đúng.
Vì sao name của user login bằng OAuth2 lại là một UUID?
Vì Spring Security dùng claim sub làm name khi provider được cấu hình qua issuer-uri, và sub của Keycloak là id của user. Đặt spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username: khi đó authentication.getName() trả alice thay vì 0140b1ce-67b1-432d-af8e-f021ea7cea40. Vẫn nên lưu sub làm khoá, vì username có thể đổi.
Vì sao role của Keycloak không có trong authority của OidcUser?
Vì OidcUser được dựng từ ID token và response của userinfo, còn Keycloak mặc định chỉ đặt realm_access.roles vào access token. Một GrantedAuthoritiesMapper đọc ID token không tìm thấy gì và bob vẫn bị 403; sau khi thêm vào web-client một protocol mapper cho realm role với id.token.claim là true, cùng mapper đó tạo ra ROLE_ADMIN và /admin trả 200.
Làm sao map role trong realm_access của Keycloak ở resource server Spring Boot?
Với authorities-claim-name=realm_access.roles, các authority chỉ còn FACTOR_BEARER: property này chỉ tên một claim ở cấp cao nhất. Spring Boot 4.1 nhận spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions=['realm_access']['roles'] cùng authority-prefix=ROLE_, tạo ra ROLE_ADMIN, trong khi dạng có dấu chấm realm_access.roles thất bại trong im lặng. Để giữ luôn các authority SCOPE_, khai báo một JwtAuthenticationConverter kết hợp JwtGrantedAuthoritiesConverter và ExpressionJwtGrantedAuthoritiesConverter.
Resource server dùng issuer-uri có cần authorization server lúc khởi động không?
Không, khác với OAuth2 client. API khởi động khi Keycloak đang dừng và không gửi gì cho Keycloak lúc khởi động; discovery và key set được tải ở request đầu tiên mang token. Request đầu tiên đó thất bại khi Keycloak còn tắt, và ERROR dispatch biến nó thành một 401 với "instance":"/error". Ngược lại, web client resolve issuer-uri ngay lúc khởi động và thoát với Unable to resolve Configuration with the provided Issuer.
Vì sao logout khỏi application Spring không logout khỏi Keycloak?
Vì /logout chỉ kết thúc session của application; SSO cookie KEYCLOAK_IDENTITY của Keycloak vẫn còn hiệu lực, và redirect login tiếp theo quay về ngay với một authorization code và auth_time ban đầu. OidcClientInitiatedLogoutSuccessHandler redirect tới end_session_endpoint của provider với id_token_hint và post_logout_redirect_uri, URI này phải được đăng ký cho client trong Keycloak; sau đó Keycloak lại hiện form login.
Còn dùng được Keycloak Spring Boot adapter không?
Không. keycloak-spring-boot-starter và keycloak-spring-security-adapter đã deprecated, bản phát hành cuối trên Maven Central là 25.0.3, và KeycloakWebSecurityConfigurerAdapter extend WebSecurityConfigurerAdapter, class mà Spring Security 6 đã xoá. Keycloak là một OpenID Provider chuẩn, và phần hỗ trợ client và resource server của chính Spring Security đã chạy với nó bằng các property trong bài.
Kết luận
Giờ Keycloak nắm mật khẩu và key. Web client cho user login bằng authorization code flow, PKCE có sẵn theo mặc định, giữ token trong session của mình, map realm role của Keycloak khi realm đã đưa chúng vào ID token, và logout khỏi cả Keycloak qua end_session_endpoint. API tin một URL issuer: nó tải discovery document và key một cách lười, kiểm tra chữ ký, iss, exp với 60 giây skew và, khi được cấu hình, aud, rồi map realm_access.roles bằng expression converter mà Spring Boot 4.1 đưa ra thành property. Giữa hai bên, OAuth2ClientHttpRequestInterceptor chuyển tiếp token của user, và một token client credentials phục vụ các lời gọi của chính application, xin một lần và làm mới ngay trước khi hết hạn.
Lab dùng Keycloak như một hộp đen được cấu hình bằng một file import. Bài tiếp theo, vẫn trong Chương 3, nhìn sang phía bên kia: tích hợp Keycloak hoặc Spring Authorization Server — tự dựng authorization server.