Keycloak (Galaxy ID) — đặc tả tích hợp

Trạng thái: POC · Ngày: 22/09/2026 · Realm: galaxy-id-poc

Đây không phải bản đặc tả lại OIDC. Mọi endpoint dưới đây là chuẩn Keycloak / OAuth 2.0. Tài liệu này ghi chính xác những gì portal gọi: client nào, tham số gì, nhận về gì, lỗi nào — để đội tích hợp không phải đọc ngược mã nguồn.

Hợp đồng gốc: {issuer}/.well-known/openid-configuration

DIRECT GRANT = POC ONLY. RFC 9700 nói password grant MUST NOT be used. Đường production ứng viên là browser OIDC / federation — xem ../docs/AUTH-STRATEGY.md.


1. Địa chỉ

Vai trò Biến môi trường Giá trị local
Issuer — nằm trong token, dịch vụ khác kiểm theo cái này KC_HOSTNAME http://localhost:8080/realms/galaxy-id-poc
Back-channel — portal gọi qua mạng container OIDC_INTERNAL_BASE http://keycloak:8080/realms/galaxy-id-poc
Admin API OIDC_ADMIN_BASE http://keycloak:8080/admin/realms/galaxy-id-poc

Hai biến riêng, không gộp. iss trong token là địa chỉ hướng trình duyệt; portal lại gọi qua mạng nội bộ. Gộp lại là lỗi captive portal kinh điển: token phát ra một issuer, dịch vụ kiểm theo issuer khác, mọi thứ trông như sai chữ ký.

2. Bốn client

Client Loại Grant Flow override Portal dùng
wifi-portal-api confidential password, client_credentials — ✅ khách hàng + đăng ký
wifi-portal-employee-api confidential password direct_grant → employee-direct-grant ✅ CBNV
wifi-portal public, PKCE S256 authorization code — ❌ giữ làm production candidate
wifi-portal-employee public, PKCE S256 authorization code browser → employee-browser ❌ giữ làm production candidate

Vì sao confidential: client public bật Direct Access Grants nghĩa là bất kỳ ai trong mạng cũng phát lại được grant mà không cần xác thực client. Portal chạy phía máy chủ nên giữ được secret.

Secret nạp bằng OIDC_API_CLIENT_SECRET qua bootstrap, không nằm trong realm-export.json.

3. Các lời gọi

3.1 Đăng nhập khách hàng

POST {internal}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
X-Correlation-ID: corr-...

grant_type=password
client_id=wifi-portal-api
client_secret={OIDC_API_CLIENT_SECRET}
username={email}
password={password}
scope=openid profile email
Kết quả Keycloak trả Portal đổi thành
Thành công 200 + access_token, refresh_token, id_token 200 kèm entitlement và session
Sai mật khẩu 401 invalid_grant 401 INVALID_CREDENTIALS
Không có tài khoản 401 invalid_grant — giống hệt 401 INVALID_CREDENTIALS
Đang bị khoá brute-force 401 invalid_grant — vẫn giống hệt 401 INVALID_CREDENTIALS
Sai client secret 401 unauthorized_client 502 IDP_CLIENT_REJECTED

Ba nguyên nhân đầu trả về y hệt nhau. Keycloak cố ý không phân biệt, và portal cũng không. Phân biệt là mở cửa cho dò tài khoản.

Timeout: 5000 ms.

3.2 Xác minh CBNV

POST {internal}/protocol/openid-connect/token

grant_type=password
client_id=wifi-portal-employee-api
client_secret={OIDC_API_CLIENT_SECRET}
username={employeeId}          ← Keycloak bắt buộc có, authenticator bỏ qua
password=n/a                   ← Keycloak bắt buộc có, authenticator bỏ qua
employeeId={employeeId}        ← authenticator đọc cái này
citizenId={citizenId}          ← authenticator đọc cái này
scope=openid profile

CCCD gửi bằng trường riêng, không dùng tham số password. Proxy, APM agent và công cụ báo lỗi đều đối xử đặc biệt với password của password grant — CCCD phải tránh xa cơ chế đó.

Luồng bên trong Keycloak:

token endpoint
  → direct_grant flow = employee-direct-grant
  → EmployeeDirectGrantAuthenticator
      validate định dạng
      → POST http://mock-hr-api:3001/api/v1/employees/verify
      → MATCH + ACTIVE + eligible?
      → tìm/tạo user theo employee_ref
      → set 4 attribute
  → phát token
