Command Palette

Search for a command to run...

[Advanced Spring Boot] OAuth2 và OpenID Connect trong Spring Boot: OAuth2 Login và resource server với JWT

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.

Một issuer hình chiếc chìa khoá trao hai token cho web client và API

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:

Bash
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.zip

Id 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:

web/build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client'

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-clientspring-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ì:

Bash
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>'
Text
  <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:

keycloak/catalogue-realm.json
{
  "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-client là 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.uris là địa chỉ Keycloak được phép đưa browser về sau khi logout.
  • Audience mapper thêm catalogue-api vào claim aud của các access token cấp cho web-clientreporting-service. Sau này API sẽ kiểm tra giá trị đó; partner-service không có mapper nên token của nó trượt ở bước kiểm tra ấy.
  • reporting-service chỉ có service account: nó tự lấy token cho mình bằng client credentials grant, không có user nào tham gia.
  • id của user được cố định để claim sub giống nhau ở mọi lần import. bob có realm role ADMIN; alice không có role nào.
  • User được import nhận đúng các realm role được liệt kê. Role default-roles-catalogue của Keycloak không được gán cho alice hay bob, nên token của alice hoàn toàn không có claim realm_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:

keycloak/other-realm.json
{
  "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

Bash
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=true
  • start-dev chạ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=true ghi 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 master và admin console, thứ mà bài 14 dùng tới. Bài này không cần đến nó.
Text
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:8080

Việ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:

Bash
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}'
JSON
{
  "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""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 specTrong lab nàyNắm giữ gì
Resource ownerhai user alicebobmột mật khẩu mà chỉ Keycloak kiểm tra
Clientweb client ở 8213, đăng ký là web-client; danh tính dịch vụ reporting-servicemột client secret, và một session cho mỗi user đã login
Authorization server, trong OIDC gọi là OpenID ProviderKeycloak, realm catalogue, issuer http://localhost:8313/realms/catalogueuser, client, role và các signing key
Resource serverAPI ở 9213URL 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 openid sẽ 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

web/src/main/resources/application.properties
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/catalogue
  • keycloak là registration id. Nó xuất hiện trong hai path mà Spring Security phục vụ: /oauth2/authorization/keycloak bắt đầu một lần login, và /login/oauth2/code/keycloak nhận authorization code, theo template redirect URI mặc định {baseUrl}/{action}/oauth2/code/{registrationId}.
  • client-idclient-secret là các giá trị trong file realm.
  • scope phải chứa openid để có một lần login OpenID Connect kèm ID token.
  • issuer-uri thay 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:

web/src/main/java/com/example/web/security/SecurityConfig.java
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:

web/src/main/java/com/example/web/account/AccountController.java
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();
    }
}
web/src/main/java/com/example/web/account/AccountResponse.java
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:

Text
DefaultSecurityFilterChain: Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

OAuth2AuthorizationRequestRedirectFilter 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:

Text
"GET /realms/catalogue/.well-known/openid-configuration HTTP/1.1" 200 6628

Spring 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:

Text
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 refused

Exception 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:

flow.sh
#!/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/&amp;/\&/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
Bash
./flow.sh alice Wonderland-2026 out/flow

Cá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
HTTP/1.1 302
Set-Cookie: JSESSIONID=98FDE59B4FF470163378DA265D1D9CDF; Path=/; HttpOnly
Location: http://localhost:8213/oauth2/authorization/keycloak

ExceptionTranslationFilter 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
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
ParameterGiá trịMục đích
response_typecodexin một authorization code
client_idweb-clientclient nào đang xin
scopeopenid profile emailopenid 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_urihttp://localhost:8213/login/oauth2/code/keycloakphải trùng một URI đã đăng ký cho client
nonceqXI1MCVM…8jQ0giá trị ngẫu nhiên phải quay lại bên trong ID token, chống replay token
code_challenge8UTXb4Pt…Z82YSHA-256 của một code_verifier ngẫu nhiên, mã hoá Base64URL
code_challenge_methodS256cá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_HASHKC_RESTART, đều với Path=/realms/catalogue/, và form của nó post tới:

