Chuyển đến nội dung chính

Bài 8: Client Scopes, Token Management và DPoP

Client Scopes (default và optional), scope parameters, consent settings, realm default scopes, evaluating scopes, quản lý Access/ID/Refresh Token lifecycle, session và token timeouts, offline access, token revocation, lightweight access tokens, DPoP (RFC 9449), và Client Policies cho token security.

🔒 DevSecOps — Bài 8 Bài 8: Client Scopes, Token Management và DPoP

Keycloak từ Cơ bản đến Nâng cao

Phần 2: SSO Protocols - OpenID Connect và SAML

xdev.asia

1. Client Scopes

Client Scopes là cơ chế quản lý nhóm protocol mappers và roles có thể được chia sẻ giữa nhiều clients. Thay vì thêm mappers vào từng client riêng lẻ, bạn tạo Client Scope và gán cho các clients cần.

1.1 Default Scopes vs Optional Scopes

LoạiMô tảKhi nào claims được thêm vào token
Default Client ScopesTự động áp dụng cho mọi token requestLuôn luôn — không cần request explicit
Optional Client ScopesChỉ áp dụng khi client request explicit trong scope parameterChỉ khi client gửi scope=scope_name

Ví dụ:

# Default scopes — luôn có trong token
# profile, email, roles, web-origins, acr → tự động áp dụng

Optional scopes — chỉ khi request

address, phone, offline_access, microprofile-jwt

Authorization request với optional scope

GET /auth?response_type=code& client_id=my-app& scope=openid profile email phone address offline_access& redirect_uri=...

1.2 Built-in Client Scopes

Keycloak cung cấp sẵn các client scopes theo chuẩn OIDC:

ScopeLoạiClaims được thêm
openidDefaultsub, iss, aud, exp, iat, auth_time, nonce, acr, session_state
profileDefaultname, family_name, given_name, preferred_username, gender, birthdate, locale, updated_at
emailDefaultemail, email_verified
rolesDefaultrealm_access.roles, resource_access.{client}.roles
web-originsDefaultallowed-origins (CORS)
acrDefaultacr (Authentication Context Class Reference)
addressOptionaladdress (formatted, street_address, locality, region, postal_code, country)
phoneOptionalphone_number, phone_number_verified
offline_accessOptionalCho phép lấy offline refresh token
microprofile-jwtOptionalupn, groups (MicroProfile JWT spec)

1.3 Tạo Client Scope mới

  1. Vào Client scopes → Create client scope

  2. Nhập thông tin:

    • Name: my-custom-scope
    • Description: Mô tả scope
    • Type: Default / Optional / None
    • Display on consent screen: ON nếu muốn hiển thị cho user
    • Consent screen text: Text hiển thị trên consent screen
    • Include in token scope: ON để scope name xuất hiện trong scope claim của token
    • GUI order: Thứ tự hiển thị trên consent screen
  3. Thêm Protocol Mappers vào scope

  4. Thêm Scope (role scope mappings) nếu cần giới hạn roles

# Ví dụ: Tạo scope "billing" chứa billing-related claims
Name: billing
Type: Optional
Display on consent screen: ON
Consent screen text: "Access your billing information"
Include in token scope: ON

# Thêm Protocol Mappers:
# 1. User Attribute Mapper: billing_plan → billing_plan claim
# 2. User Attribute Mapper: billing_email → billing_email claim
# 3. Hardcoded Claim: billing_api_version → "v2"

1.4 Gán Client Scope cho Client

  1. Mở client → tab Client scopes

  2. Click Add client scope

  3. Chọn scope và gán là Default hoặc Optional

# Gán scope bằng Admin CLI
# Lấy client UUID
CLIENT_UUID=$(bin/kcadm.sh get clients -r my-company \
  -q clientId=my-app --fields id --format csv --noquotes)

# Lấy client scope UUID
SCOPE_UUID=$(bin/kcadm.sh get client-scopes -r my-company \
  -q name=billing --fields id --format csv --noquotes)

