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

Bài 6: OpenID Connect Clients - Cấu hình từ A đến Z

Tìm hiểu chi tiết về OIDC client types (public, confidential, bearer-only), tạo và cấu hình clients qua Admin Console, các OIDC auth flows (Authorization Code, Implicit, Client Credentials, Device Authorization, CIBA), thiết lập PKCE, CIBA policy và tích hợp thực tế với React và Spring Boot.

🔒 DevSecOps — Bài 6 Bài 6: OpenID Connect Clients - Cấu hình từ A đến Z

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

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

xdev.asia

1. Tổng quan OpenID Connect trong Keycloak

OpenID Connect (OIDC) là giao thức xác thực được xây dựng trên nền tảng OAuth 2.0. Keycloak hỗ trợ đầy đủ OIDC specification và mở rộng nhiều tính năng dành cho enterprise. Trong bài này, chúng ta sẽ đi sâu vào việc tạo, cấu hình và tích hợp OIDC clients.

OIDC Endpoints trong Keycloak

Keycloak cung cấp các endpoints chuẩn OIDC. Bạn có thể lấy toàn bộ thông tin endpoints qua Well-Known Configuration:

GET https://<keycloak-host>/realms/<realm-name>/.well-known/openid-configuration

Các endpoints quan trọng:

EndpointURL PatternMục đích
Authorization/realms/{realm}/protocol/openid-connect/authKhởi tạo authentication flow
Token/realms/{realm}/protocol/openid-connect/tokenLấy/refresh tokens
UserInfo/realms/{realm}/protocol/openid-connect/userinfoLấy thông tin user
Logout/realms/{realm}/protocol/openid-connect/logoutĐăng xuất (RP-Initiated Logout)
Token Introspection/realms/{realm}/protocol/openid-connect/token/introspectKiểm tra token validity
Token Revocation/realms/{realm}/protocol/openid-connect/revokeThu hồi token
JWKS/realms/{realm}/protocol/openid-connect/certsPublic keys cho JWT verification
Device Authorization/realms/{realm}/protocol/openid-connect/auth/deviceDevice Authorization Grant

2. OIDC Client Types

Keycloak hỗ trợ ba loại client chính, mỗi loại phù hợp với kiến trúc ứng dụng khác nhau:

2.1 Public Client

Client không thể bảo mật client secret — thường là ứng dụng chạy hoàn toàn trên browser hoặc mobile device.

  • Đặc điểm: Không có client secret, xác thực qua redirect URI

  • Use cases: Single Page Applications (React, Angular, Vue), Mobile apps, Desktop apps

  • Auth flow: Authorization Code + PKCE (bắt buộc)

  • Cấu hình: Client authentication = OFF

// Ví dụ: SPA không có backend — PHẢI dùng Public Client + PKCE
// Client KHÔNG lưu trữ secret, chỉ dùng code_verifier/code_challenge
Client ID: my-spa-app
Client authentication: OFF
Valid redirect URIs: http://localhost:3000/*
Web origins: http://localhost:3000

2.2 Confidential Client

Client có khả năng bảo mật client secret — thường là server-side applications.

  • Đặc điểm: Có client secret hoặc private key, xác thực khi gọi token endpoint

  • Use cases: Server-side web apps (Spring Boot, Django, .NET), Backend APIs, Service-to-service communication

  • Auth flow: Authorization Code, Client Credentials, hoặc cả hai

  • Cấu hình: Client authentication = ON

// Ví dụ: Spring Boot backend app — dùng Confidential Client
Client ID: my-backend-api
Client authentication: ON
Client secret: auto-generated hoặc custom
Valid redirect URIs: http://localhost:8081/login/oauth2/code/keycloak

2.3 Bearer-only Client (Legacy)

Client chỉ nhận và validate bearer tokens — không khởi tạo login flow.

  • Đặc điểm: Không có redirect URI, chỉ validate incoming tokens

  • Use cases: Pure API services, Microservices chỉ nhận requests đã xác thực

  • Lưu ý: Trong Keycloak 25+, bearer-only đã bị deprecated. Thay vào đó, tạo confidential client và chỉ bật Service accounts roles

Đặc điểmPublicConfidentialBearer-only (deprecated)
Client authenticationOFFONN/A
Client secretKhôngCóKhông
Có thể khởi tạo loginCóCóKhông
Redirect URIBắt buộcBắt buộcKhông
PKCEBắt buộcTùy chọnN/A
Use case chínhSPA, MobileServer appPure API

