Первая статья серии была про решение: когда вообще стоит брать готовый 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 и что реально лежит в выданном токене.
В статье
- Что поднимаем: стенд одним взглядом
- Деплой: docker-compose, внешний Postgres, bootstrap-админ
- start-dev против production start
- Анатомия realm: clients, roles, groups, users
- Confidential против public: клиенты и redirect URI
- Потоки на клиенте: authorization code + PKCE и client credentials
- Первый токен: token endpoint и состав JWT
- Realm-as-code: export/import вместо click-ops
- Token settings: lifespans и protocol mappers
- Итог
- Источники
Что поднимаем: стенд одним взглядом
Стенд — это один 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заводят временного администратора realmmasterпри первом старте — это замена устаревших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: 20sConnection: 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 — четыре ключевые сущности, которые и определяют, кто и как получает токены:
(ключи 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
Разберём сущности по порядку.
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-время). ПриaccessTokenLifespan300 секунд токен живёт 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— только для разработки. Productionstartтребует 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 сентября.
Источники
- Keycloak — Server Administration Guide (realms, clients, roles, groups): https://www.keycloak.org/docs/latest/server_admin/
- Keycloak — Configuring Keycloak (start-dev vs start, hostname, DB, proxy): https://www.keycloak.org/server/configuration
- Keycloak — Importing and exporting realms: https://www.keycloak.org/server/importExport
- Keycloak — Running Keycloak in a container: https://www.keycloak.org/server/containers
- Keycloak — Protocol mappers и client scopes: https://www.keycloak.org/docs/latest/server_admin/#_protocol-mappers
- OpenID Connect Core 1.0 (id_token, claims): https://openid.net/specs/openid-connect-core-1_0.html
- RFC 6749 (OAuth 2.0) и RFC 7636 (PKCE): https://www.rfc-editor.org/rfc/rfc7636
Комментарии