Token Claims Contract — GIW POC
Status: POC · Date: 2026-09-21 Claims below are copied from a live token issued by the running stack, not written from memory.
1. Rule that overrides everything else
citizenId/ CCCD must never appear in any token, in any form, under any claim name.
This is verified by an automated test (tests/e2e.mjs → "token contains NO citizenId in any claim") which fails the build if the raw value or any of citizenId, cccd, CCCD, citizen_id, nationalId is present.
2. Claim classification
| Class | Meaning |
|---|---|
standard |
Defined by OIDC Core / RFC 9068. Do not redefine. |
keycloak |
Keycloak-specific but standard for the product. |
custom |
Galaxy-defined, issued by the giw-identity client scope. |
poc-only |
Exists because this is a POC; not a production commitment. |
production-candidate |
Custom claim we expect to keep. |
3. CUSTOMER token
Issued via the confidential client wifi-portal-api, password grant or register-then-login. No browser redirect is involved.
| Claim | Class | Example | Note |
|---|---|---|---|
iss |
standard | http://localhost:8080/realms/galaxy-id-poc |
Verified by Entitlement |
sub |
standard | 9e115334-e225-4a70-a4b7-e785030ce188 |
The stable subject id |
aud |
standard | ["account"] |
See §6 — azp is the real client signal today |
azp |
standard | wifi-portal-api |
|
exp / iat / auth_time |
standard | 900 s lifetime | |
jti |
standard | ||
typ |
keycloak | Bearer |
|
sid |
keycloak | SSO session id | |
acr |
keycloak | 1 |
|
scope |
standard | openid email giw-identity profile |
|
realm_access.roles |
keycloak | default-roles-galaxy-id-poc, … |
Not used by GIW policy |
resource_access |
keycloak | Not used by GIW policy | |
allowed-origins |
keycloak | ["http://localhost:3000"] |
|
preferred_username |
standard | an.nguyen@example.test |
|
email / email_verified |
standard | ||
name / given_name / family_name |
standard | ||
user_type |
custom · production-candidate | CUSTOMER |
From user attribute. Absent for a self-registered user until set — Entitlement treats absent as CUSTOMER. |
4. EMPLOYEE token
Issued via the confidential client wifi-portal-employee-api, after the custom direct-grant authenticator succeeded inside Keycloak.
| Claim | Class | Example | Note |
|---|---|---|---|
| (all CUSTOMER claims above) | |||
azp |
standard | wifi-portal-employee-api |
Distinguishes the flow |
aud |
standard | ["wifi-portal-employee-api","account"] |
Audience mapper adds the client |
user_type |
custom · production-candidate | EMPLOYEE |
|
employee_verified |
custom · production-candidate | true (boolean, not string) |
Set only by the authenticator after MATCH + ACTIVE |
employee_ref |
custom · production-candidate | EMP-12345 |
Opaque HR reference |
company |
custom · production-candidate | VIETJET |
Drives entitlement eligibility |
preferred_username |
keycloak | emp-12345 |
Derived from employee_ref, deterministic |
name |
standard | Employee EMP-12345 |
Placeholder. HR does not return a real name, by design. |
Live employee token, verbatim
{
"iss": "http://localhost:8080/realms/galaxy-id-poc",
"sub": "8f189cbd-70a4-466d-a11f-dae10d89874c",
"aud": ["wifi-portal-employee-api", "account"],
"azp": "wifi-portal-employee-api",
"typ": "Bearer",
"exp": 1789966050,
"iat": 1789965150,
"auth_time": 1789965150,
"scope": "openid email giw-identity profile",
"user_type": "EMPLOYEE",
"employee_verified": true,
"employee_ref": "EMP-12345",
"company": "VIETJET",
"preferred_username": "emp-12345"
}
Note what is absent: no citizenId, no employee name, no department, no contact detail. The HR API does not return them, so they cannot leak into a token.
5. Forbidden claims
| Claim | Why |
|---|---|
citizenId, cccd, citizen_id, nationalId |
Sensitive personal data. Verification input only. |
| Raw HR payload under any key | Minimal disclosure — the token carries a verdict, not a record. |
| Password, credential or secret of any kind | |
| Anything distinguishing "no such employee" from "wrong CCCD" | Enumeration. |
6. Audience — an open issue
Keycloak public clients receive aud: ["account"] by default; the audience mapper adds the client id. The Entitlement Service currently accepts wifi-portal-api, wifi-portal-employee-api, the two legacy redirect clients and account, falling back to azp when aud is thin.
Accepting account is broader than it should be: any token in the realm would pass the audience check. It is tolerable in a POC realm, and it is not tolerable in production.
Production: TBC (T-AUD). Target is a dedicated audience per resource server (giw-entitlement, giw-session) with account rejected.
7. Where claims come from
HR Verification API
employeeRef, company, active
│
▼
Custom Authenticator ── writes user attributes ──▶ Keycloak user
user_type = EMPLOYEE │
employee_verified = true │
employee_ref, company │
(citizenId discarded here) ▼
giw-identity client scope
(4 attribute→claim mappers)
│
▼
Access token
The client scope is created by bootstrap/src/bootstrap.js, not by realm-export.json — supplying a clientScopes array in a full realm import silently replaces Keycloak's built-in scopes and strips profile/email/roles/basic from every token. That failure was observed and fixed during this POC.