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:

  1. HR returns a byte-identical body for "unknown employee" and "wrong CCCD".
  2. Comparison is crypto.timingSafeEqual, and the HR handler scans every record even after a match, so response time does not reveal a hit.
  3. 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 Portal session map has no eviction β€” fixed: 1 h TTL, reaped every 5 min 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:

  1. The value is read into a local and passed straight to the outbound request.
  2. It is never assigned to a field, session object, cache or closure that outlives the request.
  3. It goes out of scope when the handler returns, left to the garbage collector.
  4. 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.

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