Kết quả Keycloak trả Portal đổi thành
Hợp lệ, đang làm việc, đủ điều kiện 200 + token 200
Không có mã / sai CCCD 401 invalid_grant — giống hệt nhau 401 INVALID_CREDENTIALS
Sai định dạng 400 invalid_request 401 INVALID_REQUEST
Không ở trạng thái ACTIVE 403 employee_inactive 403 EMPLOYEE_INACTIVE
Không thuộc diện Wi-Fi 403 access_denied 403 FORBIDDEN
HR timeout 504 temporarily_unavailable 504 HR_SERVICE_TIMEOUT
HR sập 503 temporarily_unavailable 503 HR_SERVICE_UNAVAILABLE
Trùng employee_ref 409 invalid_grant 409 IDENTITY_LINK_CONFLICT

Timeout: 12000 ms — phải lớn hơn hrTimeoutMs × hrMaxAttempts (3000 × 2). Đặt nhỏ hơn thì portal timeout trước và báo nhầm là "Galaxy ID sập" trong khi lỗi thật nằm ở HR.

3.3 Đăng ký

Hai bước, đều là API chuẩn của Keycloak.

Bước 1 — lấy token service account:

POST {internal}/protocol/openid-connect/token

grant_type=client_credentials
client_id=wifi-portal-api
client_secret={OIDC_API_CLIENT_SECRET}

Bước 2 — tạo user:

POST {admin}/users
Authorization: Bearer {service_account_token}
Content-Type: application/json

{
  "username": "an.nguyen@example.test",
  "email": "an.nguyen@example.test",
  "emailVerified": true,
  "enabled": true,
  "firstName": "An",
  "lastName": "Nguyen",
  "attributes": { "user_type": ["CUSTOMER"], "member_tier": ["NONE"] },
  "credentials": [{ "type": "password", "value": "...", "temporary": false }]
}
Kết quả Keycloak Portal
Tạo xong 201 tiếp tục đăng nhập ở §3.1
Trùng email 409 409 ACCOUNT_EXISTS

Quyền của service account: chỉ manage-users và view-users trong realm-management. Cố ý không dùng realm-admin — portal không được phép sửa client, flow hay cấu hình realm.

emailVerified: true là dối trá có chủ ý ở POC vì chưa có máy chủ mail. Production phải xác minh thật.

3.4 Làm mới token

POST {internal}/protocol/openid-connect/token

grant_type=refresh_token
client_id={client đã phát token ban đầu}
client_secret={OIDC_API_CLIENT_SECRET}
refresh_token={refresh_token}

Phải dùng đúng client đã phát token. Dùng client khác là invalid_grant.

3.5 Đăng xuất

POST {internal}/protocol/openid-connect/logout

client_id={client}
client_secret={OIDC_API_CLIENT_SECRET}
refresh_token={refresh_token}

Back-channel, không có vòng qua trình duyệt. Portal thu hồi phiên mạng trước, rồi mới gọi cái này — nếu gọi IdP hỏng thì mạng đã tắt rồi.

Lỗi ở bước này không chặn người dùng: token sẽ tự hết hạn.

3.6 JWKS — Entitlement Service dùng

GET {internal}/protocol/openid-connect/certs

Cache 5 phút. Gặp kid lạ thì ép nạp lại đúng một lần.


4. Hợp đồng claim

Chi tiết đầy đủ: TOKEN-CLAIMS.md

Claim Nguồn CUSTOMER EMPLOYEE
user_type user attribute CUSTOMER EMPLOYEE
employee_verified authenticator đặt — true (boolean)
employee_ref HR trả về — EMP-12345
company HR trả về — VIETJET
member_tier user attribute (mock) GOLD/SILVER/NONE —

Bốn claim đầu do client scope giw-identity ánh xạ. Scope này tạo bằng Admin API trong bootstrap, không khai trong realm-export.json.

Bẫy đã gặp thật: khai mảng clientScopes trong bản import realm đầy đủ sẽ thay thế toàn bộ scope mặc định của Keycloak, làm mất profile/email/roles/basic khỏi mọi token. Đừng khai ở đó.

member_tier là mock. GalaxyJoy chưa tồn tại. Claim này mang sẵn để bảng quyết định DMN có chiều loyalty khi nghiệp vụ cần — hiện chưa luật nào đọc tới nó.

Cấm xuất hiện trong token

citizenId · cccd · citizen_id · nationalId · mật khẩu · dữ liệu HR thô.

Có test tự động quét mọi claim, mọi user record, mọi event log và log của cả 5 container.


5. Cấu hình realm

Mục Giá trị Vì sao
registrationAllowed true Đăng ký qua Admin API, không dùng trang đăng ký
bruteForceProtected true
failureFactor 5
quickLoginCheckMilliSeconds 200 Mặc định 1000 ms khoá tài khoản 60 giây khi hai lần sai cách nhau dưới 1 giây — hành khách bấm Đăng nhập hai lần là dính
accessTokenLifespan 900
ssoSessionIdleTimeout 1800
sslRequired none Chỉ localhost. Production đặt external
eventsEnabled true 24h