# Gán default scope
bin/kcadm.sh update clients/$CLIENT_UUID/default-client-scopes/$SCOPE_UUID \
  -r my-company

# Gán optional scope
bin/kcadm.sh update clients/$CLIENT_UUID/optional-client-scopes/$SCOPE_UUID \
  -r my-company

1.5 Realm Default Client Scopes

Realm Default Client Scopes tự động được gán cho mọi client mới khi tạo:

  1. Vào Client scopes → xem danh sách

  2. Scopes có Assigned type = Default hoặc Optional ở realm level sẽ tự động gán cho clients mới

Cấu hình qua Admin CLI:

# Thêm scope vào realm default scopes
bin/kcadm.sh update realms/my-company/default-default-client-scopes/$SCOPE_UUID

Thêm scope vào realm optional scopes

bin/kcadm.sh update realms/my-company/default-optional-client-scopes/$SCOPE_UUID

Khi client có Consent required = ON, user phải đồng ý (consent) cho từng scope trước khi client nhận tokens:

  • Mỗi Client Scope có thể cấu hình Display on consent screen và Consent screen text

  • User có thể revoke consent trong Account Console → Applications

  • Consent entries được lưu per user per client

# Consent screen hiển thị:
# ┌────────────────────────────────────────────┐
# │  My Application muốn:                      │
# │                                             │
# │  ☑ Access your profile information          │   ← scope: profile
# │  ☑ Access your email address                │   ← scope: email
# │  ☐ Access your billing information          │   ← scope: billing (optional)
# │  ☐ Access your phone number                 │   ← scope: phone (optional)
# │                                             │
# │  [Accept]  [Cancel]                         │
# └────────────────────────────────────────────┘

1.7 Evaluating Scopes (Scope Evaluation)

Admin Console cung cấp tool để preview token contents dựa trên scopes:

  1. Mở client → tab Client scopes → Evaluate

  2. Nhập: User (chọn user test), Scope parameter (optional scopes)

  3. Click Evaluate để xem:

    • Effective protocol mappers: Mappers sẽ được áp dụng
    • Effective role scope mappings: Roles có trong token
    • Generated access token: Preview JSON của access token
    • Generated ID token: Preview JSON của ID token
    • Generated user info: Preview JSON của userinfo response

Đây là công cụ rất hữu ích để debug token contents mà không cần thực sự request token.

2. Token Management

2.1 Access Token

Access token là JWT chứa authorization information — xác định user nào có quyền truy cập resource nào.

Cấu trúc Access Token:

{
  "exp": 1711800300,         // Expiration time
  "iat": 1711800000,         // Issued at
  "auth_time": 1711799900,   // Authentication time
  "jti": "token-id",         // JWT ID (unique)
  "iss": "http://localhost:8080/realms/my-company",  // Issuer
  "aud": ["my-app", "account"],                      // Audience
  "sub": "user-uuid",        // Subject (user ID)
  "typ": "Bearer",           // Token type
  "azp": "my-app",           // Authorized party (client ID)
  "session_state": "session-id",
  "acr": "1",                // Authentication Context Class Reference
  "scope": "openid profile email",
  "sid": "session-id",       // Session ID
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "john",
  "given_name": "John",
  "family_name": "Doe",
  "email": "[email protected]",
  "realm_access": {
    "roles": ["default-roles-my-company", "admin"]
  },
  "resource_access": {
    "my-app": {
      "roles": ["app-admin"]
    },
    "account": {
      "roles": ["manage-account"]
    }
  }
}

2.2 ID Token

ID Token chứa identity information — xác nhận user là ai. Chỉ dành cho client (Relying Party), KHÔNG gửi đến resource server.

