Keycloak под свой продукт: экраны логина и внешние провайдеры

Keycloak под бренд и под внешние учётки. Часть первая — кастомная login-тема: каталог theme.properties + CSS + SVG-лого, наследование от штатной темы, тонкость с неаддитивным свойством styles, локализация через messages, подключение через volume + loginTheme; FreeMarker против keycloakify — честное сравнение. Часть вторая — identity brokering: Keycloak как брокер внешних IdP, first-broker-login и attribute mappers, запускаемая демонстрация на mock-OIDC (реальный прогон цепочки редиректов, brokered-пользователь ext-yandex-demo, federated-identity + роль user), конфиг Яндекс (тип OAuth v2 — id_token не выдаёт), честная оговорка про VK ID (PKCE + device_id — сложнее generic, вероятен адаптер) и честный разбор ЕСИА — почему её нельзя воспроизвести в учебном стенде (аккредитация Минцифры, ГОСТ-TLS).

Прошлая статья закончилась на бэкенде: он проверяет токен и по ролям решает, пускать ли запрос. Но пользователь бэкенда не видит — он видит экран входа, и логинится он не обязательно паролем из вашего realm. Две вещи, которые превращают «поднятый Keycloak» в «Keycloak под ваш продукт»: как выглядит форма логина и через что на неё можно войти. Первое — кастомные темы, второе — федерация (identity brokering): вход через внешние провайдеры, от Яндекса и VK до корпоративных IdP.

Обе темы разобраны на живом стенде digital-cookbook: security/keycloak (Keycloak 26.7.0 + PostgreSQL): на нём собрана минимальная login-тема в стилистике khorost.tech и настроен брокер к внешнему OIDC-провайдеру, brokered-логин прогнан end-to-end. Все конфиги и вывод команд ниже — с этого прогона. А вот ЕСИА (Госуслуги) в публичном стенде воспроизвести нельзя, и об этом — честно, без имитации «живого» подключения.

Ретрофутуристский плакат в стилистике советского sci-fi журнала «Полдень. XXI век»: слева наборная касса-типография собирает фирменный экран входа khorost.tech (кремовая бумага, терракотовая линия, орбитальный логотип) из литер темы; справа — коммутатор-брокер Keycloak с гнёздами внешних провайдеров, в которые воткнуты штекеры «Яндекс», «VK», «mock OIDC» и «Google», а отдельное гнездо «ЕСИА» закрыто пломбой с надписью «требует аккредитации и ГОСТ-TLS»; в центре пользователь входит через внешнее гнездо и получает единый пропуск-JWT с гербом realm

В статье

Две задачи: бренд и внешние учётки

«Экраны логина и внешние провайдеры» — это две разные задачи, которые решаются в Keycloak в разных местах, но обе нужны, чтобы IdP не торчал наружу как чужеродный сервис.

  • Тема (кастомизация). Дефолтная форма Keycloak — тёмно-синяя, с логотипом Keycloak. Пользователь, которого перебросили с вашего сайта на такой экран, видит разрыв: только что был ваш бренд — и вдруг чужой сервис просит пароль. Кастомная тема убирает этот разрыв: форма логина выглядит как часть продукта.
  • Федерация (identity brokering). Пользователь не хочет заводить ещё один пароль. Он хочет войти через учётку, которая у него уже есть — Яндекс, VK, корпоративный аккаунт. Keycloak умеет быть брокером: перенаправить на внешний IdP, принять результат, завести и связать локальную учётку. Ваш бэкенд по-прежнему получает стандартный токен Keycloak — откуда пользователь пришёл, ему знать не нужно.

Начнём с внешнего вида, потом перейдём к тому, через что можно войти.

Тема логина: как устроена

Тема Keycloak — это каталог с ресурсами, а не код. Структура фиксированная: themes/<name>/<type>/, где type — один из login, account, admin, email. Нас интересует login. Внутри — theme.properties (манифест темы), каталог resources/ (CSS, картинки, шрифты) и messages/ (переводы). Минимальная тема для брендинга — это theme.properties плюс один CSS-файл; ни одного шаблона копировать не обязательно.

Работает это через наследование. Тема объявляет parent, и всё, чего она сама не переопределяет — FreeMarker-шаблоны *.ftl, базовые сообщения, вёрстка PatternFly, — берётся от родителя. Наша тема на стенде наследует штатную keycloak и переопределяет ровно три вещи: добавляет свой CSS, кладёт SVG-логотип и меняет одно сообщение (заголовок формы). Каталог на стенде:

themes/
  khorost/
    login/
      theme.properties                 # parent=keycloak + styles + import
      resources/
        css/khorost.css                # брендинг «Полдень» поверх базовой темы
        img/khorost-logo.svg           # орбитальный логотип khorost.tech (inline SVG)
      messages/
        messages_en.properties         # override loginAccountTitle (демо локализации)
        messages_ru.properties         # русская локаль темы