6. Hai custom authenticator

Provider Flow Trạng thái
giw-employee-direct-grant employee-direct-grant ✅ portal đang dùng
giw-employee-verify employee-browser 🔵 production candidate, không dùng, không được xoá

Cả hai dùng chung lớp EmployeeVerification — một nơi quyết định, để hai đường không lệch nhau về xử lý CCCD.

Cấu hình qua AuthenticatorConfig, fallback sang biến môi trường GIW_HR_*. Không ghi hrApiKey vào realm-export.json.

7. Dữ liệu mock

Toàn bộ tổng hợp. .test là TLD dành riêng theo RFC 6761, vĩnh viễn không resolve được.

Khách hàng — mật khẩu Passw0rd!23

Email member_tier
an.nguyen@example.test GOLD
binh.tran@example.test SILVER
chi.le@example.test NONE
dung.pham@example.test GOLD
em.hoang@example.test SILVER
giang.vo@example.test NONE

Nhân viên — 20 bản ghi trong mock-hr-api/src/employees.json

Mã CCCD Công ty Trạng thái Kết quả
VJ12345 001234567890 VIETJET ACTIVE ✅ BOOST
GLX67890 001234500001 GALAXY ACTIVE ✅ BOOST
SVC24680 001234500002 SOVICO ACTIVE ✅ BOOST
VJ10001–VJ10002 0012345100xx VIETJET ACTIVE ✅ BOOST
GLX10101–GLX10102 00123451010x GALAXY ACTIVE ✅ BOOST
SVC10201 001234510201 SOVICO ACTIVE ✅ BOOST
VJ99999 001234500003 VIETJET INACTIVE ❌ nghỉ việc
VJ10003 001234510003 VIETJET ON_LEAVE ❌ không ACTIVE
SVC10202 001234510202 SOVICO PROBATION ❌ không ACTIVE
GLX10103 001234510103 GALAXY TERMINATED ❌ không ACTIVE
VJ55555 001234500004 VIETJET ACTIVE ❌ không thuộc diện
HDB10301 001234510301 HDBANK ACTIVE ⚠️ Entitlement chặn
VKI10401 001234510401 VIKKI ACTIVE ⚠️ Entitlement chặn
PTX10001, PTX10002 0012345005xx PARTNER-X ACTIVE ⚠️ Entitlement chặn
VJ1 001234510601 VIETJET ACTIVE ✅ mã ngắn nhất pattern cho phép
VJ0123456789012345678901234567 001234510701 VIETJET ACTIVE ✅ mã dài nhất
VJ10801 001234108 VIETJET ACTIVE ✅ CCCD 9 chữ số (CMND cũ)

Ba nhóm đáng chú ý:

① HDBANK và VIKKI — công ty trong tập đoàn nhưng chưa nằm trong EMPLOYEE_COMPANIES. HR nói đủ điều kiện, Entitlement vẫn từ chối. Đây là chỗ để nghiệp vụ nhìn thấy quyết định "công ty nào được dùng" nằm ở đâu, và sửa nó trong bảng DMN chứ không phải trong mã.

② ON_LEAVE, PROBATION, TERMINATED — nền tảng Wi-Fi coi tất cả là "không ACTIVE", không mã hoá từ vựng nội bộ của HR. HR đổi tên trạng thái thì Wi-Fi không phải sửa.

③ Các bản ghi biên — mã 3 ký tự, mã 30 ký tự, CCCD 9 số. Để validate không bị viết chặt tay theo một định dạng duy nhất.


8. Câu hỏi còn mở

Ref Câu hỏi
T-GRANT Direct Grant chỉ dùng cho POC. Production dùng gì?
T-MFA Password grant không mang được step-up. Nếu cần MFA thì thay bằng gì?
T-LINK Nhân viên đồng thời là khách hàng: một subject hai vai trò, hay hai subject liên kết? POC cố ý không tự gộp theo email
T-AUD Đang chấp nhận aud: account — mọi token trong realm đều qua được. Production phải cấp audience riêng
T-TIER member_tier đang mock. Nguồn thật là GalaxyJoy — lấy đồng bộ hay bất đồng bộ?

Liên quan

API-OVERVIEW.md · TOKEN-CLAIMS.md · API-SECURITY.md · ../docs/AUTH-STRATEGY.md · DMN-DECISION-SPEC.md

GIW POC Identity Platform · bản demo local · không phải production · sinh từ repository