{
  "exp": 1711800300,
  "iat": 1711800000,
  "auth_time": 1711799900,
  "jti": "id-token-id",
  "iss": "http://localhost:8080/realms/my-company",
  "aud": "my-app",           // Audience = client ID
  "sub": "user-uuid",
  "typ": "ID",
  "azp": "my-app",
  "nonce": "nonce-value",    // Phải match với authorization request
  "session_state": "session-id",
  "at_hash": "access-token-hash",  // Hash of access token
  "acr": "1",
  "sid": "session-id",
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "john",
  "email": "[email protected]"
}

2.3 Refresh Token

Refresh token dùng để lấy access token mới mà không cần user đăng nhập lại. Refresh token có lifespan dài hơn access token.

# Refresh access token
POST /realms/my-company/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
refresh_token=REFRESH_TOKEN&
client_id=my-app&
client_secret=CLIENT_SECRET

# Response — access token mới
{
  "access_token": "new-access-token",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "new-refresh-token",   // Refresh token mới (rotation)
  "token_type": "Bearer"
}

2.4 Session và Token Timeouts

Cấu hình trong Realm Settings → Tokens tab và Sessions tab:

Token Lifespans:

SettingMô tảGiá trị khuyến nghị
Access Token LifespanThời gian sống access token5 phút (production)
Client login timeoutThời gian tối đa hoàn thành login flow5 phút
Login timeoutThời gian tối đa trên login page30 phút
Login action timeoutThời gian cho required actions (verify email,...)5 phút
User-Initiated Action LifespanThời gian cho user-initiated actions5 phút
Default Admin-Initiated Action LifespanThời gian cho admin-initiated actions (reset password link)12 giờ

Session Lifespans:

SettingMô tảGiá trị khuyến nghị
SSO Session IdleSession hết hạn sau khoảng thời gian không hoạt động30 phút
SSO Session MaxSession hết hạn tuyệt đối (bất kể hoạt động)10 giờ
SSO Session Idle Remember MeSession idle khi "Remember Me" ON30 ngày
SSO Session Max Remember MeSession max khi "Remember Me" ON30 ngày
Client Session IdleClient session idle (ảnh hưởng refresh token)Kế thừa SSO Session Idle
Client Session MaxClient session max (ảnh hưởng refresh token)Kế thừa SSO Session Max

Mối quan hệ giữa sessions và tokens:

# Refresh token expiration = MIN(Client Session Idle, Client Session Max)
# Nếu Client Session = 0 → dùng SSO Session values

Ví dụ:

SSO Session Idle = 30 phút

SSO Session Max = 10 giờ

Access Token Lifespan = 5 phút

Client Session Idle = 0 (kế thừa SSO)

Client Session Max = 0 (kế thừa SSO)

→ Access token sống 5 phút

→ Refresh token sống tối đa 30 phút (idle) hoặc 10 giờ (max)

→ Nếu user hoạt động liên tục, session kéo dài đến SSO Session Max

Override ở Client level:

Mỗi client có thể override realm-level token settings trong tab Advanced:

Client → Advanced → Advanced Settings:
  Access Token Lifespan: 60        # Override: 1 phút cho high-security API
  Client Session Idle: 900         # Override: 15 phút idle
  Client Session Max: 3600         # Override: 1 giờ max

2.5 Revoke Refresh Token (Rotation)

Khi bật Revoke Refresh Token, mỗi lần sử dụng refresh token để lấy access token mới, refresh token cũ bị thu hồi và phát hành refresh token mới.

# Realm Settings → Tokens tab
Revoke Refresh Token: ON
Refresh Token Max Reuse: 0        # Refresh token chỉ dùng 1 lần
                                    # > 0: cho phép reuse N lần (cho network retry)

Tại sao cần Refresh Token Rotation?

  • Nếu refresh token bị đánh cắp, attacker chỉ có thể dùng 1 lần

  • Legitimate client dùng cùng refresh token → cả hai đều bị invalidate → phát hiện token theft

  • Đây là best practice được khuyến nghị trong OAuth 2.0 Security BCP

3. Offline Access

