6.4 KiB
HAN Chat Keycloak
Production-like Keycloak 26.1.4 image and realm for OTP-only phone authentication. The module is self-contained and does not publish host ports; root nginx must proxy /auth/* to keycloak:8080.
Security contract
- Realm
han-chat; public clienthan-chat-frontend. - Authorization Code flow only, mandatory PKCE S256; implicit, password/direct, device and service-account grants are disabled.
- Access tokens contain audience
han-chat-api, canonical E.164phone_numberand booleanphone_number_verified. - Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse
0; SSO idle/max are 30/90 days. - Realm brute-force protection uses temporary bounded lockouts.
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migrations are applied by Keycloak's JPA entity provider.
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
- Settings are fetched only from
GET /internal/settings/v1/otpwithAuthorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed. - Every challenge snapshots code length, TTL, SMS-order timeout and settings version. Runtime OTP values are not read from environment variables.
- Mock mode is explicit and retains the configured test code. SMS mode generates a cryptographically secure numeric OTP, stores only its HMAC and orders delivery through
POST /internal/sms/v1/send; Keycloak never calls or polls the provider. - SMS mode requires
KEYCLOAK_SMS_SERVICE_URLand an independentKEYCLOAK_SMS_SERVICE_TOKEN. No real credentials are committed.
Build and test
Requires Java 21 and Maven 3.9:
mvn -B -ntp clean verify
docker build -t han-chat/keycloak:26.1.4-otp-1.0.0 .
The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI dependencies remain provided by the pinned server image.
Configuration
Copy values from .env.example into the root backend .env; never commit .env. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
Before production deployment replace the explicit placeholder entries in realm/han-chat-realm.json:
https://APP_LINK_HOST.example/auth/callbackhttps://APP_LINK_HOST.example/auth/logouthttps://APP_WEB_ORIGIN.example
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. https://tohin.ru/auth/callback and han-chat://auth/callback are already allow-listed.
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and currentSchema=keycloak. The database role must have privileges only on schema keycloak.
Runtime
For standalone validation:
docker compose --env-file .env up --build
The service exposes only Docker-network ports:
- application HTTP:
8080, relative path/auth; - management health and metrics:
9000; - readiness:
GET http://keycloak:9000/auth/health/ready; - liveness:
GET http://keycloak:9000/auth/health/live; - Prometheus metrics:
GET http://keycloak:9000/auth/metrics.
Only nginx may publish external ports. Preserve Host, X-Forwarded-Proto=https, X-Forwarded-Host, X-Forwarded-Port=443 and the trusted client IP chain.
Realm lifecycle
--import-realm is suitable for a clean environment. It does not safely reconcile an existing production realm. For changes to a live realm:
- take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
- compare the desired safe subset (clients, scopes, flows, token policy);
- apply through a controlled admin job or Admin API procedure;
- verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
- retain old passive signing keys until all tokens signed by them expire.
Private signing keys are generated and stored by Keycloak and are absent from the realm JSON.
OTP data and operations
Provider tables:
han_otp_challenge: expiring, one-time challenges with explicit ordering/active/final statuses, settings snapshot and optionalsms_message_id;han_otp_send_counter: durable 24-hour counter/cooldown per phone HMAC;han_otp_security_event: append-only send/verify outcomes with SMS correlation and validated device audit metadata, without raw phone or OTP.
Resend creates a new durable order and marks earlier active/ordering challenges as superseded. Verification accepts only active, unexpired challenges, locks the row, increments attempts, and atomically consumes a valid code. Provider delivery status never participates in verification.
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
The provider schedules a once-per-minute expiry update and also performs lazy expiry on send and verify. The theme renders digit inputs and countdown from the challenge snapshot, submits a real resend action and carries optional han_* device metadata.
SMS order behavior
200 or 202 with a valid UUID sms_message_id and ISO-8601 ordered_at activates a real-mode challenge. Timeout, I/O failure or 5xx is retried once with the same keycloak:challenge:{id} idempotency key; final failure marks that challenge order_failed. The retry creates neither another challenge nor another send-counter increment.
Reserve, SMS HTTP order, and activation/order-failure run as separate transaction phases. The HTTP call holds no challenge/counter database lock, and every retry retains the same challenge id.
Release and recovery
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer https://tohin.ru/auth/realms/han-chat, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.