Files
han-app/codebase/backend/keycloak

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 client han-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.164 phone_number and boolean phone_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 migration han-otp-1.0.0 is 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/otp with Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
  • Mock mode is explicit. Startup rejects missing values, code 1234, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.

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/callback
  • https://APP_LINK_HOST.example/auth/logout
  • https://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:

  1. take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
  2. compare the desired safe subset (clients, scopes, flows, token policy);
  3. apply through a controlled admin job or Admin API procedure;
  4. verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
  5. 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 optimistic version and pessimistic verification lock;
  • han_otp_send_counter: durable 24-hour counter/cooldown per phone HMAC;
  • han_otp_security_event: append-only minimal outcomes without raw phone or OTP.

Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.

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.

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.