Offline tokens cho phép client truy cập resources ngay cả khi user không online (không có browser session). Offline tokens có lifespan rất dài và survive qua server restart.

3.1 Cấu hình Offline Access

  1. Đảm bảo scope offline_access được gán cho client (optional scope)

  2. Client request token với scope=offline_access

  3. Cấu hình offline session timeouts trong Realm Settings → Sessions tab:

SettingMô tảGiá trị khuyến nghị
Offline Session IdleOffline session hết hạn sau idle30 ngày
Offline Session Max LimitedBật giới hạn max lifetimeON
Offline Session MaxOffline session max lifetime60 ngày
# Request offline token
GET /auth?response_type=code&
  client_id=my-app&
  scope=openid offline_access&
  redirect_uri=...

# Token response — refresh_token là offline token
{
  "access_token": "...",
  "expires_in": 300,
  "refresh_expires_in": 0,          // 0 = offline token (không expire theo session)
  "refresh_token": "offline-token",
  "token_type": "Bearer",
  "scope": "openid offline_access"
}

# Sử dụng offline token để refresh
POST /token
grant_type=refresh_token&
refresh_token=offline-token&
client_id=my-app&
client_secret=CLIENT_SECRET

3.2 Quản lý Offline Sessions

  • Admin Console → Sessions → tab Offline Sessions: xem tất cả offline sessions

  • User Account Console → Sessions: user có thể xem và revoke offline sessions

  • Admin REST API: revoke offline sessions programmatically

# Revoke offline session cho user cụ thể
DELETE /admin/realms/my-company/users/{user-id}/consents/{client-id}

# Revoke tất cả sessions (bao gồm offline) cho user
POST /admin/realms/my-company/users/{user-id}/logout

4. Token Revocation

4.1 Token Revocation Endpoint (RFC 7009)

Keycloak hỗ trợ Token Revocation endpoint cho phép client thu hồi access token hoặc refresh token:

# Revoke refresh token
POST /realms/my-company/protocol/openid-connect/revoke
Content-Type: application/x-www-form-urlencoded

token=REFRESH_TOKEN&
token_type_hint=refresh_token&
client_id=my-app&
client_secret=CLIENT_SECRET

# Revoke access token
POST /realms/my-company/protocol/openid-connect/revoke
Content-Type: application/x-www-form-urlencoded

token=ACCESS_TOKEN&
token_type_hint=access_token&
client_id=my-app&
client_secret=CLIENT_SECRET

Lưu ý:

  • Revoke refresh token → invalidate cả refresh token và associated SSO session (tùy cấu hình)

  • Revoke access token → với JWT-based tokens, revocation chỉ effective mà resource server thực hiện token introspection hoặc sử dụng Token Revocation Events

4.2 Not-Before Policy

Thu hồi tất cả tokens issued trước một thời điểm:

# Set not-before timestamp — tất cả tokens issued trước thời điểm này bị invalidate
PUT /admin/realms/my-company
{
  "notBefore": 1711800000   // Unix timestamp
}

# Hoặc qua Admin Console:
# Realm Settings → Sessions → "Set to now" → "Push"
# "Push" gửi not-before policy đến tất cả clients có Admin URL

4.3 Token Introspection (RFC 7662)

Resource server sử dụng Token Introspection để xác minh token validity và lấy claims:

# Introspect token
POST /realms/my-company/protocol/openid-connect/token/introspect
Content-Type: application/x-www-form-urlencoded

token=ACCESS_TOKEN&
client_id=my-resource-server&
client_secret=RESOURCE_SERVER_SECRET

# Response — token hợp lệ
{
  "active": true,
  "sub": "user-uuid",
  "email": "[email protected]",
  "realm_access": { "roles": ["admin"] },
  "client_id": "my-app",
  "token_type": "Bearer",
  "exp": 1711800300,
  "iat": 1711800000,
  "scope": "openid profile email"
}

# Response — token không hợp lệ
{
  "active": false
}