3. Tạo OIDC Client qua Admin Console

3.1 Các bước tạo Client

  1. Truy cập Admin Console → chọn realm → Clients → Create client

  2. General Settings:

    • Client type: OpenID Connect
    • Client ID: my-app (unique identifier)
    • Name: My Application (tên hiển thị)
    • Description: Mô tả client
    • Always display in UI: OFF
  3. Capability config:

    • Client authentication: ON (confidential) hoặc OFF (public)
    • Authorization: ON nếu cần fine-grained authorization
    • Authentication flow: chọn các flows phù hợp
  4. Login settings:

    • Root URL, Home URL, Valid redirect URIs, Valid post logout redirect URIs, Web origins

3.2 Tạo Client bằng Admin CLI

# Đăng nhập Admin CLI
bin/kcadm.sh config credentials \
  --server http://localhost:8080 \
  --realm master \
  --user admin \
  --password admin

Tạo confidential client

bin/kcadm.sh create clients -r my-company
-s clientId=my-backend-app
-s name="My Backend Application"
-s enabled=true
-s protocol=openid-connect
-s publicClient=false
-s 'redirectUris=["http://localhost:8081/*"]'
-s 'webOrigins=["http://localhost:8081"]'
-s serviceAccountsEnabled=true
-s directAccessGrantsEnabled=false

Tạo public client

bin/kcadm.sh create clients -r my-company
-s clientId=my-spa-app
-s name="My SPA Application"
-s enabled=true
-s protocol=openid-connect
-s publicClient=true
-s 'redirectUris=["http://localhost:3000/*"]'
-s 'webOrigins=["http://localhost:3000"]'
-s directAccessGrantsEnabled=false

3.3 Tạo Client bằng Admin REST API

# Lấy access token
ACCESS_TOKEN=$(curl -s -X POST \
  "http://localhost:8080/realms/master/protocol/openid-connect/token" \
  -d "client_id=admin-cli" \
  -d "username=admin" \
  -d "password=admin" \
  -d "grant_type=password" | jq -r '.access_token')

Tạo client

curl -s -X POST
"http://localhost:8080/admin/realms/my-company/clients"
-H "Authorization: Bearer $ACCESS_TOKEN"
-H "Content-Type: application/json"
-d '{ "clientId": "my-backend-app", "name": "My Backend Application", "enabled": true, "protocol": "openid-connect", "publicClient": false, "redirectUris": ["http://localhost:8081/*"], "webOrigins": ["http://localhost:8081"], "serviceAccountsEnabled": true, "directAccessGrantsEnabled": false, "attributes": { "pkce.code.challenge.method": "S256" } }'

4. Client Settings chi tiết

4.1 General Settings

SettingMô tảGhi chú
Client IDUnique identifier cho clientKhông thể thay đổi sau khi tạo
NameTên hiển thịHỗ trợ localization key: ${my-client-name}
DescriptionMô tả client
Always display in UILuôn hiển thị trên Account ConsoleDùng cho internal tools

4.2 Access Settings

SettingMô tảVí dụ
Root URLURL gốc, prepend vào relative URLshttp://localhost:3000
Home URLURL mặc định khi redirect về client/dashboard
Valid redirect URIsDanh sách redirect URIs hợp lệ (wildcard *)http://localhost:3000/*
Valid post logout redirect URIsURIs hợp lệ sau logout+ (kế thừa redirect URIs)
Web originsCORS allowed origins+ (kế thừa redirect URIs)
Admin URLURL cho backchannel operationsURL backend (logout, policy enforcement)

Lưu ý bảo mật cho redirect URIs:

  • KHÔNG BAO GIỜ sử dụng wildcard * làm redirect URI trong production — đây là lỗ hổng Open Redirect

  • Khai báo chính xác các redirect URIs cần thiết

  • Sử dụng HTTPS trong production

  • Tránh sử dụng localhost trong production redirect URIs

# ❌ KHÔNG NÊN — quá rộng, dễ bị Open Redirect attack
Valid redirect URIs: *

# ❌ KHÔNG NÊN — wildcard domain
Valid redirect URIs: https://*.example.com/*

# ✅ NÊN — khai báo chính xác
Valid redirect URIs:
  https://myapp.example.com/callback
  https://myapp.example.com/silent-renew