Text
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=eyJydSI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODIxMy9sb2dpbi9vYXV0aDIvY29kZS9rZXljbG9hayIsInJ0IjoiY29kZSIsInN0IjoiLUYwOUttV0JuVWp2dTVuUkxueDBPRUNxYW5KZjk1YXppblM0LWFSeXFsMD0ifQ

Mậ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, passwordcredentialId rỗng:

Http
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-b40f62afe290

Callback 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
HTTP/1.1 302
Set-Cookie: JSESSIONID=DBD16E3F828AA2D5C590FD0D9FABF48C; Path=/; HttpOnly
Location: http://localhost:8213/me?continue

Trước khi trả lời, web client tự gọi Keycloak ba lần. Access log của Keycloak trong giây đó:

Text
"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 193
  • POST …/token đổi authorization code, code_verifier và client secret lấy một access token, một ID token và một refresh token.
  • GET …/certs tải các public key để verify chữ ký của ID token, trước khi kiểm tra iss, aud, expnonce của nó.
  • GET …/userinfo tải các claim của user bằng access token. Ở 7.1.1, OidcUserRequestUtils.shouldRetrieveUserInfo chỉ hỏi provider có userinfo endpoint hay không và grant có phải authorization_code hay không; OidcUserService của Spring Security 6.5 vẫn còn một tập accessibleScopes gồm profile, email, addressphone cho 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ẻ.

Ba làn browser, web client ở 8213 và Keycloak ở 8313: GET /me trả 302 tới /oauth2/authorization/keycloak; path đó trả 302 tới auth endpoint với response_type code, client_id web-client, scope openid profile email, state, redirect_uri, nonce và code_challenge S256; Keycloak trả trang login, POST username và password trả 302 tới /login/oauth2/code/keycloak với state, session_state, iss và code; callback gọi ba lời back-channel là POST token với code, code_verifier và secret, GET certs và GET userinfo, rồi trả 302 tới /me?continue với JSESSIONID mới, và GET /me trả 200

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_challengecode_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:

Bash
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/token
Http
HTTP/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:

CookiePathDo 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:

web/src/main/java/com/example/web/lab/TokenLogController.java
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:

Bash
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 .
ID token của bob
{
  "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"
}
access token của bob
{
  "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.

ClaimID tokenAccess token
typIDBearer
audweb-client: dành cho clientcatalogue-api: dành cho API, do audience mapper thêm vào
azpweb-clientweb-client: client đã lấy được nó
noncegiá trị ở bước 2không có
at_hashwfQkTQ1piUYK3urueWKUEAkhông có
scopekhông cóopenid email profile
realm_accesskhông có{"roles":["ADMIN"]}
allowed-originskhông có["http://localhost:8213"], cho CORS
thời hạn300 s300 s, mặc định của Keycloak
ai đọcweb client, một lần, lúc loginAPI, ở 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.

Bash
printf '%s' "$(cat access.jwt)" | openssl dgst -sha256 -binary | head -c 16 | b64url
Text
wfQkTQ1piUYK3urueWKUEA

Dò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:

JSON
{"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: expiatInstant, aud là một list.
  • Các authority là OIDC_USER cộng một authority SCOPE_ cho mỗi scope được cấp. Không có authority FACTOR_ nào: FACTOR_PASSWORD của bài Basics 33 và FACTOR_BEARER củ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ột createdBy sẽ hiển thị.

Dùng preferred_username làm name

web/src/main/resources/application.properties
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8313/realms/catalogue
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username 

Sau 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"):

Bash
curl -s -i -b out/flow-bob/jar http://localhost:8213/admin
Http
HTTP/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:

web/src/main/java/com/example/web/security/SecurityConfig.java
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; 
        }; 
    } 
  • OidcUserAuthority chính là authority OIDC_USER; nó mang ID token và các claim của userinfo, nơi một mapper tìm dữ liệu về user.
  • instanceof vớ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_ADMIN cho 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.

