Keycloak на практике: realms, clients, потоки, деплой

От решения к практике: разворачиваем Keycloak 26.7.0 (Quarkus) в docker-compose с внешним PostgreSQL, разбираем анатомию realm (confidential vs public clients, redirect URI, realm- и client-роли, группы, client scopes), потоки authorization code + PKCE и client credentials, получаем первый access-token curl-ом к token endpoint и читаем реальный декодированный JWT (iss, aud=backend, exp, realm_access.roles). Realm-as-code через kc.sh export/import вместо click-ops, token lifespans и protocol mappers ролей.

Первая статья серии была про решение: когда вообще стоит брать готовый IdP вместо своего auth. Ответ дан — берём. Теперь практика: развернуть Keycloak, разобраться в его модели (realm, клиенты, роли, пользователи), настроить потоки и получить первый токен, который потом будет проверять бэкенд. Всё в этой статье опирается на живой стенд digital-cookbook: security/keycloak — Keycloak 26.7.0 на Quarkus + PostgreSQL, с realm-as-code и demo-пользователями; конфиги и вывод команд взяты с реального прогона, не придуманы.

Задача статьи — пройти путь от docker compose up до валидного access-token с ролями в claims, по дороге объяснив, как устроена модель Keycloak и почему её стоит держать как код, а не кликать в админке. Механику самих OAuth2/OIDC-флоу подробно разбирает отдельная статья про authorization code, PKCE и client credentials — здесь мы смотрим, как эти флоу настраиваются в Keycloak и что реально лежит в выданном токене.

Ретрофутуристский пульт управления Keycloak в стилистике советского sci-fi: центральный терминал-realm «demo» с тремя гнёздами-клиентами (confidential «backend» под ключом, public «frontend» и «cli»), рядом стойка ролей user/admin, картотека пользователей alice/bob, а из выходного лотка выезжает перфокарта-JWT с гравировкой iss/aud/exp/realm_access; на заднем плане — бак PostgreSQL как единственный источник состояния

В статье

Что поднимаем: стенд одним взглядом

Стенд — это один docker-compose.yml, поднимающий Keycloak и PostgreSQL, плюс realm demo, импортируемый на старте из JSON. В realm заранее заведены три клиента (backend, frontend, cli), две realm-роли (user, admin) и два пользователя (alice, bob) — этого достаточно, чтобы показать все ключевые понятия и получить рабочий токен.

Версии зафиксированы точными тегами, а не latest — поведение Keycloak меняется между мажорами (переход на Quarkus, удаление legacy), поэтому пиновка обязательна:

  • Keycloak quay.io/keycloak/keycloak:26.7.0 — актуальный мажор 26.x на Quarkus (не legacy WildFly), образ собран 2026-07-09;
  • PostgreSQL postgres:16-alpine — внешнее состояние.

Всё, что ниже, воспроизводимо: docker compose up -d → healthy за ~30 секунд → токен → и его состав. Все креды на стенде — demo-only, для production они не годятся (об этом — в пятой статье про productionготовится, с 16 сентября).

Деплой: docker-compose, внешний Postgres, bootstrap-админ

Начнём с самого сервиса Keycloak в compose. Разберём его по частям, а потом соберём.

keycloak:
  image: quay.io/keycloak/keycloak:26.7.0
  command: ["start-dev", "--import-realm", "--health-enabled=true", "--metrics-enabled=true"]
  environment:
    KC_BOOTSTRAP_ADMIN_USERNAME: admin
    KC_BOOTSTRAP_ADMIN_PASSWORD: admin      # demo-only
    KC_HOSTNAME: http://keycloak:8080
    KC_DB: postgres
    KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
    KC_DB_USERNAME: keycloak
    KC_DB_PASSWORD: keycloak
    KC_HEALTH_ENABLED: "true"
    KC_METRICS_ENABLED: "true"
    KC_HTTP_MANAGEMENT_PORT: "9000"
  ports:
    - "8080:8080"   # auth: OIDC/OAuth2 endpoints, admin UI
    - "9000:9000"   # management: health, metrics
  volumes:
    - ./realm:/opt/keycloak/data/import:ro
  depends_on:
    postgres:
      condition: service_healthy