4.3 Capability Config

SettingMô tảKhi nào bật
Client authenticationON = confidential, OFF = publicON cho server apps
AuthorizationFine-grained authorization (UMA)Khi cần resource-based permissions
Standard flowAuthorization Code FlowHầu hết use cases
Direct access grantsResource Owner Password CredentialsLegacy apps (không khuyến nghị)
Implicit flowImplicit Grant (deprecated)Không nên dùng
Service accounts rolesClient Credentials GrantMachine-to-machine auth
OAuth 2.0 Device Authorization GrantDevice Code FlowSmart TV, CLI tools
OIDC CIBA GrantClient-Initiated Backchannel AuthBanking, telecom

4.4 Login Settings

SettingMô tả
Login themeTheme cho trang đăng nhập của client này
Consent requiredHiển thị consent screen cho user
Display client on screenHiển thị tên client trên consent screen
Client consent screen textText tùy chỉnh cho consent

4.5 Logout Settings

SettingMô tả
Front channel logoutLogout qua browser redirect (OpenID Connect Front-Channel Logout)
Backchannel logout URLURL nhận backchannel logout requests từ Keycloak
Backchannel logout session requiredBao gồm session ID trong logout token
Backchannel logout revoke offline sessionsThu hồi offline sessions khi logout
# Ví dụ Backchannel Logout URL cho Spring Boot
Backchannel logout URL: http://localhost:8081/logout/connect/back-channel/keycloak

# Ví dụ Front Channel Logout URL
Front channel logout URL: http://localhost:3000/logout-callback

5. OIDC Authentication Flows chi tiết

5.1 Authorization Code Flow

Đây là flow được khuyến nghị nhất cho hầu hết use cases. User được redirect đến Keycloak login page, sau khi xác thực thành công, Keycloak trả về authorization code, client đổi code lấy tokens.

┌──────────┐     ┌──────────┐     ┌──────────┐
│  User    │     │  Client  │     │ Keycloak │
│ (Browser)│     │  (App)   │     │  (IdP)   │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │  1. Click Login│                │
     │───────────────>│                │
     │                │ 2. Redirect    │
     │<───────────────│  /auth?        │
     │                │  response_type │
     │                │  =code&        │
     │                │  client_id=... │
     │ 3. Login page  │                │
     │───────────────────────────────>│
     │ 4. Enter credentials           │
     │───────────────────────────────>│
     │ 5. Redirect with code          │
     │<──────────────────────────────│
     │───────────────>│                │
     │                │ 6. Exchange    │
     │                │    code for    │
     │                │    tokens      │
     │                │───────────────>│
     │                │ 7. Tokens      │
     │                │<──────────────│
     │ 8. Authenticated│               │
     │<───────────────│                │

Request Authorization Code:

GET /realms/my-company/protocol/openid-connect/auth?
  response_type=code&
  client_id=my-app&
  redirect_uri=http://localhost:3000/callback&
  scope=openid profile email&
  state=random-state-value&
  nonce=random-nonce-value

Exchange Code for Tokens:

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

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

Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzUxMiIs...",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "not-before-policy": 0,
  "session_state": "a-session-id",
  "scope": "openid profile email"
}

5.2 Authorization Code Flow + PKCE

PKCE (Proof Key for Code Exchange, RFC 7636) bảo vệ Authorization Code Flow khỏi authorization code interception attack. Bắt buộc sử dụng cho Public Clients và khuyến nghị cho tất cả clients.

Cách hoạt động:

  1. Client tạo code_verifier (random string 43-128 ký tự)

  2. Client tính code_challenge = Base64URL(SHA256(code_verifier))

  3. Gửi code_challenge trong authorization request

  4. Gửi code_verifier trong token request — Keycloak verify bằng cách hash và so sánh

Cấu hình PKCE trong Keycloak:

Vào client → tab Advanced → Advanced Settings:

SettingGiá trịMô tả
Proof Key for Code Exchange Code Challenge MethodS256Bắt buộc PKCE với SHA-256 (khuyến nghị)
plainPKCE với plain text (không an toàn)
(empty)Không bắt buộc PKCE

PKCE flow:

# 1. Tạo code_verifier (client-side)
code_verifier="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

2. Tạo code_challenge = Base64URL(SHA256(code_verifier))

code_challenge="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

