Security
The middleware handles fiscally sensitive data and credentials for multiple stores. Security is layered: transport, authentication per channel, per-store credential isolation, and a complete audit trail.
Security channels at a glance
| Channel | Authentication | Session model |
|---|---|---|
POS API (/api/taxint/**) | Bearer JWT (Oracle IDM) | Stateless |
Store & Reports APIs (/api/stores/**, /api/reports/**) | Spring Security | Stateless |
| Admin web portal | Email + password, then WhatsApp OTP | HTTP session |
| Chrilan Smart API (outbound) | Per-store API key (x-api-key header) | — |
| Oracle STS (outbound) | OAuth2 authorization code + PKCE | Cached id_token |
| Whapi (outbound) | Bearer token | — |
POS API: JWT validation
POS terminals obtain a JWT from Oracle Restaurant IDM. The middleware validates it by:
- Extracting the
kid(key ID) from the JWT header - Fetching the public key from Oracle IDM's JWKS endpoint (
/oauth2/jwks) - Verifying the signature, issuer and expiration
| Parameter | Value |
|---|---|
| JWKS URL | https://ors-idm.msaf.oraclerestaurants.com/oauth2/jwks |
| Expected issuer | https://ors-idm.msaf.oraclerestaurants.com |
| Clock skew tolerance | 300 seconds |
| JWKS cache duration | 3600 seconds (per key) |
auth.jwt.validation-enabled and is currently disabled for UAT (the filter only checks the Bearer prefix). Enable it before enforcing production trust. Even when disabled, the store lookup and per-store API key checks still apply — an unregistered store cannot submit regardless of token state.
Per-store API keys: no fallbacks
Chrilan API keys live in the store_registrations table, one per store. There is no default or fallback key anywhere in configuration. Consequences by design:
- An invoice cannot be submitted under the wrong store's fiscal device.
- A missing key fails closed (HTTP 403), never open.
- Key rotation is a per-store edit in the portal; no restart needed.
Admin portal: two-factor with WhatsApp OTP
- Passwords stored as BCrypt hashes.
- Second factor: 6-digit OTP via WhatsApp, 5-minute expiry, single-use (resend invalidates prior codes).
- Separate Spring Security filter chain from the API: the portal is session-based, the API is stateless JWT.
- Logout invalidates the session server-side.
Web and transport hardening
- CORS restricted to the production origin (
https://server.zra.co.zm) with explicit allowed methods; credentials not allowed cross-origin. - Rate limiting: 100 requests/minute per client with a burst capacity of 200.
- Error hygiene: stack traces never exposed (
include-stacktrace: never); messages included for API consumers. - SSL termination at Nginx in production; the app trusts
X-Forwarded-*headers viaforward-headers-strategy: native. - Request body size capped at 10 MB.
Secrets handling
- Database credentials, Oracle client secrets and the Whapi token live in server-side configuration only — never in responses, logs of payloads to clients, or this wiki.
- Store API keys are write-and-use only: they are used to call Chrilan and are not exposed by the Reports API.
- JWT signing secret and JWKS caching are configurable; JWKS public keys are cached per
kidwith expiry.
Audit as a security control
Every inbound request is logged before the security chain (RequestResponseLoggingFilter), so even rejected requests leave evidence: source, payload, status and timing. Combined with conversion logs and fiscal invoice persistence, any receipt or rejection can be reconstructed after the fact. See Audit Logging.
Operational security checklist
- Enable
auth.jwt.validation-enabled=truewhen moving beyond UAT. - Rotate the default Spring Security admin credentials on any new deployment.
- Keep WhatsApp OTP enabled (
whapi.api.enabled=true) in production. - Review failed transactions weekly for probing patterns (repeated 401/403).
- Deactivate stores immediately when a location closes or a device is decommissioned.