keycloak/catalogue-realm.json
      "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.claimfalse 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:

Bash
./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
Text
{"name":"bob","authorities":["SCOPE_openid","SCOPE_email","ROLE_ADMIN","SCOPE_profile","OIDC_USER"],"realm":{"roles":["ADMIN"]}}
HTTP/1.1 200
Admin area for bob

ID 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 đó:

Bash
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/logout
Http
HTTP/1.1 302
Location: http://localhost:8213/login?logout

Session 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:

Text
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:

web/src/main/java/com/example/web/security/SecurityConfig.java
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
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_endpoint lấy từ discovery document mà issuer-uri đã tải lúc khởi động.
  • id_token_hint là 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_uri{baseUrl}/ đã được thay giá trị, và Keycloak chỉ chấp nhận nó vì file realm liệt kê nó trong post.logout.redirect.uris.

Đi theo redirect đó:

Http
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, XOKTA. 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ả:

web/src/main/resources/application-social.properties
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-secret
Bash
java -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'
Http
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=S256

Trang /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:

GoogleGitHub
Authorization endpointhttps://accounts.google.com/o/oauth2/v2/authhttps://github.com/login/oauth/authorize
Scope mặc địnhopenid profile emailread:user
nonce trong requestkhông
Token endpointhttps://www.googleapis.com/oauth2/v4/tokenhttps://github.com/login/oauth/access_token
User infohttps://www.googleapis.com/oauth2/v3/userinfohttps://api.github.com/user
issuer-uri, JWK sethttps://accounts.google.com, https://www.googleapis.com/oauth2/v3/certskhông có
Thuộc tính làm namesubid
Principal sau khi loginOidcUser, authority OIDC_USEROAuth2User, authority OAUTH2_USER

Các giá trị là hằng chuỗi trong bytecode của CommonOAuth2Provider$1$2, còn tên authority là hằng của OidcUserAuthorityOAuth2UserAuthority. 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_clientThe 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:

api/src/main/resources/application.properties
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/catalogue

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.

api/src/main/java/com/example/api/common/SecurityConfig.java
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.

api/src/main/java/com/example/api/account/CallerController.java
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());
    }
}
api/src/main/java/com/example/api/account/CallerResponse.java
package com.example.api.account;
 
import java.util.List;
 
public record CallerResponse(String subject, String username, String clientId, List<String> audience,
                             List<String> authorities) {
}
api/src/main/java/com/example/api/report/ReportController.java
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 productsint 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ểmRequest API gửi tới Keycloak
API khởi độngkhông có
request đầu tiên có tokenGET …/.well-known/openid-configuration, rồi GET …/certs
request thứ hai, cùng keykhông có
một token có kid không nằm trong key set đã cacheGET …/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
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

Bash
curl -s -u reporting-service:reporting-service-secret -d grant_type=client_credentials \
  http://localhost:8313/realms/catalogue/protocol/openid-connect/token