Khi nào dùng Token Introspection vs JWT Verification:

Phương phápƯu điểmNhược điểm
JWT Verification (local)Nhanh, không cần gọi KeycloakKhông real-time, revocation delay
Token Introspection (remote)Real-time status, full claimsNetwork latency, dependency on Keycloak

5. DPoP — Demonstration of Proof-of-Possession (RFC 9449)

DPoP giải quyết vấn đề Bearer Token theft — nếu access token bị đánh cắp, attacker có thể sử dụng ở bất kỳ đâu vì không có binding giữa token và client.

5.1 DPoP hoạt động thế nào?

DPoP ràng buộc token với một asymmetric key pair cụ thể do client sở hữu. Client phải chứng minh quyền sở hữu private key mỗi khi sử dụng token.

┌──────────┐                     ┌──────────┐
│  Client  │                     │ Keycloak │
└────┬─────┘                     └────┬─────┘
     │                                │
     │  1. Generate key pair          │
     │     (public + private key)     │
     │                                │
     │  2. Token request +            │
     │     DPoP Proof (signed with    │
     │     private key)               │
     │───────────────────────────────>│
     │                                │
     │  3. DPoP-bound access token    │
     │     (contains cnf.jkt claim)   │
     │<──────────────────────────────│
     │                                │
     │                          ┌──────────┐
     │                          │ Resource │
     │                          │  Server  │
     │                          └────┬─────┘
     │  4. API request +             │
     │     DPoP-bound token +        │
     │     DPoP Proof (new, signed   │
     │     with same private key)    │
     │──────────────────────────────>│
     │                               │
     │  5. Verify: token.cnf.jkt     │
     │     matches DPoP proof's      │
     │     public key                │
     │  6. Response                  │
     │<─────────────────────────────│

5.2 DPoP Proof JWT Structure

// DPoP Proof Header
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "base64url-encoded-x",
    "y": "base64url-encoded-y"
  }
}

// DPoP Proof Payload { "jti": "unique-proof-id", // Unique ID, ngăn replay attacks "htm": "POST", // HTTP method "htu": "https://keycloak/token", // HTTP URI (token endpoint) "iat": 1711800000, // Issued at "ath": "access-token-hash" // Hash of access token (khi gọi resource server) }

5.3 Cấu hình DPoP trong Keycloak

DPoP được enforce thông qua Client Policies:

  1. Tạo Client Profile:

    • Realm Settings → Client Policies → Profiles tab → Create
    • Name: dpop-profile
    • Add Executor: DPoP Proof Verification
  2. Tạo Client Policy:

    • Policies tab → Create
    • Name: dpop-policy
    • Add Condition: Client Access Type hoặc Any Client
    • Associate Profile: dpop-profile
# Token request với DPoP
POST /realms/my-company/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6Ik...

grant_type=authorization_code&
code=AUTH_CODE&
client_id=my-app&
redirect_uri=http://localhost:3000/callback

# Response — DPoP-bound token
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "DPoP",              // Token type = "DPoP" thay vì "Bearer"
  "expires_in": 300
}

# Access token chứa confirmation claim
{
  "cnf": {
    "jkt": "thumbprint-of-client-public-key"   // JWK Thumbprint
  }
}

# Gọi Resource Server với DPoP token
GET /api/resource
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...          # DPoP proof mới (htm=GET, htu=API URL)

5.4 DPoP Nonce

Keycloak hỗ trợ server-issued DPoP nonce để tăng cường bảo mật chống replay attacks:

# Server response header khi DPoP nonce required
HTTP/1.1 401 Unauthorized
DPoP-Nonce: server-generated-nonce

# Client PHẢI include nonce trong DPoP proof tiếp theo
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": { ... }
}
{
  "jti": "new-unique-id",
  "htm": "POST",
  "htu": "https://keycloak/token",
  "iat": 1711800001,
  "nonce": "server-generated-nonce"    // Server-issued nonce
}

