Files
han-app/VM1_app/codebase/backend/keycloak/README.md
T
2026-08-26 11:05:32 +03:00

7.6 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 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 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/otp with Authorization: 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_URL and an independent KEYCLOAK_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.

Yandex SmartCaptcha

Invisible SmartCaptcha protects every operation that orders an OTP SMS, including resend. It is disabled by default. To enable it, create one CAPTCHA in Yandex Cloud, add the public login hostname (without https://) to allowed sites and set:

KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=<public-client-key>
KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=<secret-server-key>

The browser token is validated by Keycloak before OtpFlow.start(). A provider rejection, missing token or non-temporary HTTP 4xx denies the SMS order. Timeout, I/O, HTTP 408/429/5xx and malformed provider responses are logged without token/phone/keys and handled fail-open. Tokens are one-time and a resend always executes a fresh CAPTCHA.

SmartCaptcha CSP is applied only by nginx to the han-chat login and login-action endpoints. Never set a custom browserSecurityHeaders.contentSecurityPolicy in the realm: it can break Keycloak Admin Console and third-party cookie iframes.

Realm import derives browser redirect, logout and web-origin URLs from the non-secret PUBLIC_WEB_URL environment variable. Keycloak resolves the ${PUBLIC_WEB_URL} placeholders in realm/han-chat-realm.json during the initial --import-realm.

Use exact Expo universal/app links and web origins. Do not replace them with wildcards. ${PUBLIC_WEB_URL}/auth/callback, ${PUBLIC_WEB_URL}/mobile/oidc/callback and han-chat://auth/callback are allow-listed by the initial realm import. Android Custom Tabs cannot follow a custom-scheme 302, so the mobile client uses the HTTPS bridge page and then opens han-chat://auth/callback.

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 explicit ordering/active/final statuses, settings snapshot and optional sms_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 ${KEYCLOAK_PUBLIC_URL}/realms/han-chat, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.