JSON
{
  "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ã:

JSON
{
  "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:

JSON
{"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:

JSON
{"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:

Bash
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'; done
Text
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: 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ó ghi kid JJJ0huHP…, không có trong key set của catalogue; 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à JwtIssuerValidatorissuer-uri thê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:8080 nhận về token mà một API cấu hình http://localhost:8313 từ chối. Hãy cấu hình một hostname công khai duy nhất cho Keycloak, option hostname khi 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ó:

JSON
{"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:

api/src/main/resources/application.properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8313/realms/catalogue
spring.security.oauth2.resourceserver.jwt.audiences=catalogue-api 

Sau khi restart, token của partner nhận về:

Text
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 đó:

expiry-watch.sh
#!/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"
Text
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 GMT

Issuer 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ó.

Một bearer token đi vào API và qua bốn bước kiểm tra: chữ ký theo kid tìm trong JWKS mà Keycloak trả ở /certs, tải ở request đầu tiên và tải lại khi gặp kid lạ; iss bằng http://localhost:8313/realms/catalogue; exp cộng 60 giây skew; aud chứa catalogue-api; rồi claim thành authority, SCOPE_ từ scope, ROLE_ từ realm_access.roles và FACTOR_BEARER, và @PreAuthorize hasRole ADMIN. Mỗi bước thất bại rẽ sang error_description của 401 WWW-Authenticate: no matching key(s) found cho realm khác, Invalid signature cho token bị sửa, Jwt expired at 07:22:45Z, The iss claim is not valid cho token 127.0.0.1, The aud claim is not valid cho partner-service

Map realm_access.roles cho @PreAuthorize

Token của bob có "realm_access":{"roles":["ADMIN"]}, và /api/reports/stock đòi hasRole('ADMIN'):

Http
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:

Bash
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=TRACE

Các authority của bob chỉ còn ["FACTOR_BEARER"], và report vẫn 403. Log TRACE nói lý do:

Text
JwtGrantedAuthoritiesConverter: Returning no authorities since could not find any claims that might contain scopes

JwtGrantedAuthoritiesConverter 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ý:

Bash
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ứcAuthority của bob/api/reports/stock
['realm_access']['roles']FACTOR_BEARER, ROLE_ADMIN200 {"products":3,"unitsInStock":128}
[realm_access][roles]FACTOR_BEARER, ROLE_ADMIN200
realm_access.rolesFACTOR_BEARER403

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:

api/src/main/java/com/example/api/common/SecurityConfig.java
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:

TokenAuthority ở API/api/reports/stock
bobFACTOR_BEARER, SCOPE_openid, SCOPE_email, ROLE_ADMIN, SCOPE_profile200
alice, không có claim realm_accessSCOPE_openid, SCOPE_email, FACTOR_BEARER, SCOPE_profile403
reporting-serviceROLE_offline_access, SCOPE_email, ROLE_uma_authorization, FACTOR_BEARER, ROLE_default-roles-catalogue, SCOPE_profile403

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():

web/src/main/java/com/example/web/catalogue/CatalogueApiConfig.java
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();
    }
}
web/src/main/java/com/example/web/catalogue/CatalogueController.java
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);
    }
}
  • OAuth2AuthorizedClientManager là 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.
  • setClientRegistrationIdResolver cố đị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ụ.
  • ApiCaller là một record có năm thành phần giống CallerResponse của API, và catalogue-api.base-url=http://localhost:9213 nằm trong application.properties.

GET /catalogue/caller của bob trả về những gì API đã thấy:

JSON
{"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:

web/src/main/resources/application.properties
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:9213

Registration client_credentials không xuất hiện trên trang login. RestClient của nó dùng một manager khác:

web/src/main/java/com/example/web/reporting/ReportingApiConfig.java
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();
    }
}
  • AuthorizedClientServiceOAuth2AuthorizedClientManager là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 trong OAuth2AuthorizedClientService mà 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-service qua 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:

count-token-calls.sh
#!/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/caller

Trên một web client vừa khởi động:

Text
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/caller trả "username":"service-account-reporting-service""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. ClientCredentialsOAuth2AuthorizedClientProviderRefreshTokenOAuth2AuthorizedClientProvider đề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.

Hai cột nối bằng một mũi tên: web client đóng vai OAuth2 client giữ state, browser chỉ giữ JSESSIONID, HttpSession giữ OAuth2AuthenticationToken với DefaultOidcUser và OIDC_USER, SCOPE_, ROLE_ADMIN, cùng OAuth2AuthorizedClient với access token 300 giây và refresh token; RestClient với OAuth2ClientHttpRequestInterceptor gửi Authorization Bearer tới API, API đóng vai resource server không giữ gì theo user, BearerTokenAuthenticationFilter và JwtDecoder từ issuer-uri tạo JwtAuthenticationToken với SCOPE_, ROLE_ADMIN và FACTOR_BEARER cho mỗi request; bên dưới là token client credentials của reporting-service, xin một lần cho mười lời gọi và làm mới 60 giây trước exp

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_challengecode_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_grantPKCE 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?

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.claimtrue, cùng mapper đó tạo ra ROLE_ADMIN/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 JwtGrantedAuthoritiesConverterExpressionJwtGrantedAuthoritiesConverter.

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?