Ключевые моменты этого фрагмента:

  • Внешний PostgreSQL — единственный источник состояния. KC_DB=postgres и KC_DB_URL подключают Keycloak к отдельной базе. В Keycloak нет встроенного persistent-хранилища: пользователи, realm, клиенты, ключи подписи — всё в PostgreSQL. Это делает базу единственной точкой, которую нельзя потерять (бэкапы — тема production-статьи), но зато сам Keycloak stateless по данным и легко масштабируется в несколько реплик.
  • Bootstrap-админ через env. KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD заводят временного администратора realm master при первом старте — это замена устаревших KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD из старых мажоров. Bootstrap-креды нужны только для первичного входа; в production их выдают через секреты и меняют после инициализации, а не хранят в compose.
  • KC_HOSTNAME фиксирует issuer. Без явного hostname Keycloak в dev-режиме строит iss токена из заголовка Host запроса. Тогда токен, взятый с хоста по localhost:8080, получил бы iss=http://localhost:8080/... и не прошёл бы проверку в бэкенде, который делает discovery по внутреннему docker-адресу http://keycloak:8080/.... Фиксация KC_HOSTNAME держит issuer стабильным по обе стороны.
  • Разделение портов. 8080 — auth-трафик (OIDC/OAuth2-эндпоинты и админка), 9000 — management-порт для /health и /metrics. Health и метрики намеренно не торчат на публичном порту.

Health-проба заслуживает отдельного слова. Minimal-образ Keycloak не содержит ни curl, ни wget — но есть bash (5.1.8) с /dev/tcp и grep. Поэтому healthcheck ходит на /health/ready management-порта через сокет bash:

healthcheck:
  test:
    - "CMD"
    - "bash"
    - "-c"
    - "exec 3<>/dev/tcp/127.0.0.1/9000; printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; grep -q '\"status\": \"UP\"' <&3"
  interval: 10s
  timeout: 5s
  retries: 30
  start_period: 20s

Connection: close заставляет сервер закрыть сокет после ответа, чтобы grep дочитал тело до EOF и не завис. Тело /health/ready в готовом состоянии содержит "status": "UP" — проба матчит именно эту строку. PostgreSQL проверяется штатным pg_isready, а Keycloak стартует только после condition: service_healthy базы.

При старте с флагом --import-realm Keycloak подхватывает realm из смонтированного каталога ./realm. В логе это выглядит так:

Importing from directory /opt/keycloak/bin/../data/import
KC-SERVICES0030: Full model import requested. Strategy: IGNORE_EXISTING
Realm 'demo' imported
KC-SERVICES0032: Import finished successfully

Поднятый стенд становится healthy примерно за 30 секунд (start_period 20s плюс один-два интервала пробы). Проверить, что realm на месте, можно штатной админ-CLI kcadm.sh:

$ kcadm.sh get realms/demo
{
  "realm" : "demo",
  "enabled" : true
}

start-dev против production start

В command стенда стоит start-dev — и это осознанный выбор для локальной разработки, который нельзя переносить в production. Разница между двумя режимами — не косметическая:

  • start-dev — режим разработки: HTTP без TLS разрешён, hostname-strict выключен, кэши в памяти без кластеризации, включена автоматическая пересборка при смене темы. Удобно поднять и потыкать, но небезопасно и не масштабируется.
  • start (production) — требует явной конфигурации: KC_HOSTNAME с реальным доменом, TLS (KC_HTTPS_* или работа за reverse-proxy с KC_PROXY_HEADERS), настроенный внешний KC_DB, распределённый кэш сессий (Infinispan). По умолчанию start откажется стартовать без валидного hostname и HTTPS — это защита от «случайно уехало в прод в dev-конфиге».

Почему dev-режим нельзя в прод, разбирается подробно в статье про productionготовится, с 16 сентября: dev-креды, отсутствие TLS, in-memory-кэши без HA. На стенде мы намеренно упрощаем: start-dev + внешний Postgres — этого достаточно, чтобы предметно показать модель realm и потоки, не отвлекаясь на TLS и кластер. Весь трафик к боевому IdP обязан идти по TLS с корректным hostname, а секреты клиентов и DB-креды — жить в хранилище секретов, а не в env compose-файла.