3. Authorization request với code_challenge

GET /realms/my-company/protocol/openid-connect/auth? response_type=code& client_id=my-spa-app& redirect_uri=http://localhost:3000/callback& scope=openid profile email& state=random-state& code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& code_challenge_method=S256

4. Token request với code_verifier

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

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

5.3 Implicit Flow (Deprecated)

KHÔNG NÊN sử dụng. OAuth 2.0 Security Best Current Practice (RFC 9700) khuyến nghị không dùng Implicit Flow vì tokens được trả về qua URL fragment, dễ bị đánh cắp qua browser history hoặc referrer header.

Thay thế: Sử dụng Authorization Code Flow + PKCE cho tất cả clients, kể cả SPAs.

Nếu buộc phải hỗ trợ legacy systems:

# Bật Implicit Flow trong client settings
Capability Config → Implicit flow: ON

Request (trả về token trực tiếp)

GET /realms/my-company/protocol/openid-connect/auth? response_type=id_token token& client_id=legacy-app& redirect_uri=http://localhost:3000/callback& scope=openid profile& state=random-state& nonce=random-nonce

5.4 Client Credentials Flow

Dành cho machine-to-machine authentication — không có user interaction. Client tự xác thực bằng credentials của chính nó.

Use cases:

  • Microservice gọi microservice

  • Backend batch jobs

  • Scheduled tasks cần truy cập API

  • CI/CD pipelines

Cấu hình:

  1. Tạo Confidential Client (Client authentication = ON)

  2. Bật Service accounts roles trong Capability Config

  3. Gán roles cho service account: Client → Service account roles tab

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

grant_type=client_credentials&
client_id=my-service&
client_secret=MY_CLIENT_SECRET&
scope=openid

# Response
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 300,
  "token_type": "Bearer",
  "not-before-policy": 0,
  "scope": "openid profile email"
}
# Lưu ý: KHÔNG có refresh_token và id_token trong Client Credentials flow

5.5 Device Authorization Grant (RFC 8628)

Dành cho thiết bị có hạn chế về input — Smart TV, IoT devices, CLI tools. User xác thực trên thiết bị khác (phone, laptop) bằng mã code.

Cấu hình:

  1. Client → Capability Config → bật OAuth 2.0 Device Authorization Grant

  2. Realm Settings → cấu hình OAuth Device Code lifespan (mặc định 600 giây)

# Bước 1: Device request — lấy device code và user code
POST /realms/my-company/protocol/openid-connect/auth/device
Content-Type: application/x-www-form-urlencoded

client_id=my-tv-app

# Response
{
  "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
  "user_code": "WDJB-MJHT",
  "verification_uri": "http://localhost:8080/realms/my-company/device",
  "verification_uri_complete": "http://localhost:8080/realms/my-company/device?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}

# Bước 2: Hiển thị user_code và verification_uri trên TV/device
# User truy cập verification_uri trên phone/laptop, nhập user_code, đăng nhập

# Bước 3: Device polling — kiểm tra xem user đã xác thực chưa
POST /realms/my-company/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:device_code&
client_id=my-tv-app&
device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS

# Response khi user chưa xác thực
{
  "error": "authorization_pending",
  "error_description": "The authorization request is still pending"
}

# Response khi user đã xác thực — nhận tokens
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "eyJhbGciOi...",
  "id_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 300
}

5.6 CIBA — Client-Initiated Backchannel Authentication (OIDC CIBA)

CIBA cho phép client khởi tạo authentication mà không cần redirect user qua browser. Thay vào đó, Keycloak gửi authentication request đến user qua channel khác (push notification, SMS, email).

Use cases:

  • Banking: Point-of-sale xác thực thanh toán qua mobile app

  • Telecom: Xác thực SIM-based authentication

  • Call centers: Agent xác thực customer qua phone

Cấu hình CIBA:

  1. Client → Capability Config → bật OIDC CIBA Grant

  2. Realm Settings → Authentication → tab CIBA Policy:

SettingMô tảGiá trị mặc định
Backchannel Token Delivery Modepoll, ping, hoặc pushpoll
Expires InThời gian hết hạn authentication request120 giây
IntervalKhoảng cách giữa các polling requests5 giây
Authentication Requested User HintLoại user hint: login_hint, login_hint_token, id_token_hintlogin_hint
# CIBA authentication request
POST /realms/my-company/protocol/openid-connect/ext/ciba/auth
Content-Type: application/x-www-form-urlencoded

