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.

GIW POC Identity Platform · local demo · not production · generated from the repository