API Security β GIW POC Identity Platform
Status: POC Β· Date: 2026-09-21
This document states what is implemented, and β more usefully for a reviewer β what is not.
DIRECT GRANT / PASSWORD GRANT = POC-ONLY IMPLEMENTATION. Not a production architecture, not a production Galaxy ID flow, not a production employee authentication recommendation. The browser / Authorization Code + PKCE path is retained as PRODUCTION CANDIDATE. Full comparison and migration path:
../docs/AUTH-STRATEGY.md.
1. Grant choice β read this first
The portal renders its own UI and calls Galaxy ID over the token API. There is no browser redirect and no Keycloak-rendered page. That is a deliberate decision with real costs; a reviewer should accept or reject it explicitly rather than discover it in the code.
| Property | Value |
|---|---|
| Grant, customer | password (RFC 6749 Β§4.3) |
| Grant, employee | password + employeeId + citizenId, routed to a custom direct-grant authenticator |
| Grant, registration | client_credentials for a service account, then password |
| Client type | Confidential, client secret held server-side |
| PKCE | Not applicable β no authorization code exists |
| Implicit / hybrid | Disabled |
| Standard (redirect) flow | Disabled on both API clients |
| Service account | Enabled on wifi-portal-api only, scoped to manage-users + view-users |
Why
A captive portal is the one place the redirect flow genuinely breaks. The captive network assistant β the mini-browser iOS and macOS pop up on a captive Wi-Fi β handles redirects to a third-party origin badly and does not persist cookies reliably. A login that depends on a redirect fails exactly where this product lives.
What it costs
| Lost | Consequence |
|---|---|
| SSO across Galaxy properties | Every portal login is independent |
| MFA / step-up authentication | The password grant carries no challenge mechanism (T-MFA) |
| IdP federation β corporate, social | Would need a redirect path added back |
| Credential isolation | The portal now handles raw passwords and is in audit scope for them |
No standards exception is being claimed here. RFC 9700 (OAuth 2.0 Security BCP) states the password grant MUST NOT be used, and OAuth 2.1 removes it. There is no first-party carve-out in either document, and this POC does not invoke one.
Direct Grant is used only as a constrained POC workaround for captive-portal UX validation. Production authentication architecture remains TBC and should prefer modern browser-based OIDC/federation where operationally feasible.
Tracked as T-GRANT. If corporate IdP federation is ever required for employees β which ADR-003 recommends over CCCD β a redirect path has to come back for that journey.
Confidential, not public
A public client with Direct Access Grants enabled lets anyone on the network replay the grant with no client authentication whatsoever. The portal is a server-side application, so it can hold a secret, and it does. The secret is applied by bootstrap from OIDC_API_CLIENT_SECRET and never written into realm-export.json.
Mode B is retained, not deleted
wifi-portal and wifi-portal-employee β public clients, PKCE S256 enforced, standard flow on, Direct Grant off β remain in the realm with their employee-browser flow and the giw-employee-verify authenticator. Nothing in the POC calls them.
They are kept because Authorization Code + PKCE is the path an Enterprise IdP federates into, and ADR-003 recommends exactly that in place of CCCD. Five automated tests assert they still work, so the path cannot rot unnoticed.
Tokens never reach the browser
Tokens live in a server-side map. The browser holds an opaque sid cookie: HttpOnly, SameSite=Strict, Max-Age=3600. The map is reaped every 5 minutes so it cannot grow without bound.
SameSite=Strict, not Lax: the portal has no cross-site entry point by design, so there is nothing to relax for.
2. Token validation
The Entitlement Service performs full validation before reading a single claim:
| Check | Implementation |
|---|---|
| Algorithm | RS256/RS384/RS512 allowlist. none and HMAC are rejected outright. |
| Signature | crypto.verify against the JWKS key matching the token kid |
| Key rotation | JWKS cached 5 minutes; an unknown kid forces one immediate refetch |
iss |
Exact match against configured issuer |
aud |
Must intersect the configured audience list; falls back to azp when aud is thin |
exp |
Rejected when past, Β±30 s clock skew |
nbf |
Rejected when in the future, Β±30 s |
Identity is taken from the token, never from the body. A body subjectId that contradicts sub returns 403; a contradicting userType is logged and ignored. Without this, the portal could assert its own entitlements and the service would be decorative.
3. Service-to-service authentication
| Leg | POC | Production target |
|---|---|---|
| Keycloak authenticator β HR API | X-API-Key shared secret, constant-time compared |
mTLS or a short-lived service token. A shared static key is not acceptable for an endpoint that receives a CCCD. |
| Portal β Entitlement | End-user bearer token | Keep. Possibly add a client credential for the portal itself. |
| Portal β Session | X-API-Key shared secret |
Service token with an audience of giw-session |
| Entitlement β JWKS | None (public keys) | Keep |
The HR API key is never written to realm-export.json. It reaches Keycloak through the environment variable GIW_HR_API_KEY, so the repository carries no secret. .env is git-ignored; .env.example holds placeholders only.
4. Transport
| Environment | Transport |
|---|---|
| POC on localhost | Plain HTTP. sslRequired: none on the realm. |
| Anything else | HTTPS required. Set sslRequired: external, KC_HOSTNAME to an https origin, and add Secure to the portal sid cookie. |
Shipping this POC's transport configuration to a shared environment would put a CCCD on the wire in clear text.
5. Brute force and rate limiting
| Surface | Control | Value |
|---|---|---|
| Keycloak password login | Realm brute-force protection | 5 failures, wait increments to 900 s, not permanent lockout |
| Employee verify form (browser flow) | Per-authentication-session counter | 5 submissions then ACCESS_DENIED |
| Employee verify (API flow) | none at the portal | Gap β the direct grant has no per-session counter |
| Quick-login burst | Realm quickLoginCheckMilliSeconds |
2 failures inside 200 ms β 60 s hold. Was 1000 ms, which locked out a passenger who double-tapped Login. |
| HR verify API | Per-peer sliding counter | 30 requests / 60 s β 429 RATE_LIMITED |
| Entitlement Service | none | Gap |
| Session Service | none | Gap |
The employee-form counter is per authentication session, so it is bypassable by restarting the flow. It raises the cost of a CCCD guessing attack but does not stop one. A production control must be per employeeId and per source, enforced centrally.
6. Account and credential enumeration
Three deliberate design choices:
- HR returns a byte-identical body for "unknown employee" and "wrong CCCD".
- Comparison is
crypto.timingSafeEqual, and the HR handler scans every record even after a match, so response time does not reveal a hit. - The authenticator renders the same message (
employeeVerifyFailed) for both.
A test asserts the two responses are deep-equal. If someone later "improves" the error messages, that test fails.
7. Replay protection
| Vector | Control |
|---|---|
Stolen sid cookie |
HttpOnly (no script access), SameSite=Strict (no cross-site send), 1 h TTL, server-side reaping |
| Replayed credentials | Not preventable by the portal β this is the cost of the password grant, mitigated by brute-force protection |
| Duplicate entitlement | Idempotency-Key returns the original decision |
| Duplicate session provisioning | Idempotency-Key returns the original session |
| Duplicate grant / revoke | Declarative: re-applying the same desired state is a no-op |
| Duplicate registration | Keycloak returns 409; the portal surfaces ACCOUNT_EXISTS |
Authorization-code, state and nonce protections no longer apply: there is no authorization code in this architecture. That is a reduction in the number of moving parts, not a gap β but it also removes the defence-in-depth those mechanisms provided.
8. Secrets
| Secret | Where | Committed? |
|---|---|---|
HR_API_KEY |
.env, container env, GIW_HR_API_KEY |
No β .env is git-ignored |
SESSION_API_KEY |
.env, container env |
No |
KC_ADMIN_PASSWORD |
.env, container env |
No |
| Postgres password | .env, container env |
No |
| OIDC client secret | None exists β public clients | n/a |
.env.example contains placeholder values marked change-me. They are weak on purpose so the stack boots unattended, and they must be replaced before the stack leaves a laptop.
9. Audit
| Event | Where |
|---|---|
| Login success / failure, logout, register, token exchange | Keycloak realm events (eventsEnabled, 24 h expiry) |
| Employee verification outcome | Authenticator log + Keycloak event with giw_correlation_id, giw_employee_ref |
| HR verification outcome | HR structured log: correlationId, employeeId, employeeRef, active, wifiEligible |
| Entitlement granted / denied | Entitlement log: sub, entitlementId, entitlementType, tier, or the denial reason |
| Session created / granted / revoked | Session log with sessionId and entitlementId |
All logs are single-line JSON with a correlationId, so one journey is one grep.
10. Security headers
Portal responses carry X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store. Keycloak sets its own.
Not yet set: Content-Security-Policy on portal pages, and Strict-Transport-Security (meaningless over plain HTTP).
11. Known gaps β read this before approving anything
| # | Gap | Severity |
|---|---|---|
| 1 | Plain HTTP; a CCCD would cross the network in clear text outside localhost | Critical outside localhost |
| 2 | HR authentication is a static shared key | High |
| 3 | No rate limiting on Entitlement or Session, or on the portal's own auth API | High β the portal is now the front door for credential stuffing |
| 3b | The API employee flow has no per-attempt counter (the browser flow does) | High β a CCCD guessing attack is cheaper here |
| 3c | Password grant: no MFA, no SSO, no federation (T-GRANT, T-MFA) | Medium, by design, needs explicit sign-off |
| 4 | aud: account is accepted β any realm token passes the audience check |
Medium (T-AUD) |
| 5 | Entitlement and session state are in memory; restart loses everything | Medium for POC, blocking for production |
| 6 | resolved | |
| 7 | No CSP on portal pages | Low |
| 8 | Brute-force counter on the employee form is per-session, so it is bypassable | Medium |
| 9 | POST /admin/fault on the HR mock has no authentication at all |
Critical if ever deployed β it is a test harness and must be removed, not secured |
| 10 | No consent artefact is captured for the CCCD path | High β Law 91/2025 |
11b. Security boundary β who knows what
| Party | Knows | Holds | Never |
|---|---|---|---|
| Browser | The portal host, nothing else | One opaque sid cookie β HttpOnly, SameSite=Strict, 1 h |
Access / refresh / ID token Β· the Keycloak, HR, Entitlement or Session endpoint |
| Portal / BFF | All downstream endpoints | Tokens, server-side only, 1 h TTL reaped every 5 min | Persists a password Β· persists a CCCD Β· logs either Β· decides authentication or entitlement |
| Keycloak | Credentials during verification | Identity, sessions | Opens the network Β· decides entitlement |
| HR API | Employment status | Nothing β stateless verdicts | Returns a profile Β· is a system of record for identity |
| Entitlement | Verified claims | Decisions | Enforces Β· creates sessions |
| Session | Session state | Access state | Decides β grant without entitlementId is 422 |
On "clear sensitive values immediately after the request"
JavaScript strings are immutable. A password or CCCD read from a request body cannot be zeroed β there is no memset. What is actually guaranteed:
- The value is read into a local and passed straight to the outbound request.
- It is never assigned to a field, session object, cache or closure that outlives the request.
- It goes out of scope when the handler returns, left to the garbage collector.
- No log statement in the portal takes it as an argument.
Points 2β4 are enforceable and enforced, with automated tests behind 4. Point 1 is not a wipe and this document will not pretend otherwise. A language with controlled memory would do better β one more reason Mode A is POC-only.
Inside Keycloak the same holds: EmployeeVerification keeps the CCCD in a local, hands it to HrVerificationClient, and never writes it to a field, user attribute, auth-session note, event detail or log line. HrVerificationClient logs e.getClass().getSimpleName() on failure, never e.getMessage(), because some transport stacks echo the request body into the exception message.
12. CCCD β the standing objection
This POC implements Employee ID + CCCD because the task order asked for it, and it contains the data as tightly as the design allows: transient, unlogged, unstored, untokenised, with automated tests enforcing all four.
That is containment, not endorsement. ADR-003 and decision D3 both currently say: remove CCCD from employee verification and use corporate IdP federation (OIDC) with company-email OTP as fallback. Nothing in this POC changes that recommendation.
Employee ID + CCCD = POC verification approach. Production: TBC β requires Legal + Security + HR review.