client_id=my-pos-app&
client_secret=CLIENT_SECRET&
scope=openid&
[email protected]&
binding_message=Xac+nhan+thanh+toan+500k

# Response
{
  "auth_req_id": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 120,
  "interval": 5
}

# Polling for token (giống Device Auth)
POST /realms/my-company/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:openid:params:grant-type:ciba&
client_id=my-pos-app&
client_secret=CLIENT_SECRET&
auth_req_id=eyJhbGciOiJSUzI1NiIs...

Custom CIBA Authentication Channel Provider:

Mặc định Keycloak sử dụng CIBALoginUserResolver nội bộ. Để gửi push notification thực tế, bạn cần implement SPI custom:

// Implement interface CIBAAuthenticationChannelProvider
public class MyCIBAChannelProvider implements CIBAAuthenticationChannelProvider {
@Override
public void requestAuthentication(
    CIBALoginUserResolver.CIBALoginUser user,
    AuthenticationChannelRequest request) {
    // Gửi push notification đến user's device
    // binding_message: "Xác nhận thanh toán 500k"
    pushNotificationService.send(
        user.getDeviceToken(),
        request.getBindingMessage(),
        request.getAuthResultUrl()
    );
}

@Override
public boolean verifyAuthentication(String authResultId) {
    // Verify kết quả từ user's device
    return authResultStore.isApproved(authResultId);
}

}

6. Tích hợp OIDC Client với React (SPA)

6.1 Sử dụng keycloak-js adapter

Keycloak cung cấp JavaScript adapter chính thức cho SPAs:

# Cài đặt
npm install keycloak-js

Cấu hình Keycloak client cho React:

Client ID: my-react-app
Client authentication: OFF (public client)
Valid redirect URIs: http://localhost:3000/*
Valid post logout redirect URIs: http://localhost:3000/*
Web origins: http://localhost:3000
PKCE Code Challenge Method: S256

Khởi tạo Keycloak trong React:

// src/keycloak.ts
import Keycloak from "keycloak-js";

const keycloak = new Keycloak({ url: "http://localhost:8080", realm: "my-company", clientId: "my-react-app", });

export default keycloak;

// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import keycloak from "./keycloak";

keycloak
  .init({
    onLoad: "login-required", // hoặc 'check-sso'
    pkceMethod: "S256",
    checkLoginIframe: false, // tắt cho production tránh cookie issues
    silentCheckSsoRedirectUri:
      window.location.origin + "/silent-check-sso.html",
  })
  .then((authenticated) => {
    if (authenticated) {
      console.log("User is authenticated");
      console.log("Token:", keycloak.token);
      console.log("User info:", keycloak.tokenParsed);

      // Auto-refresh token trước khi hết hạn
      setInterval(() => {
        keycloak
          .updateToken(70) // refresh nếu token hết hạn trong 70 giây
          .then((refreshed) => {
            if (refreshed) {
              console.log("Token was refreshed");
            }
          })
          .catch(() => {
            console.error("Failed to refresh token");
            keycloak.login(); // redirect về login nếu refresh thất bại
          });
      }, 60000);

      ReactDOM.createRoot(
        document.getElementById("root") as HTMLElement
      ).render(
        <React.StrictMode>
          <App keycloak={keycloak} />
        </React.StrictMode>
      );
    } else {
      console.warn("Not authenticated");
      keycloak.login();
    }
  })
  .catch((error) => {
    console.error("Keycloak init failed:", error);
  });
// src/App.tsx
import Keycloak from "keycloak-js";

interface AppProps {
  keycloak: Keycloak;
}