Ключевая мысль: вы не форкаете тему Keycloak целиком, вы кладёте тонкий слой поверх штатной. Ни один .ftl-шаблон на стенде не скопирован — а значит, при обновлении мажора Keycloak (где шаблоны меняются) расходиться будет нечему. Это осознанный выбор в пользу минимализма: чем меньше вы переопределили, тем меньше сломается при апгрейде.

theme.properties и неаддитивное свойство styles

Весь манифест темы на стенде — три строки, и в них спрятана тонкость, на которой легко споткнуться.

parent=keycloak
import=common/keycloak

styles=css/login.css css/khorost.css
  • parent=keycloak — наследуем штатную login-тему со всеми .ftl-шаблонами и базовыми стилями.
  • import=common/keycloak — подключаем общие vendor-ресурсы (шрифты pficon, служебные скрипты), как у родителя.
  • styles=... — а вот здесь тонкость: свойство styles не аддитивное. Значение ребёнка не добавляется к родительскому, а полностью его заменяет. У темы keycloak это styles=css/login.css. Если написать просто styles=css/khorost.css, базовый login.css перестанет подключаться, и форма поедет — пропадёт вся вёрстка PatternFly, поверх которой мы наводим бренд.

Поэтому в styles перечислены оба файла явно: сначала базовый css/login.css (он резолвится по цепочке наследования ресурсов из родителя), затем наш css/khorost.cssпоследним, чтобы его правила переопределяли базовые по каскаду CSS. Свойство stylesCommon (наборы PatternFly v4/v3) мы не трогаем — оно наследуется как есть.

Это тот класс поведения, который в документации есть, но замечаешь его только когда форма внезапно теряет стили. Правило простое: при переопределении styles всегда перечисляйте и родительские файлы, которые хотите сохранить.

Брендинг «Полдень»: CSS, логотип, шрифты

Сам брендинг — это обычный CSS поверх классов PatternFly, которыми размечена штатная форма. Задача темы khorost.tech — привести экран входа к стилистике сайта (советский sci-fi журнал «Полдень. XXI век»: кремово-бежевый фон, тонкие линии, приглушённая офсетная палитра, терракотовый акцент, геометрическая чистота без скруглений). Фрагмент, задающий фон страницы и карточку формы:

:root {
    --kh-bg:     #ece3d0;   /* кремово-бежевый фон */
    --kh-card:   #f7f2e7;   /* тёплая бумага карточки */
    --kh-ink:    #2b2b2b;   /* чернильный текст */
    --kh-accent: #b4552d;   /* терракота — акцент */
    /* Шрифты — ЧЕСТНО системный стек: брендовые woff2 сайта в репо стенда не тащим */
    --kh-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

/* Фон: заменяем дефолтную картинку Keycloak на кремовый офсет с «чертёжной сеткой» */
.login-pf body {
    background: var(--kh-bg);
    background-image:
        repeating-linear-gradient(0deg,  rgba(43,43,43,.035) 0 1px, transparent 1px 40px),
        repeating-linear-gradient(90deg, rgba(43,43,43,.035) 0 1px, transparent 1px 40px);
    color: var(--kh-ink);
    font-family: var(--kh-font-sans);
}

/* Карточка формы: тёплая бумага, терракотовая линия сверху, острые углы */
.card-pf {
    background: var(--kh-card);
    border: 1px solid #d3c7ae;
    border-top: 3px solid var(--kh-accent);
    border-radius: 0;                       /* геометрическая чистота */
    max-width: 480px;
}

/* Кнопка входа: сплошная терракота, капитель, острые углы */
.pf-c-button.pf-m-primary {
    background-color: var(--kh-accent);
    border-radius: 0;
    text-transform: uppercase;
    letter-spacing: 2px;
}

Логотип подключён не картинкой в шаблоне, а фоном в CSSbackground-image: url(...khorost-logo.svg) на #kc-header-wrapper (шапку формы, которая у demo-realm пуста, потому что displayNameHtml не задан). Сам логотип — SVG-файл (resources/img/khorost-logo.svg: орбитальная марка «планета + кольцо + спутник» и вордмарк khorost.tech с подписью IDP · KEYCLOAK), подключённый как внешний CSS-ресурс — не инлайн в HTML и не растровый бинарник. Как вектор он правится текстом и не зависит от плотности пикселей.

Про шрифты — честно: тема держит стилистику «Полдень» на палитре, тонких линиях и геометрии, а гарнитура — системный стек (system-ui, …), а не брендовые Work Sans / Instrument Sans сайта: их бинарные .woff2 в репо стенда не тащим и @font-face не заводим.

Что реально видно на экране входа после подключения темы (проверено скриншотом на стенде): кремовый фон с еле заметной чертёжной сеткой, орбитальный логотип khorost.tech в шапке, карточка тёплой бумаги с терракотовой линией сверху и острыми углами, поля ввода с терракотовым фокусом, кнопка SIGN IN капителью с разрядкой, и ниже — блок «Or sign in with → Mock OIDC (external IdP)» (тот самый identity brokering, к которому перейдём во второй половине статьи). Визуально — ничего общего с дефолтной тёмно-синей темой Keycloak.

Важная граница: что можно и чего нельзя менять темой. Цвета, шрифты, логотип, тексты, расположение блоков CSS-ом — можно и без правки шаблонов. Добавить кастомное поле в форму регистрации, изменить логику экрана, встроить свой виджет — уже нельзя одним CSS: нужно переопределять .ftl-шаблон (и держать его копию, рискуя разойтись с апстримом), либо переходить на keycloakify (о нём — ниже). Для чистого брендинга CSS достаточно; для продуктового UX с логикой — нет.

Локализация: messages без правки шаблонов

Тексты формы логина — это не строки в шаблоне, а ключи сообщений, и тема может нести собственные переводы. Файлы — messages/messages_<locale>.properties; ключи наследуются от base/keycloak, тема переопределяет только нужные. На стенде мы поменяли ровно один ключ — заголовок формы:

# Русская локаль темы. Показывается, только если в realm включена
# интернационализация и добавлен язык ru (Realm settings -> Localization).
loginAccountTitle=Вход — khorost.tech
doLogIn=Войти
usernameOrEmail=Пользователь или e-mail
password=Пароль

Здесь два практических вывода. Первый: брендинг заголовка сделан через сообщение, а не через правку login.ftl — это чистый приём, тема остаётся тонким слоем без единого скопированного шаблона. Второй: русская локаль появляется на форме, только если в realm включена интернационализация (Realm settings → Localization → Internationalization) и добавлен язык ru; тогда на форме возникает переключатель локали, а строки берутся из messages_ru.properties темы поверх штатных. То есть кастомная тема несёт свои переводы, но показывает их только когда realm разрешил многоязычность.

Подключение темы: volume + loginTheme

Тема готова — осталось два шага: положить её туда, где Keycloak её найдёт, и указать realm использовать именно её.

Шаг 1 — примонтировать каталог темы в контейнер Keycloak (в проде — запечь в образ или положить на постоянный том):

keycloak:
  volumes:
    - ./realm:/opt/keycloak/data/import:ro
    - ./themes:/opt/keycloak/themes:ro     # <-- тема доступна Keycloak

Шаг 2 — назначить тему realm. На стенде проще всего прописать её прямо в импортируемом realm-JSON — поле верхнего уровня loginTheme:

{
  "realm": "demo",
  "loginTheme": "khorost",
  ...
}

Альтернатива — через админку (Realm settings → Themes → Login theme = khorost), но тогда изменение нужно пере-экспортировать в realm-файл, иначе оно потеряется при пересоздании стенда. Прямая правка JSON надёжнее для воспроизводимого стенда.

Одна тонкость про кеширование тем, которая различает dev и prod:

  • в start-dev темы не кешируются — правки CSS, логотипа, сообщений видны после обычного F5, без пересборки образа и без рестарта контейнера. Это удобно для разработки темы;
  • в production-режиме start темы кешируются — там правки требуют рестарта или явного отключения кеша (--spi-theme-...-cache-themes=false, что для прода не нужно). Это осознанно: в проде тема статична, и кеш ускоряет отдачу формы.

Проверка на стенде: после docker compose up -d и импорта realm запрос kcadm get realms/demo --fields realm,loginTheme возвращает "loginTheme":"khorost", а форма логина отдаётся с подключёнными css/login.css + css/khorost.css и логотипом image/svg+xml — тема применилась.

FreeMarker против keycloakify: честное сравнение

То, что мы сделали выше, — штатный путь: тема на FreeMarker-шаблонах Keycloak плюс CSS. У него есть современная альтернатива — keycloakify, — и выбор между ними стоит сделать осознанно, а не по инерции.

FreeMarker + CSS (штатный путь). Тема — это каталог, theme.properties, CSS и (при необходимости) копии .ftl. Плюсы: нулевые зависимости и никакого шага сборки, всё «из коробки» Keycloak, минимальная тема — буквально пара файлов. Для брендинга (цвета, логотип, шрифт, тексты) этого достаточно, что мы и показали. Минусы: FreeMarker — старый шаблонизатор; сложную кастомную логику или интерактив на нём писать неудобно, а переопределение экрана требует держать копию .ftl — с риском разойтись с апстримом при обновлении мажора Keycloak, где шаблоны меняются.

keycloakify (React-темы). Вы пишете экраны логина на React/TypeScript, а тулинг собирает их в стандартную Keycloak-тему (тот же theme/<name>/login, но с готовым JS/CSS-бандлом и FTL-обёрткой). Плюсы: React/TS-стек, компонентная модель, hot-reload в dev, Storybook всех экранов, типобезопасность. Минусы: нужен Node-тулчейн и шаг сборки в CI, появляется зависимость от совместимости keycloakify с версией Keycloak, а бандл тяжелее «пары CSS-файлов».

Вывод без универсального ответа: для чистого брендинга (наш случай) FreeMarker + CSS проще и надёжнее — меньше движущихся частей, нечему ломаться при апгрейде. Для продуктового login-UX с логикой, кастомными полями и дизайн-системой на React keycloakify оправдан — он даёт нормальный фронтенд-стек вместо борьбы с FreeMarker. Граница ровно там же, где в разделе про брендинг: «покрасить» — CSS; «переписать поведение» — React-тулинг. На стенде keycloakify мы не собирали — только назвали как альтернативу.

Identity brokering: Keycloak как брокер

Переходим ко второй задаче — входу через внешние учётки. Механизм называется identity brokering, и роль Keycloak в нём — брокер: он стоит между вашим приложением и внешним провайдером.

Идея в одном абзаце. Пользователь на форме логина жмёт «Войти через Яндекс». Keycloak перенаправляет его на Яндекс, тот аутентифицирует пользователя и возвращает результат обратно в Keycloak. Keycloak при первом таком входе заводит у себя локальную учётку и связывает её с внешним аккаунтом (first-broker-login flow), заполняя атрибуты из claims внешнего IdP через mappers. При последующих входах он узнаёт этот внешний аккаунт и логинит уже в существующую локальную учётку. А вашему приложению Keycloak выдаёт свой стандартный токен — с iss вашего realm. Приложение не знает и не должно знать, что за пользователем стоит Яндекс: оно работает с одним IdP.

Ключевое следствие: федерация не меняет контракт бэкенда. Всё, что мы разбирали в третьей статье — локальная JWKS-валидация, проверка iss/aud/exp, извлечение realm_access.roles, — работает ровно так же. Внешний провайдер — это деталь того, как пользователь попал в realm, а не того, как бэкенд проверяет токен.

Поток целиком:

sequenceDiagram participant U as Пользователь (браузер) participant KC as Keycloak (брокер) participant IdP as Внешний IdP (Яндекс/VK/mock) U->>KC: 1. На форме логина «Войти через ...» KC-->>U: 2. redirect на authorization endpoint IdP U->>IdP: 3. Аутентификация на стороне IdP IdP-->>U: 4. redirect с authorization code на /broker/alias/endpoint U->>KC: 5. Передаёт code брокеру KC->>IdP: 6. Обмен code на токен (серверно, token endpoint) IdP-->>KC: 7. id_token + userinfo (claims) Note over KC: 8. first-broker-login — завести и связать учётку, mappers кладут claims в атрибуты и роль KC-->>U: 9. redirect на клиента с кодом Keycloak U->>KC: 10. Обмен кода на access_token Keycloak (iss = ваш realm)

sequenceDiagram
    participant U as Пользователь (браузер)
    participant KC as Keycloak (брокер)
    participant IdP as Внешний IdP (Яндекс/VK/mock)
    U->>KC: 1. На форме логина «Войти через ...»
    KC-->>U: 2. redirect на authorization endpoint IdP
    U->>IdP: 3. Аутентификация на стороне IdP
    IdP-->>U: 4. redirect с authorization code на /broker/alias/endpoint
    U->>KC: 5. Передаёт code брокеру
    KC->>IdP: 6. Обмен code на токен (серверно, token endpoint)
    IdP-->>KC: 7. id_token + userinfo (claims)
    Note over KC: 8. first-broker-login — завести и связать учётку, mappers кладут claims в атрибуты и роль
    KC-->>U: 9. redirect на клиента с кодом Keycloak
    U->>KC: 10. Обмен кода на access_token Keycloak (iss = ваш realm)
Identity brokering: пользователь входит через внешний IdP, Keycloak (брокер) на первом входе заводит и связывает локальную учётку, а приложению отдаёт свой токен с iss своего realm

Два места, которые настраиваются, — это сам identity provider (endpoints внешнего IdP, client_id/secret, scopes) и набор mappers (какой claim внешнего IdP кладётся в какой атрибут/роль локального пользователя). Дальше — как это выглядит на стенде.

Стенд: brokered-логин на mock-OIDC

Демонстрировать brokering на реальном Яндексе в публичном стенде нельзя — нужен зарегистрированный client_secret, которого не место в репозитории. Поэтому в качестве «внешнего IdP» на стенде поднят лёгкий mock-OIDC-провайдер (ghcr.io/navikt/mock-oauth2-server:2.1.10, порт 8083): он отдаёт полный OIDC discovery и JWKS из коробки и настроен так, чтобы неинтерактивно выдавать фиксированный профиль внешнего пользователя. Это позволяет прогнать весь поток brokering запускаемо, без реальных секретов. Для Яндекса конфиг отличается от mock только адресами endpoints, scopes и mappers (generic OAuth v2); а вот VK ID устроен сложнее обычного generic-провайдера — о его дополнительном контракте честно ниже.

Identity provider mock в realm demo — обычный generic OIDC-провайдер (providerId: oidc). Конфиг (секрет — demo-only, в реальном провайдере он идёт из секрет-менеджера):

{
  "alias": "mock",
  "displayName": "Mock OIDC (external IdP)",
  "providerId": "oidc",
  "enabled": true,
  "trustEmail": true,
  "firstBrokerLoginFlowAlias": "first broker login",
  "config": {
    "clientId": "keycloak-broker",
    "clientSecret": "broker-demo-secret",
    "clientAuthMethod": "client_secret_post",
    "syncMode": "IMPORT",
    "defaultScope": "openid profile email",
    "authorizationUrl": "http://localhost:8083/default/authorize",
    "tokenUrl":         "http://mock-oidc:8083/default/token",
    "jwksUrl":          "http://mock-oidc:8083/default/jwks",
    "userInfoUrl":      "http://mock-oidc:8083/default/userinfo",
    "issuer":           "http://mock-oidc:8083/default",
    "validateSignature": "true",
    "useJwksUrl": "true"
  }
}

И пять mappers — они заполняют локального brokered-пользователя из claims внешнего IdP. Тип «Attribute Importer» (oidc-user-attribute-idp-mapper) переносит claim в атрибут, отдельные мапперы — username из шаблона и hardcoded-роль:

[
  { "name": "username",  "identityProviderMapper": "oidc-username-idp-mapper",
    "config": { "template": "${CLAIM.preferred_username}" } },
  { "name": "email",     "identityProviderMapper": "oidc-user-attribute-idp-mapper",
    "config": { "claim": "email", "user.attribute": "email" } },
  { "name": "firstName", "identityProviderMapper": "oidc-user-attribute-idp-mapper",
    "config": { "claim": "given_name", "user.attribute": "firstName" } },
  { "name": "lastName",  "identityProviderMapper": "oidc-user-attribute-idp-mapper",
    "config": { "claim": "family_name", "user.attribute": "lastName" } },
  { "name": "grant-user","identityProviderMapper": "oidc-hardcoded-role-idp-mapper",
    "config": { "role": "user" } }
]

То есть: preferred_username внешнего IdP становится username локальной учётки, email/given_name/family_name — соответствующими атрибутами, а grant-user жёстко назначает brokered-пользователю realm-роль user. После этого его токен Keycloak несёт realm_access.roles: [..., "user"] — и бэкенд из третьей статьи разграничивает доступ по этой роли, не подозревая о внешнем происхождении пользователя.

Небольшая техническая заметка про создание атрибутных mappers через kcadm: их нельзя завести флагом -s 'config.user.attribute=email' — из-за точки в имени ключа nested-парсер kcadm ломается. На стенде мапперы создавались JSON-телом (create ... -f - со stdin) — это надёжный обходной приём.

Тонкость issuer: разные хосты для браузера и сервера

Здесь всплывает нюанс, родственный тому, что был с KC_HOSTNAME в третьей статье, но с новым поворотом: в brokering участвуют два актора с разных сторон сети. Браузер идёт на authorization endpoint внешнего IdP с хоста, а Keycloak обменивает код на токен и ходит за JWKS/userinfo серверно, из docker-сети. Один и тот же mock-провайдер эти адреса видит по-разному.

Решается это не через discovery (который вывел бы один хост для всех endpoints), а явным заданием URL с правильным хостом для каждого:

endpoint URL в конфиге IdP кто вызывает
authorizationUrl http://localhost:8083/... браузер (с хоста)
tokenUrl http://mock-oidc:8083/... Keycloak (docker-сеть)
jwksUrl http://mock-oidc:8083/... Keycloak
userInfoUrl http://mock-oidc:8083/... Keycloak
issuer http://mock-oidc:8083/default = iss серверного токена

Поскольку Keycloak получает токен серверно (token endpoint на mock-oidc:8083), iss в id_token равен http://mock-oidc:8083/default — и совпадает с issuer в конфиге провайдера, иначе Keycloak отклонил бы токен. В production этой раздвоенности нет: внешний провайдер (Яндекс, VK) доступен по одному публичному домену и с хоста браузера, и с сервера Keycloak, а KC_HOSTNAME совпадает с реальным доменом — специальных ухищрений не требуется. Раздвоение хостов — артефакт именно локального docker-стенда, и полезно понимать его природу, чтобы не переносить костыли в прод.

Реальный прогон brokered-логина

На стенде весь поток прогоняется неинтерактивным скриптом (broker-demo.sh): он инициирует authorization code + PKCE-логин через IdP mock, вручную проходит цепочку редиректов брокера и в конце обменивает код Keycloak на access-token. Реальный вывод (на свежем стенде после docker compose down -v && up, воспроизводимость проверена):

== PKCE + инициируем brokered-логин через IdP 'mock' ==
   hop 1: 303  (keycloak:8080/realms/demo/protocol/openid-connect/auth)
   hop 2: 303  (keycloak:8080/realms/demo/broker/mock/login)
   hop 3: 302  (localhost:8083/default/authorize)          # внешний IdP
   hop 4: 302  (keycloak:8080/realms/demo/broker/mock/endpoint)
   hop 5: 302  (keycloak:8080/realms/demo/login-actions/first-broker-login)
   hop 6: 302  (keycloak:8080/realms/demo/broker/after-first-broker-login)
   финальный redirect на клиента — authorization code Keycloak получен

== Обмен кода Keycloak на access_token (PKCE) ==
   Keycloak access_token payload (фрагмент):
     "iss":"http://keycloak:8080/realms/demo"
     "sub":"cd095524-1804-4e99-aaa9-e872aa2e16b3"
     "realm_access":{"roles":["offline_access", ... "user", ...]}
     "preferred_username":"ext-yandex-demo"
     "email":"ext.user@yandex.mock"

Шесть hop’ов — это и есть поток с диаграммы вживую: инициатор auth (hop 1) → редирект на брокер broker/mock/login (hop 2) → внешний IdP localhost:8083/authorize (hop 3) → возврат кода в broker/mock/endpoint (hop 4) → first-broker-login (hop 5, завести/связать учётку) → after-first-broker-login (hop 6) → код Keycloak клиенту. First-broker-login прошёл без интерактивной страницы Review Profile, потому что mock отдал полный профиль (email + given_name + family_name) — Keycloak нечего было «доспрашивать».

Что осталось в realm после логина (проверено через kcadm):

GET users/{id}/federated-identity  ->
  [ { "identityProvider":"mock", "userId":"ext-user-01", "userName":"ext-yandex-demo" } ]

GET users/{id}/role-mappings/realm ->
  [ { "name":"user" }, { "name":"default-roles-demo" } ]

Итог прогона предметный: внешний sub=ext-user-01 связан с локальным пользователем ext-yandex-demo, атрибуты (email, имя) подтянуты из claims через мапперы, назначена realm-роль user (hardcoded-mapper), и Keycloak выдал свой access-token с iss=http://keycloak:8080/realms/demo. Это ровно то, ради чего нужен брокер: один внешний вход — один локальный пользователь realm — один стандартный токен для бэкенда.

Яндекс и VK: подключение реальных РФ-провайдеров

Реальные российские провайдеры подключаются тем же механизмом brokering, что и mock. Отличаются только адреса endpoints, scopes, мапперы под конкретные имена claims — и тип провайдера в Keycloak. Тип выбирается не по вкусу, а по тому, отдаёт ли провайдер id_token: если да — oidc (OpenID Connect v1.0), если это чистый OAuth2 с профилем через userinfo — oauth2 (OAuth v2, отдельный тип в списке Add provider). Взять oidc для провайдера без id_token — типовая ошибка: OIDC-провайдер ждёт id_token в token-ответе и на его отсутствии спотыкается. clientSecret не в репозитории: на стенде — плейсхолдер/env, в проде — из секрет-менеджера. И один обязательный шаг на стороне провайдера: в кабинете приложения прописать redirectUri вида https://<keycloak-домен>/realms/<realm>/broker/<alias>/endpoint.

Яндекс (Yandex ID / Yandex OAuth). Приложение регистрируется на oauth.yandex.ru, включается доступ к email и логину. Discovery у Яндекса неполный — endpoints задают явно; id_token Яндекс не выдаёт, профиль отдаётся через userinfo (login.yandex.ru/info). Это чистый OAuth2 с userinfo, а не OIDC, поэтому в Keycloak его добавляют типом OAuth v2 (providerId: "oauth2"), а не «OpenID Connect v1.0» (Add provider → OAuth v2):

{
  "alias": "yandex",
  "displayName": "Яндекс",
  "providerId": "oauth2",
  "config": {
    "clientId": "${YANDEX_CLIENT_ID}",
    "clientSecret": "${YANDEX_CLIENT_SECRET}",
    "authorizationUrl": "https://oauth.yandex.ru/authorize",
    "tokenUrl":         "https://oauth.yandex.ru/token",
    "userInfoUrl":      "https://login.yandex.ru/info?format=json",
    "defaultScope":     "login:email login:info"
  }
}

Мапперы (тип Attribute Importer, как на стенде): default_emailemail, login/idusername, first_name/last_namefirstName/lastName. Профиль извлекается из userinfo, не из id_token.

VK (VK ID). С VK оговорка честнее и сложнее, чем «поменяй endpoints». Актуальный VK ID — это не обычный OIDC/OAuth2-провайдер: он требует PKCE, возвращает в callback дополнительный параметр device_id и требует передавать его при обмене authorization code на токены и при refresh. Актуальные endpoints живут на домене id.vk.ru (не только id.vk.com из старых примеров): /authorize, /oauth2/auth, /oauth2/user_info. Этот дополнительный контракт — device_id, который нужно протащить из ответа авторизации в token-запрос, — выходит за рамки того, что стоковый generic OAuth v2 / OIDC identity provider Keycloak умеет из коробки: пробросить device_id между callback и обменом кода стоковому провайдеру негде.

Поэтому честно: интеграцию VK ID со стоковым провайдером Keycloak я в стенде не проверял — и утверждать «работает как обычный generic OIDC» не стану. Реалистично один из двух путей: (1) кастомный identity-provider SPI / адаптер под VK ID (обрабатывающий PKCE + device_id), либо (2) прокси-слой, приводящий VK ID к каноничному OIDC-контракту. Плюс отдельная тонкость маппинга: email VK ID возвращает в ответе /oauth2/user_info при запрошенном scope. Практический вывод: VK ID — не «две строки конфига», а самостоятельная интеграционная задача; пиновать под текущую версию VK ID и проверять на живом контуре.

Общая рекомендация для РФ-аудитории: российские провайдеры (Яндекс, VK) — как основной вариант входа, зарубежные (Google, GitHub) — альтернативой. И trustEmail включать осознанно: доверять email внешнего IdP стоит только если провайдер отдаёт его как verified.

ЕСИА: почему только концептуально

Логичный следующий вопрос для РФ-продукта — «а Госуслуги (ЕСИА) так же подключаются?». Честный ответ: архитектурно — да, тем же brokering, но в публичном учебном стенде это не воспроизводимо, и имитировать «живое» подключение здесь я не буду. Причины конкретны, а не «сложно вообще».

  • Аккредитация Минцифры. ЕСИА — государственный IdP. Доступ к нему как информационной системы требует регистрации ИС/РИС и согласования; нельзя просто «зарегистрировать OAuth-приложение», как у Яндекса или VK. Это административный барьер, а не технический.
  • ГОСТ-TLS и подпись. Взаимодействие с ЕСИА идёт по каналам с ГОСТ-шифрованием (сертификаты аккредитованных УЦ, СКЗИ вроде КриптоПро) и с подписанием запросов по ГОСТ. Стандартный Keycloak с RS256 и обычным TLS «из коробки» это не закрывает — нужен ГОСТ-терминирующий прокси (например, stunnel или nginx с ГОСТ-движком) перед Keycloak плюс кастомная работа с подписью запросов.
  • Специфика профиля. ЕСИА исторически ближе к собственному профилю OAuth2 (и SAML-схемам), набор scope и claims регламентирован (СНИЛС, подтверждённая учётная запись, уровни УЗ — упрощённая / стандартная / подтверждённая), плюс требования к журналированию и защите ПДн.

Как это ложится на Keycloak: ЕСИА подключается через generic OIDC/OAuth2-провайдер (или собственный SPI) плюс ГОСТ-TLS-прослойку, а атрибуты (СНИЛС, уровень УЗ) переносятся теми же мапперами, что и у Яндекса. Но развернуть это в открытом стенде без аккредитации и СКЗИ невозможно — поэтому демонстрацию brokering мы даём на mock-OIDC и конфиге Яндекс/VK, а ЕСИА оставляем как архитектурно возможный, но регуляторно и криптографически особый случай. Фабриковать «прогон ЕСИА» было бы нечестно.

Важная оговорка про актуальность: изложенное выше — это общая архитектурная форма, а не пошаговый регламент. Конкретика ЕСИА (версия протокольного профиля, точный набор scope и claims, уровни учётной записи, требования к сертификатам и журналированию, endpoints тестового и продуктивного контуров) задаётся официальными методическими рекомендациями и регламентом подключения ИС к ЕСИА, которые версионируются и периодически меняются. Перед реальной интеграцией сверяйтесь с текущей редакцией этих документов на официальных ресурсах (Минцифры / технический портал ЕСИА) и фиксируйте дату актуальности — детали здесь намеренно даны на уровне подхода, а не как замена действующему регламенту.

Social login как простой контраст

На фоне ЕСИА видно, насколько проще устроены соц-провайдеры. Google и GitHub в Keycloak — это встроенные типы identity provider (не generic OIDC, а именно Google/GitHub в списке): достаточно вставить client_id/client_secret из консоли провайдера и прописать redirect URI. Endpoints, scopes и базовые мапперы Keycloak уже знает — заводить их руками, как для generic-провайдера, не нужно.

Это удобный ориентир сложности подключения: встроенные соц-провайдеры (Google/GitHub) — два поля; generic-провайдер (Яндекс, OAuth v2) — endpoints + scopes + мапперы руками; VK ID — ступенью выше (PKCE + device_id, стоковый провайдер может не подойти — вероятен custom SPI/адаптер); ЕСИА — плюс аккредитация и ГОСТ-TLS. Один и тот же механизм brokering, но цена подключения растёт по мере того, как провайдер дальше от «стандартного OIDC из коробки».

Мост к «своему» входу и РФ-углу

Стоит соотнести эту статью с двумя точками серии.

С «сделай сам»: в цикле auth есть отдельная статья про вход и регистрацию, где OAuth-вход, привязку внешних аккаунтов и заведение локальной учётки нужно писать руками — обрабатывать редиректы, обменивать код, доставать профиль, связывать с существующим пользователем, думать про верификацию email. Здесь всё это — конфиг identity provider плюс мапперы. Ровно тот обмен, о котором первая статья серии: вы меняете разработку auth-пласта на эксплуатацию готового IdP. Федерация — один из самых наглядных примеров этой экономии: каждый внешний провайдер «сделай сам» — отдельная интеграция, в Keycloak — запись в realm.

С РФ-углом первой статьи: self-hosted Keycloak on-prem закрывает вход через Яндекс стандартным механизмом (VK ID из-за PKCE + device_id требует адаптера — см. выше), а ЕСИА показывает границу — где заканчивается «настроил провайдера» и начинается «нужна аккредитация и СКЗИ». Это не минус Keycloak, а свойство государственного IdP; понимать эту границу полезно ещё на этапе выбора.

Механику самих OAuth2/OIDC-флоу, которые здесь прячутся за словами «authorization code» и «обмен кода на токен», подробно разбирает статья про OAuth2 и OIDC — Keycloak в brokering выступает и клиентом внешнего IdP, и сервером для вашего приложения одновременно.

Итог

  • Тема логина — тонкий слой поверх штатной. Каталог themes/<name>/login, theme.properties с parent=keycloak, свой CSS и SVG-лого; ни одного скопированного .ftl — нечему расходиться при апгрейде. Тонкость: свойство styles не аддитивное, значение ребёнка заменяет родительское — перечисляйте и базовый login.css, и свой файл, свой последним.
  • Брендинг — это CSS, не код. Цвета, шрифты, логотип (фоном на шапке), тексты — без правки шаблонов. Кастомные поля и логику одним CSS не сделать: там нужен .ftl или keycloakify.
  • Локализация — через messages. Тема несёт свои переводы (messages_<locale>.properties); заголовок формы менять сообщением, а не шаблоном. Русская локаль видна, только если в realm включена интернационализация.
  • FreeMarker против keycloakify. Для чистого брендинга — FreeMarker + CSS проще и надёжнее; для продуктового login-UX с логикой на React — keycloakify оправдан ценой Node-тулчейна и шага сборки.
  • Identity brokering — Keycloak как брокер. Внешний вход → first-broker-login заводит и связывает локальную учётку → мапперы кладут claims в атрибуты и роли → приложению уходит стандартный токен с iss вашего realm. Контракт бэкенда из третьей статьи не меняется.
  • На стенде — запускаемо на mock-OIDC. Реальный прогон: шесть hop’ов брокера, brokered-пользователь ext-yandex-demo связан с внешним ext-user-01, роль user назначена, токен Keycloak выдан. Тонкость локального стенда — разные хосты endpoints для браузера и сервера; в проде её нет.
  • Яндекс — тип OAuth v2 (id_token не выдаёт, профиль через userinfo), endpoints + scopes + мапперы руками, секреты не в репо. VK ID — сложнее generic: PKCE + device_id, стоковый провайдер Keycloak может не подойти (вероятен custom SPI/адаптер) — в стенде не проверялось. ЕСИА — только концептуально: аккредитация Минцифры и ГОСТ-TLS вне учебного стенда. Google/GitHub — встроенные провайдеры, два поля.

Осталась одна тема серии — самая «взрослая»: как эксплуатировать Keycloak в production. HA и кластеризация, внешняя БД как единственный источник состояния, бэкапы и восстановление, болезненные обновления мажоров. Об этом — пятая, заключительная статья серииготовится, с 16 сентября.

Источники

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

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

Комментарии