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

Lesson 8: Client Scopes, Token Management and DPoP

Client Scopes (default and optional), scope parameters, consent settings, realm default scopes, evaluating scopes, managing Access/ID/Refresh Token lifecycle, session and token timeouts, offline access, token revocation, lightweight access tokens, DPoP (RFC 9449), and Client Policies for token security.

🔒 DevSecOps — Lesson 8 Lesson 8: Client Scopes, Token Management and DPoP

Keycloak from Basic to Advanced

Part 2: SSO Protocols - OpenID Connect and SAML

xdev.asia

1. Client Scopes

Client Scopes is a mechanism for managing groups of protocol mappers and roles that can be shared among multiple clients. Instead of adding mappers to each individual client, you create a Client Scope and assign it to the necessary clients.

1.1 Default Scopes vs Optional Scopes

TypeDescriptionWhen claims are added to token
Default Client ScopesAutomatically applied to all request tokensAlways — no need for explicit request
Optional Client ScopesOnly applies when the client requests explicitly in scope parameterOnly when the client sends scope=scope_name

For example:

# 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 provides available client scopes according to OIDC standards:

ScopeTypeClaims added
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_accessOptionalAllow to get offline refresh token
microprofile-jwtOptionalupn, groups (MicroProfile JWT spec)

1.3 Create new Client Scope

  1. Go to Client scopes → Create client scope

  2. Enter information:

    • Name: my-custom-scope
    • Description: Description scope
    • Type: Default / Optional / None
    • Display on consent screen: ON if you want to display it to user
    • Consent screen text: Text displayed on consent screen
    • Include in token scope: ON so that the scope name appears in the scope claim of token
    • GUI order: Order displayed on consent screen
  3. Add Protocol Mappers to scope

  4. Add Scope (role scope mappings) if needed to limit 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 Assign Client Scope to Client

  1. Open client → tab Client scopes

  2. Click Add client scope

  3. Select scope and assign it as Default or 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 are automatically assigned to every new client when created:

  1. Go to Client scopes → see list

  2. Scopes have Assigned type = Default or Optional at realm level will automatically assign to new clients

Configuration via 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

When the client has Consent required = ON, the user must consent for each scope before the client receives tokens:

  • Each Client Scope can configure Display on consent screen and Consent screen text

  • User can revoke consent in Account Console → Applications

  • Consent entries are saved 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 provides tools to preview token contents based on scopes:

  1. Open client → tab Client scopes → Evaluate

  2. Enter: User (select user test), Scope parameter (optional scopes)

  3. Click Evaluate to view:

    • Effective protocol mappers: Mappers will be applied
    • Effective role scope mappings: Roles included in token
    • Generated access token: Preview JSON of access token
    • Generated ID token: Preview JSON of ID token
    • Generated user info: Preview JSON of userinfo response

This is a very useful tool to debug token contents without actually requesting the token.

2. Token Management

2.1 Access Token

Access token is a JWT containing authorization information — determining which user has access to which resource.

Access Token structure:

{
  "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 contains identity information — confirms who user is. Only for clients (Relying Party), NOT sent to 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 is used to get a new access token without needing the user to log in again. Refresh tokens have a longer lifespan than access tokens.

# 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 and Token Timeouts

Configuration in Realm Settings → Tokens tab and Sessions tab:

Token Lifespans:

SettingDescriptionRecommended value
Access Token LifespanAccess token life time5 minutes (production)
Client login timeoutMaximum time to complete login flow5 minutes
Login timeoutMaximum time on login page30 minutes
Login action timeoutTime for required actions (verify email,...)5 minutes
User-Initiated Action LifespanTime for user-initiated actions5 minutes
Default Admin-Initiated Action LifespanTime for admin-initiated actions (reset password link)12 hours

Session Lifespans:

SettingDescriptionRecommended value
SSO Session IdleSession expires after inactivity period30 minutes
SSO Session MaxSession expires absolutely (regardless of activity)10 hours
SSO Session Idle Remember MeSession idle when "Remember Me" ON30 days
SSO Session Max Remember MeSession max when "Remember Me" ON30 days
Client Session IdleClient session idle (affects refresh token)Inherits SSO Session Idle
Client Session MaxClient session max (affects refresh token)Inherits SSO Session Max

Relationship between sessions and 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 at Client level:

Each client can override realm-level token settings in the Advanced:

tab
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)