function App({ keycloak }: AppProps) {
  const handleLogout = () => {
    keycloak.logout({
      redirectUri: window.location.origin,
    });
  };

  const callApi = async () => {
    // Tự động gắn Bearer token vào API calls
    const response = await fetch("http://localhost:8081/api/data", {
      headers: {
        Authorization: `Bearer ${keycloak.token}`,
      },
    });
    const data = await response.json();
    console.log(data);
  };

  return (
    <div>
      <h1>Welcome, {keycloak.tokenParsed?.preferred_username}</h1>
      <p>Email: {keycloak.tokenParsed?.email}</p>
      <p>Roles: {keycloak.tokenParsed?.realm_access?.roles?.join(", ")}</p>
      <button onClick={callApi}>Call API</button>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
}

export default App;

6.2 Sử dụng react-oidc-context (thay thế keycloak-js)

Một lựa chọn khác là sử dụng thư viện react-oidc-context dựa trên oidc-client-ts — không phụ thuộc vào Keycloak-specific adapter:

npm install react-oidc-context oidc-client-ts
// src/main.tsx
import { AuthProvider } from "react-oidc-context";

const oidcConfig = {
  authority: "http://localhost:8080/realms/my-company",
  client_id: "my-react-app",
  redirect_uri: "http://localhost:3000/callback",
  post_logout_redirect_uri: "http://localhost:3000",
  scope: "openid profile email",
  automaticSilentRenew: true,
};

ReactDOM.createRoot(document.getElementById("root")!).render(
  <AuthProvider {...oidcConfig}>
    <App />
  </AuthProvider>
);
// src/App.tsx
import { useAuth } from "react-oidc-context";

function App() {
  const auth = useAuth();

  if (auth.isLoading) return <div>Loading...</div>;
  if (auth.error) return <div>Error: {auth.error.message}</div>;

  if (!auth.isAuthenticated) {
    return <button onClick={() => auth.signinRedirect()}>Login</button>;
  }

  return (
    <div>
      <p>Welcome, {auth.user?.profile.preferred_username}</p>
      <button onClick={() => auth.removeUser()}>Logout</button>
    </div>
  );
}

7. Tích hợp OIDC Client với Spring Boot

7.1 Spring Boot OAuth2 Resource Server

Cấu hình Spring Boot làm Resource Server — validate JWT tokens từ Keycloak:

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/my-company
          jwk-set-uri: http://localhost:8080/realms/my-company/protocol/openid-connect/certs
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        return http.build();
    }

    // Custom converter để map Keycloak realm_access.roles → Spring Security authorities
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            List<GrantedAuthority> authorities = new ArrayList<>();

            // Extract realm roles
            Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
            if (realmAccess != null) {
                List<String> roles = (List<String>) realmAccess.get("roles");
                if (roles != null) {
                    roles.forEach(role ->
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + role))
                    );
                }
            }

            // Extract client roles
            Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
            if (resourceAccess != null) {
                Map<String, Object> clientAccess =
                    (Map<String, Object>) resourceAccess.get("my-backend-app");
                if (clientAccess != null) {
                    List<String> clientRoles = (List<String>) clientAccess.get("roles");
                    if (clientRoles != null) {
                        clientRoles.forEach(role ->
                            authorities.add(new SimpleGrantedAuthority("ROLE_" + role))
                        );
                    }
                }
            }

            return authorities;
        });
        return converter;
    }
}

7.2 Spring Boot OAuth2 Client (Server-side login)

Cấu hình Spring Boot làm OAuth2 Client — server-side login flow:

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-backend-app
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
        provider:
          keycloak:
            issuer-uri: http://localhost:8080/realms/my-company
            user-name-attribute: preferred_username

Keycloak client settings cho Spring Boot OAuth2 Client:

Client ID: my-backend-app
Client authentication: ON (confidential)
Valid redirect URIs: http://localhost:8081/login/oauth2/code/keycloak
Backchannel logout URL: http://localhost:8081/logout/connect/back-channel/keycloak
Web origins: http://localhost:8081

8. Advanced Client Settings

8.1 Tab Advanced

Các cấu hình nâng cao trong tab Advanced của client:

SettingMô tảGiá trị khuyến nghị
Access Token LifespanOverride realm-level token lifespan cho client nàyĐể trống = dùng realm level
Client Session IdleOverride client session idle timeoutĐể trống = dùng realm level
Client Session MaxOverride client session max lifespanĐể trống = dùng realm level
Client Offline Session IdleOverride offline session idle timeoutĐể trống = dùng realm level
Client Offline Session MaxOverride offline session max lifespanĐể trống = dùng realm level
PKCE Code Challenge MethodBắt buộc PKCE methodS256
Pushed Authorization Request RequiredBắt buộc PAR (RFC 9126)ON cho high-security apps
ACR to LoA MappingMap ACR values → Level of AssuranceCấu hình cho step-up auth

8.2 Tab Credentials (Confidential Clients)