/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_hintpost_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-starterkeycloak-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.

Bài viết liên quan

[Advanced Spring Boot] Locking và concurrency trong Spring Boot: optimistic @Version, pessimistic lock và race condition

Locking và concurrency trong Spring Boot 4.1.1 trên PostgreSQL: lost update khi hai người cùng sửa một product, @Version và câu update … where version=? nó gửi đi, chuỗi exception tới được code của bạn, saveAll, dirty checking và bulk update @Modifying bỏ qua version, version qua HTTP trả về 409 ProblemDetail, retry một conflict bao quanh cả transaction và cách đặt retry khiến nó không bao giờ retry, PESSIMISTIC_WRITE so với PESSIMISTIC_READ, NOWAIT và jakarta.persistence.lock.timeout thành set local lock_timeout, SKIP LOCKED cho work queue, một deadlock thật (40P01) và cách sửa, atomic update có điều kiện, CHECK constraint, và một bảng để chọn giữa chúng.

[Advanced Spring Boot] Nhiều datasource trong Spring Boot: hai database và định tuyến read/write replica

Nhiều datasource trong Spring Boot 4.1.1 với PostgreSQL: Boot lùi lại những gì khi có hai bean DataSource, DataSourceProperties và bẫy prefix của HikariCP, hai EntityManagerFactory dựng bằng EntityManagerFactoryBuilder, @EnableJpaRepositories và @Transactional(transactionManager), repository chạy dưới transaction manager sai, Flyway cho database thứ hai, vì sao ghi vào hai database không atomic, streaming replica trong Docker, AbstractRoutingDataSource và bug cờ read-only, LazyConnectionDataSourceProxy cùng read-only DataSource có sẵn, SQLSTATE 25006, replication lag và các cách giữ read-your-writes, mỗi đích một connection pool HikariCP.

[Advanced Spring Boot] Tự dựng authorization server: Spring Authorization Server và Keycloak

Dựng một authorization server OAuth2 và OpenID Connect bằng Spring Authorization Server, nay là một module của Spring Security, trên Spring Boot 4.1.1: starter mà Initializr chọn và starter đã deprecated, một server chỉ từ property, hai discovery document và các endpoint chúng công bố, hai filter chain Boot đăng ký và những gì thay đổi khi bạn tự khai báo, client_credentials và authorization_code với PKCE từng bước, body lỗi thật, RSA key đổi sau mỗi lần restart, key cố định và key rotation với JWK selector, JWKS cache của resource server, client, authorization và consent lưu bằng JDBC trên PostgreSQL với schema script lấy từ jar, claim roles từ OAuth2TokenCustomizer và cái bẫy allowlist của Jackson, opaque token với introspection được đo so với việc validate JWT, và phép so sánh có đo đạc với Keycloak.

[Advanced Spring Boot] NoSQL với Spring Data: MongoDB và Redis

Spring Data MongoDB và Spring Data Redis trên Spring Boot 4.1.1: @Document, id là String hay ObjectId, field _class, embedded hay @DocumentReference cùng các query mỗi cách gửi đi, MongoRepository và MongoTemplate kèm command đã log, $push/$inc so với load-modify-save dưới 50 thread, @Version, Boot có tạo index từ @Indexed không, COLLSCAN và IXSCAN trên 300.000 document, một aggregation pipeline, transaction trên replica set, một field bị đổi tên, serializer của RedisTemplate, INCR, sorted set, hash, TTL, key của @RedisHash và phantom key, pipelining và Lettuce.