Анатомия realm: clients, roles, groups, users

Realm — корневая единица изоляции в Keycloak. Внутри realm живут свои пользователи, роли, клиенты, ключи подписи и настройки; realm demo ничего не знает про пользователей другого realm. Один Keycloak обслуживает много независимых realm — это удобно для мультитенантности или разделения «прод / стейдж / внутренние службы». Realm master — служебный, в нём заводится bootstrap-админ; свои приложения кладут в отдельный realm (у нас — demo).

Внутри realm — четыре ключевые сущности, которые и определяют, кто и как получает токены:

flowchart TD R["realm: demo
(ключи RS256, настройки, lifespans)"] R --> C["Clients"] R --> Ro["Realm roles"] R --> U["Users"] C --> Cb["backend
confidential
service accounts"] C --> Cf["frontend
public
auth code + PKCE S256"] C --> Cc["cli
public
direct access grant"] Ro --> Ru["user"] Ro --> Ra["admin"] U --> Ua["alice → user"] U --> Ub["bob → user + admin"] Cc -. protocol mappers .-> T["access-token (JWT)
realm_access.roles
aud=backend"] Ub --> T

flowchart TD
  R["realm: demo
(ключи RS256, настройки, lifespans)"] R --> C["Clients"] R --> Ro["Realm roles"] R --> U["Users"] C --> Cb["backend
confidential
service accounts"] C --> Cf["frontend
public
auth code + PKCE S256"] C --> Cc["cli
public
direct access grant"] Ro --> Ru["user"] Ro --> Ra["admin"] U --> Ua["alice → user"] U --> Ub["bob → user + admin"] Cc -. protocol mappers .-> T["access-token (JWT)
realm_access.roles
aud=backend"] Ub --> T
Анатомия realm demo: клиенты трёх типов, realm-роли, пользователи с ролями и protocol mappers, которые кладут роли и audience в access-token

Разберём сущности по порядку.

Clients — приложения, которые полагаются на Keycloak. Именно клиент запрашивает токены; у каждого свой тип, свои разрешённые потоки, redirect URI и scopes. Три клиента стенда покрывают три типовых сценария (подробнее — ниже).

Roles бывают двух уровней, и различие важно:

  • realm-роли — глобальные в пределах realm (user, admin в стенде). Подходят для сквозных ролей, которые значат одно и то же во всех приложениях.
  • client-роли — привязаны к конкретному клиенту (например, report-service.viewer). Полезны, когда одна и та же роль в разных сервисах значит разное. В токене они лежат отдельно: realm-роли — в realm_access.roles, client-роли — в resource_access.<client>.roles.

Groups — способ навесить набор ролей и атрибутов на множество пользователей разом: заводим группу «операторы поддержки» с ролями, добавляем в неё людей — роли наследуются. В стенде группы не заведены (двух пользователей проще держать с прямыми ролями), но в реальной установке группы — основной инструмент управления доступом в масштабе.

Users — учётные записи. В стенде их две, alice и bob, с разными наборами ролей — чтобы показать разницу в токенах и в доступе к защищённым эндпоинтам. Пароли в demo-realm сохранены как хэши прямо в экспорте (--users realm_file), поэтому пользователи восстанавливаются после пересоздания стенда и сразу логинятся.

Identity providers (обзорно) — внешние источники входа: соц-логины, корпоративные OIDC/SAML-провайдеры, LDAP/AD. Keycloak выступает брокером: пользователь логинится где-то ещё, Keycloak заводит и связывает локальную учётку. Подключение внешних провайдеров (Яндекс/VK, разбор ЕСИА) — отдельная большая тема, ей посвящена четвёртая статья серииготовится, с 15 сентября.

Confidential против public: клиенты и redirect URI

Тип клиента — первое решение при его заведении, и оно диктует, какие потоки клиенту доступны. Разница в одном: умеет ли клиент хранить секрет.

Клиент Тип Что включено
backend confidential client authentication ON, service accounts ON; standard flow и direct access — OFF
frontend public standard flow + PKCE S256, redirect URI http://localhost:3000/*
cli public direct access grants (password grant для demo-скриптов)
  • Confidential-клиент (backend) имеет секрет и может аутентифицировать сам себя. Такой клиент — это бэкенд, работающий на сервере, где секрет можно хранить безопасно. У backend включены service accounts (клиент получает токен от своего имени по client credentials) и он служит целевым получателем токенов — resource server’ы валидируют aud=backend. Standard flow и direct access у него выключены: бэкенду не нужен браузерный редирект.
  • Public-клиент (frontend, cli) секрет хранить не может — код SPA виден в браузере, CLI распространяется как бинарник. Для таких клиентов единственная защита authorization code flow — PKCE: клиент генерирует code_verifier, шлёт его хэш (code_challenge, метод S256), и перехваченный код без verifier бесполезен. У frontend включён именно этот путь.

Redirect URI — критичная настройка безопасности public-клиента. Keycloak после логина редиректит браузер обратно на приложение только по адресу из белого списка redirect URI клиента. У frontend это http://localhost:3000/*. Слишком широкий шаблон (например, *) — дыра: authorization code можно увести на чужой адрес. Правило простое — перечислять точные адреса приложения, без лишних wildcard.

Client scopes — механизм, определяющий, какие claims и роли попадут в токен клиента. Часть scopes — default (применяются всегда, например roles, который кладёт realm_access.roles), часть — optional (по запросу через параметр scope). Через client scopes навешиваются и protocol mappers — правила, преобразующие данные пользователя и клиента в claims токена (о них — в разделе про token settings).

Потоки на клиенте: authorization code + PKCE и client credentials

Тип клиента определил, какие OAuth2-потоки ему доступны. На стенде задействованы три (механику каждого детально разбирает статья про OAuth2/OIDC-флоу — здесь фокус на том, где они включаются в Keycloak):

  • Authorization code + PKCE (frontend, public) — основной поток для UI и SPA. Пользователь редиректится на страницу логина Keycloak, вводит креды там (приложение пароль не видит), Keycloak возвращает одноразовый код на redirect URI, код обменивается на токены. PKCE (S256) защищает обмен от перехвата кода. Это правильный поток для браузерных и мобильных клиентов.
  • Client credentials (backend, confidential) — сервис-сервис без пользователя. Клиент предъявляет свой секрет и получает токен от собственного имени (service account). Применяется, когда один бэкенд ходит в другой без участия человека.
  • Direct access grant / password grant (cli, public) — клиент сам собирает логин и пароль и шлёт их на token endpoint. Поток не рекомендован для реальных приложений: клиент видит пароль пользователя, а браузерный login-флоу в обход — а с ним и интерактивный второй фактор (OTP-экран, WebAuthn) и редирект на внешние IdP — по умолчанию не участвует (формально к direct-grant-флоу можно привязать свои authenticator’ы, но это нетипично и не заменяет полноценный MFA-флоу браузера). Зато поток удобен для demo-скриптов и тестов: получить токен одним curl без браузерного редиректа. На стенде мы используем его именно для этого — чтобы показать состав токена без поднятия SPA.

Дальше — как раз этот короткий путь: берём токен cli-клиентом и смотрим, что внутри.

Первый токен: token endpoint и состав JWT

Token endpoint realm — http://localhost:8080/realms/demo/protocol/openid-connect/token. Запросим токен для пользователя bob через public-клиент cli по password grant:

curl -s -X POST \
  http://localhost:8080/realms/demo/protocol/openid-connect/token \
  -d grant_type=password \
  -d client_id=cli \
  -d username=bob \
  -d password=bob-demo-2026

В ответе — JSON с access_token (JWT), refresh_token, expires_in и прочим. Декодируем payload access-token (вторая часть JWT, base64url) для bob — вот что реально лежит внутри:

{
  "iss": "http://keycloak:8080/realms/demo",
  "aud": ["backend", "account"],
  "exp": 1783971391,
  "azp": "cli",
  "realm_access": {
    "roles": ["offline_access", "admin", "uma_authorization", "default-roles-demo", "user"]
  }
}

Что здесь важно для бэкенда, который будет этот токен проверять:

  • iss — issuer, http://keycloak:8080/realms/demo. Обратите внимание: токен мы забрали, постучавшись на localhost:8080 с хоста, но в iss стоит keycloak:8080 — потому что адрес issuer жёстко зафиксирован через KC_HOSTNAME и НЕ зависит от того, по какому хосту вы дошли до token endpoint. Это принципиально: бэкенд сверяет iss строго по строке и по нему же делает discovery, поэтому issuer в токене и в конфиге resource server должны совпадать до символа. Рассинхрон (localhost в токене против keycloak в бэкенде, или наоборот) — типичная причина, по которой валидация молча падает; фиксированный KC_HOSTNAME как раз и убирает эту неоднозначность.
  • aud — audience, ["backend", "account"]. Значение backend кладётся туда специальным audience-mapper’ом (об этом ниже); account часто присутствует (его добавляет дефолтный client scope, связанный с клиентом account), но это не гарантия — состав aud зависит от назначенных client scopes и мапперов и может отличаться. Полагаться в проверке нужно строго на своё значение: resource server требует backend в aud.
  • exp — время истечения (Unix-время). При accessTokenLifespan 300 секунд токен живёт 5 минут.
  • azp — authorized party, cli: клиент, запросивший токен.
  • realm_access.roles — realm-роли пользователя. У bob здесь и admin, и user (плюс служебные offline_access, uma_authorization, default-roles-demo).

Для контраста — тот же запрос для alice (у неё только роль user):

{
  "iss": "http://keycloak:8080/realms/demo",
  "aud": ["backend", "account"],
  "exp": 1783971392,
  "azp": "cli",
  "realm_access": {
    "roles": ["offline_access", "uma_authorization", "default-roles-demo", "user"]
  }
}

Разница ровно одна и ровно та, что нужна: в realm_access.roles у alice нет admin. Именно по этому claim бэкенд разграничит доступ — эндпоинт /admin вернёт bob статус 200, а alice — 403, тогда как /me откроется обоим, а запрос без токена получит 401. Как бэкенд валидирует подпись по JWKS и извлекает роли с минимальной нагрузкой на Keycloak — ядро третьей статьи серииготовится, с 14 сентября.

Отдельно про audience. По умолчанию Keycloak не кладёт backend в aud токена, выданного клиентом cli или frontend. Чтобы resource server мог проверять aud=backend, на клиентах cli и frontend заведён audience-mapper (oidc-audience-mapper, included.client.audience=backend). После этого aud содержит backend — и токен, выданный любым из клиентов, явно адресован бэкенду. Это не косметика: проверка audience не даёт использовать токен, выписанный для одного сервиса, против другого.

Роль из токена — это ответ на вопрос «кто вошёл и в каких ролях», но не «что этому кому можно». Enforcement (можно ли admin удалять чужие записи) остаётся на стороне приложения: роль из realm_access.roles мапится на решение через RBAC/ABAC/ReBAC. Keycloak выдаёт роли — авторизация доступа живёт в вашем коде.

Realm-as-code: export/import вместо click-ops

Всё, что мы разобрали — клиенты, роли, mappers, пользователи, — можно завести руками в админ-консоли. И это работает ровно до второго окружения. Кликнутая вручную конфигурация не воспроизводится: её нельзя ревьюить, откатывать, поднимать заново одной командой; «а что там настроено на стейдже» превращается в археологию по вкладкам админки. Модель realm — это конфигурация, а конфигурация должна лежать в Git как код.

Keycloak даёт для этого штатный экспорт-импорт realm в JSON. Экспорт realm целиком, вместе с пользователями:

kc.sh export --dir /tmp/exp --realm demo --users realm_file

Полученный demo-realm.json (в стенде — 2327 строк) описывает весь realm: клиенты с их флоу и redirect URI, роли, mappers, пользователи с хэшами паролей, настройки токенов. Этот файл кладётся в репозиторий рядом с compose, в каталог ./realm, смонтированный в /opt/keycloak/data/import. Обратная сторона — импорт на старте флагом --import-realm (он и стоит в command стенда): при подъёме Keycloak поднимает realm из JSON, и окружение получается идентичным.

Воспроизводимость проверена на стенде напрямую: docker compose down -v (удаляет и volume Postgres) → docker compose up -d → realm импортируется заново, токены bob и alice после переимпорта идентичны ожидаемым, пароли работают из файла. Полный цикл «снёс — поднял» даёт то же самое состояние — это и есть смысл realm-as-code.

Два практических замечания. Секрет confidential-клиента и пароли пользователей в экспорте лежат в открытом/хэшированном виде — для приватного стенда это допустимо, но при публикации такой JSON секреты заменяют плейсхолдерами, а реальные значения подают через секрет-хранилище. И импорт со стратегией IGNORE_EXISTING (видно в логе выше) не перезаписывает уже существующий realm — для обновления конфигурации существующего realm используют отдельные стратегии импорта или Admin REST API.

Token settings: lifespans и protocol mappers

Последний слой — настройки токенов на уровне realm. Два, которые стоит понимать сразу.

Lifespans — времена жизни токенов и сессий. В realm demo они такие:

Настройка Значение Что задаёт
accessTokenLifespan 300 с (5 мин) срок жизни access-token — тот самый exp в JWT
ssoSessionIdleTimeout 1800 с (30 мин) простой сессии до разлогина
ssoSessionMaxLifespan 36000 с (10 ч) максимальная длина SSO-сессии

Компромисс здесь фундаментальный: короткий access-token означает быструю реакцию на отзыв прав (протухнет через 5 минут), но чаще требует обновления через refresh; длинный — дешевле по числу обновлений, но опаснее (отозванный доступ действует до истечения токена). 5 минут — разумный дефолт; тонкая настройка под нагрузку и требования отзыва — тема статьи про валидацию токеновготовится, с 14 сентября.

Protocol mappers — правила, кладущие данные в claims токена. Именно mapper realm roles (из default client-scope roles) кладёт роли пользователя в realm_access.roles — тот claim, по которому бэкенд разграничивает доступ. Audience-mapper, добавляющий backend в aud, — из той же категории. Через mappers в токен добавляют и произвольные атрибуты пользователя (email, кастомные поля), scopes, client-роли. По сути mappers — это мост между моделью Keycloak (пользователи, роли, атрибуты) и содержимым выданного JWT.

Кратко про страницу логина: realm demo ссылается на кастомную тему полем loginTheme: khorost. Тема задаёт брендинг экранов авторизации (шаблоны + CSS). В dev-режиме темы не кешируются, поэтому правки видны после обычного обновления страницы без пересборки образа. Кастомизация экранов логина и подключение внешних провайдеров — предмет четвёртой статьи серииготовится, с 15 сентября.

Итог

  • Деплой Keycloak — это сервис + внешний PostgreSQL. База хранит всё состояние (пользователи, realm, клиенты, ключи); сам Keycloak по данным stateless. На стенде — docker-compose с пиновкой 26.7.0, bootstrap-админом через KC_BOOTSTRAP_ADMIN_*, фиксированным KC_HOSTNAME и health-пробой на management-порту 9000.
  • start-dev — только для разработки. Production start требует TLS, реального hostname, работы за reverse-proxy и распределённого кэша; dev-конфиг в прод переносить нельзя.
  • Модель realm — четыре сущности. Clients (confidential с секретом vs public на PKCE), roles (realm-level vs client-level), groups (роли пачкой), users. Redirect URI public-клиента — критичная настройка безопасности, wildcard опасен.
  • Потоки задаются типом клиента. Authorization code + PKCE для UI, client credentials для сервис-сервис, password grant — только для demo/CLI.
  • Токен — это JWT со стандартными claims. На реальном выводе стенда: iss совпадает с KC_HOSTNAME, aud=backend (через audience-mapper), exp = 5 минут, а realm_access.roles отличает bob (user+admin) от alice (user) — по этому claim бэкенд и разграничивает доступ.
  • Realm держат как код. kc.sh export → JSON в Git → --import-realm на старте: воспроизводимо, ревьюится, поднимается одной командой. Click-ops в админке не масштабируется.

Следующий шаг — интеграция бэкенда: как resource server на Go и Java валидирует этот токен локально по JWKS, извлекает роли и снижает нагрузку на Keycloak. Об этом — третья статья серииготовится, с 14 сентября.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии