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
clientScopestrong bản import realm đầy đủ sẽ thay thế toàn bộ scope mặc định của Keycloak, làm mấtprofile/roles/basickhỏ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
| 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