When Revoke Refresh Token is enabled, each time a refresh token is used to get a new access token, the old refresh token is revoked and a new refresh token is issued.

# 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)

Why is it necessary to Refresh Token Rotation?

  • If the refresh token is stolen, the attacker can only use it once

  • Legitimate client uses the same refresh token → both are invalidated → token theft detected

  • This is best practice recommended in OAuth 2.0 Security BCP

3. Offline Access

Offline tokens allow client to access resources even when the user is not online (no browser session). Offline tokens have a very long lifespan and survive server restarts.

3.1 Offline Access Configuration

  1. Ensure scope offline_access is assigned to the client (optional scope)

  2. Client request token with scope=offline_access

  3. Configure offline session timeouts in Realm Settings → Sessions tab:

SettingDescriptionRecommended value
Offline Session IdleOffline session expires after idle30 days
Offline Session Max LimitedTurn on max lifetime limitON
Offline Session MaxOffline session max lifetime60 days
# 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 Managing Offline Sessions

  • Admin Console → Sessions → tab Offline Sessions: see all offline sessions

  • User Account Console → Sessions: user can view and 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 supports Token Revocation endpoint allowing clients to revoke access tokens or refresh tokens:

# 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

Note:

  • Revoke refresh token → invalidate both refresh token and associated SSO session (depending on configuration)

  • Revoke access token → with JWT-based tokens, revocation is only effective if the resource server performs token introspection or uses Token Revocation Events

4.2 Not-Before Policy

Revoke all tokens issued before a time:

# 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 uses Token Introspection to verify token validity and retrieve 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
}

When to use Token Introspection vs JWT Verification:

MethodAdvantagesDisadvantages
JWT Verification (local)Fast, no need to call KeycloakNo 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 solves the problem Bearer Token theft — if the access token is stolen, the attacker can use it anywhere because there is no binding between the token and the client.

5.1 How does DPoP work?

DPoP binds the token to a specific asymmetric key pair owned by the client. The client must prove ownership of the private key every time it uses the 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 Configure DPoP in Keycloak

DPoP is enforced through Client Policies:

  1. Create Client Profile:

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

    • Policies tab → Create
    • Name: dpop-policy
    • Add Condition: Client Access Type or 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 supports server-issued DPoP nonce to enhance security against 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 allow enforcement of security requirements related to tokens:

ExecutorDescription
DPoP Proof VerificationDPoP required for token requests
Holder-of-key EnforcerRequired token binding (MTLS or DPoP)
Secure Signing AlgorithmOnly allow secure algorithms (RS256, ES256,...)
PKCE EnforcerRequired PKCE for authorization code flow
Confidential Client EnforcerRequired client authentication
Secure Response TypeOnly allowed secure response types
Reject Implicit GrantDo not allow implicit grants
# 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. Practice exercises

Lab 1: Client Scopes

  1. Create Client Scope organization with mappers: org_id, org_name, org_role

  2. Assign scope to client first as Default, then change to Optional

  3. Test: Request token has no scope parameter → no org claims

  4. Test: Request token with scope=openid organization → yes org claims

  5. Use Evaluate tool to preview token

Lab 2: Token Lifecycle

  1. Configure Access Token Lifespan = 1 minute

  2. Turn on Revoke Refresh Token, Refresh Token Max Reuse = 0

  3. Get token → wait 1 minute → call API → receive 401

  4. Refresh token → receive new token → API call → success

  5. Try to refresh old token → get error (revoke)

Lab 3: Offline Access

  1. Assign scope offline_access to client

  2. Get token with scope=openid offline_access

  3. Check refresh_expires_in = 0 (offline token)

  4. Restart Keycloak → use offline token to refresh → still works

  5. Xem offline sessions trong Admin Console

Lab 4: DPoP

  1. Create Client Policy enforce DPoP for client dpop-client

  2. Generate key pair, create DPoP proof JWT

  3. Request token with DPoP header → receive DPoP-bound token

  4. Call Resource Server with token but no DPoP proof → resource server reject

  5. Call Resource Server with token and DPoP proof → success

Lab 5: Token Revocation

  1. Get access token and refresh token

  2. Revoke refresh token qua revocation endpoint

  3. Try to refresh → get error

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

  5. Try using old access token → token introspection returns active=false