Quản lý client credentials:

  • Client Authenticator: Client ID and Secret (mặc định), Signed JWT (client_secret_jwt), Signed JWT with Private Key (private_key_jwt), X.509 Certificate

  • Client Secret: Regenerate nếu bị compromised

  • Registration access token: Dùng cho Dynamic Client Registration

8.3 Tab Service Account Roles

Gán roles cho service account (Client Credentials flow):

  1. Mở client → tab Service account roles

  2. Click Assign role

  3. Chọn realm roles hoặc client roles cần gán

# Ví dụ gán role bằng Admin CLI
# Lấy service account user ID
SERVICE_ACCOUNT_ID=$(bin/kcadm.sh get clients/$CLIENT_UUID/service-account-user \
  -r my-company --fields id --format csv --noquotes)

# Gán realm role
bin/kcadm.sh add-roles -r my-company \
  --uusername service-account-my-service \
  --rolename admin

# Gán client role
bin/kcadm.sh add-roles -r my-company \
  --uusername service-account-my-service \
  --cclientid target-client \
  --rolename manage-users

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

Lab 1: Tạo Public Client cho React SPA

  1. Tạo client react-spa-lab với Client authentication = OFF

  2. Cấu hình: Valid redirect URIs = http://localhost:3000/*, Web origins = http://localhost:3000

  3. Bật PKCE: Advanced → PKCE Code Challenge Method = S256

  4. Tạo React app, cài keycloak-js, tích hợp login/logout

  5. Verify token trong browser DevTools → Application → Network tab

Lab 2: Tạo Confidential Client cho Spring Boot API

  1. Tạo client spring-api-lab với Client authentication = ON

  2. Bật Service accounts roles

  3. Gán role admin cho service account

  4. Tạo Spring Boot project với spring-boot-starter-oauth2-resource-server

  5. Implement endpoint /api/me trả về thông tin user từ JWT

  6. Test với curl gửi Bearer token

Lab 3: Client Credentials Flow

  1. Tạo client batch-worker chỉ có Client Credentials flow

  2. Lấy token qua curl

  3. Gọi API endpoint với token vừa lấy

  4. Kiểm tra token contents qua jwt.io (chỉ dùng cho development)

Lab 4: Device Authorization Flow

  1. Tạo public client cli-tool với Device Authorization Grant enabled

  2. Sử dụng curl để simulate device flow:

    • Request device code
    • Mở verification URI trên browser, nhập user code
    • Poll cho token
  3. Verify token nhận được

# Script test Device Authorization Flow
#!/bin/bash
REALM=my-company
CLIENT_ID=cli-tool
KC_URL=http://localhost:8080

# Bước 1: Request device code
RESPONSE=$(curl -s -X POST \
  "$KC_URL/realms/$REALM/protocol/openid-connect/auth/device" \
  -d "client_id=$CLIENT_ID")

DEVICE_CODE=$(echo $RESPONSE | jq -r '.device_code')
USER_CODE=$(echo $RESPONSE | jq -r '.user_code')
VERIFY_URI=$(echo $RESPONSE | jq -r '.verification_uri_complete')
INTERVAL=$(echo $RESPONSE | jq -r '.interval')

echo "========================================"
echo "Mở URL sau trên browser:"
echo "$VERIFY_URI"
echo "Hoặc truy cập: $(echo $RESPONSE | jq -r '.verification_uri')"
echo "Nhập code: $USER_CODE"
echo "========================================"

# Bước 2: Polling for token
while true; do
  sleep $INTERVAL
  TOKEN_RESPONSE=$(curl -s -X POST \
    "$KC_URL/realms/$REALM/protocol/openid-connect/token" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
    -d "client_id=$CLIENT_ID" \
    -d "device_code=$DEVICE_CODE")

  ERROR=$(echo $TOKEN_RESPONSE | jq -r '.error // empty')
  if [ -z "$ERROR" ]; then
    echo "Xác thực thành công!"
    echo "Access Token: $(echo $TOKEN_RESPONSE | jq -r '.access_token' | head -c 50)..."
    break
  elif [ "$ERROR" = "authorization_pending" ]; then
    echo "Đang chờ user xác thực..."
  elif [ "$ERROR" = "slow_down" ]; then
    INTERVAL=$((INTERVAL + 5))
    echo "Slow down, tăng interval lên ${INTERVAL}s"
  else
    echo "Error: $ERROR"
    break
  fi
done