5.5 DPoP Implementation Example (JavaScript)

// Tạo DPoP key pair
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["sign", "verify"]
);

// Export public key cho DPoP proof const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);

// Tạo DPoP proof JWT function createDPoPProof(method, url, accessToken = null, nonce = null) { const header = { typ: "dpop+jwt", alg: "ES256", jwk: { kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, y: publicKey.y, }, };

const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), };

// Thêm access token hash khi gọi resource server if (accessToken) { const encoder = new TextEncoder(); const data = encoder.encode(accessToken); const hashBuffer = await crypto.subtle.digest("SHA-256", data); payload.ath = base64url(hashBuffer); }

// Thêm nonce nếu server yêu cầu if (nonce) { payload.nonce = nonce; }

return signJWT(header, payload, keyPair.privateKey); }

// Sử dụng const dpopProof = await createDPoPProof( "POST", "http://localhost:8080/realms/my-company/protocol/openid-connect/token" );

const tokenResponse = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", DPoP: dpopProof, }, body: "grant_type=authorization_code&code=AUTH_CODE&...", });

6. Client Policies cho Token Security

Client Policies cho phép enforce các security requirements liên quan đến tokens:

ExecutorMô tả
DPoP Proof VerificationBắt buộc DPoP cho token requests
Holder-of-key EnforcerBắt buộc token binding (MTLS hoặc DPoP)
Secure Signing AlgorithmChỉ cho phép algorithms an toàn (RS256, ES256,...)
PKCE EnforcerBắt buộc PKCE cho authorization code flow
Confidential Client EnforcerBắt buộc client authentication
Secure Response TypeChỉ cho phép response types an toàn
Reject Implicit GrantKhông cho phép implicit grant
# Ví dụ: Policy enforce DPoP + PKCE + Secure Algorithm
Profile: high-security-profile
  Executors:
    - DPoP Proof Verification
    - PKCE Enforcer (S256 only)
    - Secure Signing Algorithm (RS256, ES256)
    - Reject Implicit Grant
    - Confidential Client Enforcer

Policy: high-security-policy
  Conditions:
    - Client Role: has role "high-security"
  Profiles:
    - high-security-profile

7. Bài tập thực hành

Lab 1: Client Scopes

  1. Tạo Client Scope organization với mappers: org_id, org_name, org_role

  2. Gán scope cho client trước tiên là Default, sau đó đổi sang Optional

  3. Test: Request token không có scope parameter → không có org claims

  4. Test: Request token với scope=openid organization → có org claims

  5. Sử dụng Evaluate tool để preview token

Lab 2: Token Lifecycle

  1. Cấu hình Access Token Lifespan = 1 phút

  2. Bật Revoke Refresh Token, Refresh Token Max Reuse = 0

  3. Lấy token → chờ 1 phút → gọi API → nhận 401

  4. Refresh token → nhận token mới → gọi API → thành công

  5. Thử dùng refresh token cũ → nhận error (đã bị revoke)

Lab 3: Offline Access

  1. Gán scope offline_access cho client

  2. Lấy token với scope=openid offline_access

  3. Kiểm tra refresh_expires_in = 0 (offline token)

  4. Restart Keycloak → sử dụng offline token để refresh → vẫn hoạt động

  5. Xem offline sessions trong Admin Console

Lab 4: DPoP

  1. Tạo Client Policy enforce DPoP cho client dpop-client

  2. Generate key pair, tạo DPoP proof JWT

  3. Request token với DPoP header → nhận DPoP-bound token

  4. Gọi Resource Server với token nhưng không có DPoP proof → resource server reject

  5. Gọi Resource Server với token và DPoP proof → thành công

Lab 5: Token Revocation

  1. Lấy access token và refresh token

  2. Revoke refresh token qua revocation endpoint

  3. Thử refresh → nhận error

  4. Set Not-Before policy qua Admin Console → Push

  5. Thử dùng access token cũ → token introspection trả về active=false