Новаком
KEYCLOAK

Keycloak SSO + Spring Boot: полный гайд от Docker до production

Настройка Keycloak SSO со Spring Boot 3: Docker Compose, realm, клиенты, роли, Spring Security OAuth2, RBAC. Пошаговый tutorial с Kotlin-кодом и тестами.

Н
Новаком
2026-05-24 · 12 минут чтения

Почему Keycloak, а не «свой» SSO

Большинство туториалов по Keycloak заканчиваются на «нажмите Login — работает!». А потом начинается продакшен: realm-роли перемешиваются с client-ролями, токены кэшируются неправильно, HTTPS ломает redirect URI, а Multi-Tenant превращается в цирк. Эта статья — про весь путь: от docker compose up до рабочей конфигурации, которую не стыдно деплоить.

Keycloak — open-source identity provider от Red Hat. Для личных кабинетов и корпоративных порталов он закрывает четыре задачи, которые дорого писать руками:

  • SSO (Single Sign-On) — один логин на все приложения. Пользователь авторизуется один раз и получает доступ ко всем сервисам без повторного ввода пароля.
  • Social Login — Google, GitHub, Яндекс ID, VK ID. Настройка через админку, без кода.
  • RBAC и Fine-Grained Permissions — роли, группы, scopes, политики авторизации. Всё через UI или API.
  • MFA — OTP, WebAuthn, Kerberos. Включается галочкой в realm settings.

Если вы строите корпоративный портал или SaaS с несколькими тенантами — Keycloak экономит 3-6 месяцев разработки по сравнению со своей реализацией OAuth2 + RBAC.


Шаг 1. Docker Compose: Keycloak + PostgreSQL

Keycloak из коробки работает с H2, но в продакшене это не вариант — данные живут в памяти контейнера. Ставим PostgreSQL.

# docker-compose.yml
version: "3.9"

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${KC_DB_PASSWORD:-changeme}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak"]
      interval: 5s
      timeout: 3s
      retries: 10

  keycloak:
    image: quay.io/keycloak/keycloak:25.0
    command: start-dev
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${KC_DB_PASSWORD:-changeme}
      KC_HOSTNAME: localhost
      KC_HTTP_PORT: 8180
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
    ports:
      - "8180:8180"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pgdata:

Запускаем:

docker compose up -d

Через 15-20 секунд Keycloak доступен на http://localhost:8180. Логин: admin / пароль из KC_ADMIN_PASSWORD.

Важно: start-dev — это dev-режим без HTTPS и с отключённым кэшированием тем. Для продакшена используйте start с полноценным TLS. Об этом — в секции Production Checklist ниже.


Шаг 2. Realm и Client

Создание Realm

Realm — это изолированное пространство: свои пользователи, роли, клиенты, настройки. Realm master — системный, его не трогаем.

  1. Откройте Admin Console → нажмите на выпадающий список «master» → Create Realm.
  2. Realm name: my-app (латиница, lowercase, без пробелов).
  3. Enabled: ON → Create.

Настройка Client

Client в Keycloak — это ваше приложение (бэкенд, фронтенд, мобилка). Для Spring Boot бэкенда нужен confidential client.

  1. Clients → Create client.
  2. Client ID: spring-backend.
  3. Client authentication: ON (это делает клиент confidential — с секретом).
  4. Authorization: OFF (пока не нужно, включим позже при необходимости).
  5. Valid redirect URIs: http://localhost:8080/* (для dev).
  6. Web origins: http://localhost:3000 (если фронт на Next.js).

Сохраните. Перейдите во вкладку Credentials — скопируйте Client secret. Он понадобится в application.yml.

Для SPA-фронтенда создайте второй client:

  1. Client ID: spa-frontend.
  2. Client authentication: OFF (public client — SPA не может хранить секрет).
  3. Valid redirect URIs: http://localhost:3000/*.
  4. Web origins: + (разрешает CORS для всех redirect URI).

Экспорт конфигурации

Keycloak позволяет экспортировать realm в JSON для воспроизводимости. Это спасает при настройке CI/CD:

docker exec -it keycloak-keycloak-1 /opt/keycloak/bin/kc.sh export \
  --dir /opt/keycloak/data/export \
  --realm my-app \
  --users realm_file

Скопируйте JSON из контейнера:

docker cp keycloak-keycloak-1:/opt/keycloak/data/export/my-app-realm.json ./keycloak/

Теперь realm можно импортировать при старте:

keycloak:
  # ...
  command: start-dev --import-realm
  volumes:
    - ./keycloak/my-app-realm.json:/opt/keycloak/data/import/my-app-realm.json

Шаг 3. Пользователи, роли и группы

Роли

В Keycloak есть два типа ролей:

  • Realm roles — глобальные, видны всем клиентам. Примеры: admin, user, manager.
  • Client roles — привязаны к конкретному клиенту. Пример: клиент billing-service имеет роль invoice-viewer.

Типичная ошибка — путать их. Если ваш Spring Boot бэкенд проверяет hasRole("ADMIN"), а роль ADMIN создана как client role у другого клиента — Spring её не увидит. Начните с realm roles, переходите к client roles когда у вас реально несколько сервисов с разными наборами разрешений.

Создайте realm roles:

  1. Realm roles → Create roleROLE_ADMIN.
  2. Повторите для ROLE_USER, ROLE_MANAGER.

Префикс ROLE_ — конвенция Spring Security. Keycloak его не требует, но Spring по умолчанию добавляет ROLE_ к hasRole(). Проще сразу именовать с префиксом.

Группы

Группы — это способ назначать роли массово. Вместо того чтобы вручную давать 50 пользователям роли ROLE_USER + ROLE_REPORTS, вы создаёте группу employees, назначаете ей эти роли, и добавляете пользователей в группу.

  1. Groups → Create groupemployees.
  2. Откройте группу → Role mapping → Assign role → выберите ROLE_USER, ROLE_REPORTS.

Тестовые пользователи

  1. Users → Create user.
  2. Username: testuser, Email: test@example.com, Email verified: ON.
  3. Credentials → Set password → test123, Temporary: OFF.
  4. Role mapping → Assign role → ROLE_USER.

Повторите для testadmin с ролью ROLE_ADMIN.


Шаг 4. Spring Boot 3 + Spring Security OAuth2

Вот где начинается код. Мы настраиваем Spring Boot как OAuth2 Resource Server — он получает JWT-токен от клиента, валидирует подпись через публичный ключ Keycloak и извлекает роли.

Зависимости

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
    // для тестов
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("com.github.dasniko:testcontainers-keycloak:3.5.1")
}

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/my-app
          jwk-set-uri: http://localhost:8180/realms/my-app/protocol/openid-connect/certs

# Кастомная настройка маппинга ролей
keycloak:
  resource: spring-backend
  realm: my-app

issuer-uri — Keycloak автоматически отдаёт OpenID Connect discovery по пути /.well-known/openid-configuration. Spring подтянет оттуда public key для валидации JWT-подписи. jwk-set-uri — резервный вариант, если discovery недоступен при старте.

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                authorize("/api/public/**", permitAll)
                authorize("/actuator/health", permitAll)
                authorize("/api/admin/**", hasRole("ADMIN"))
                authorize(anyRequest, authenticated)
            }
            oauth2ResourceServer {
                jwt {
                    jwtAuthenticationConverter = keycloakJwtAuthenticationConverter()
                }
            }
            csrf { disable() } // REST API — CSRF не нужен для Bearer-токенов
            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }
        }
        return http.build()
    }

    @Bean
    fun keycloakJwtAuthenticationConverter(): JwtAuthenticationConverter {
        val converter = JwtAuthenticationConverter()
        converter.setJwtGrantedAuthoritiesConverter(KeycloakRoleConverter())
        return converter
    }
}

KeycloakRoleConverter — ключевой класс

Keycloak кладёт роли в JWT в нестандартное для Spring место. Вместо стандартного scope или authorities — роли лежат в realm_access.roles и resource_access.{client-id}.roles. Spring Security по умолчанию этого не понимает. Нужен кастомный конвертер:

class KeycloakRoleConverter : Converter<Jwt, Collection<GrantedAuthority>> {

    override fun convert(jwt: Jwt): Collection<GrantedAuthority> {
        val authorities = mutableSetOf<GrantedAuthority>()

        // Realm roles
        val realmAccess = jwt.getClaimAsMap("realm_access")
        if (realmAccess != null) {
            @Suppress("UNCHECKED_CAST")
            val roles = realmAccess["roles"] as? List<String> ?: emptyList()
            roles.mapTo(authorities) { SimpleGrantedAuthority(it) }
        }

        // Client roles
        val resourceAccess = jwt.getClaimAsMap("resource_access")
        if (resourceAccess != null) {
            @Suppress("UNCHECKED_CAST")
            val clientAccess = resourceAccess["spring-backend"] as? Map<String, Any>
            @Suppress("UNCHECKED_CAST")
            val clientRoles = clientAccess?.get("roles") as? List<String> ?: emptyList()
            clientRoles.mapTo(authorities) { SimpleGrantedAuthority(it) }
        }

        return authorities
    }
}

Без этого конвертера @PreAuthorize("hasRole('ADMIN')") никогда не сработает — Spring просто не увидит роли из Keycloak-токена. Это самая частая проблема, с которой приходят на Stack Overflow.

Тестовый контроллер

@RestController
@RequestMapping("/api")
class DemoController {

    @GetMapping("/public/health")
    fun health() = mapOf("status" to "ok")

    @GetMapping("/profile")
    @PreAuthorize("hasRole('ROLE_USER')")
    fun profile(authentication: JwtAuthenticationToken): Map<String, Any?> {
        val jwt = authentication.token
        return mapOf(
            "sub" to jwt.subject,
            "email" to jwt.getClaimAsString("email"),
            "name" to jwt.getClaimAsString("preferred_username"),
            "roles" to authentication.authorities.map { it.authority }
        )
    }

    @GetMapping("/admin/users")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    fun adminUsers() = mapOf("users" to listOf("user1", "user2"))
}

Проверяем:

# Получаем токен
TOKEN=$(curl -s -X POST \
  http://localhost:8180/realms/my-app/protocol/openid-connect/token \
  -d "grant_type=password" \
  -d "client_id=spring-backend" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "username=testuser" \
  -d "password=test123" | jq -r '.access_token')

# Запрос с токеном
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/profile

Шаг 5. Role-Based Access Control (RBAC)

@PreAuthorize — удобно для простых проверок. Но в реальных проектах — личных кабинетах, CRM, B2B-порталах — нужны проверки вроде «пользователь может редактировать только свои заказы» или «менеджер видит заказы своего отдела». Тут hasRole уже не хватает.

Вариант 1: SpEL-выражения

@PreAuthorize("hasRole('ROLE_ADMIN') or #userId == authentication.token.subject")
@GetMapping("/users/{userId}/orders")
fun getUserOrders(@PathVariable userId: String): List<Order> {
    return orderService.findByUserId(userId)
}

SpEL читает authentication.token.subject — это sub из JWT (UUID пользователя в Keycloak). Вы сравниваете его с userId из URL.

Вариант 2: Custom Security Expression

Для сложной логики вынесите проверки в отдельный bean:

@Component("authz")
class AuthorizationService(
    private val orderRepository: OrderRepository
) {
    fun canAccessOrder(authentication: JwtAuthenticationToken, orderId: Long): Boolean {
        val userId = authentication.token.subject
        val order = orderRepository.findById(orderId).orElse(null) ?: return false

        // Админ видит всё
        if (authentication.authorities.any { it.authority == "ROLE_ADMIN" }) return true

        // Владелец видит свой заказ
        if (order.userId == userId) return true

        // Менеджер видит заказы своего отдела
        if (authentication.authorities.any { it.authority == "ROLE_MANAGER" }) {
            val department = authentication.token.getClaimAsString("department")
            return order.department == department
        }

        return false
    }
}

Использование:

@PreAuthorize("@authz.canAccessOrder(authentication, #orderId)")
@GetMapping("/orders/{orderId}")
fun getOrder(@PathVariable orderId: Long): Order {
    return orderService.findById(orderId)
}

Этот подход масштабируется лучше, чем SpEL-простыни. Каждый метод @authz.* можно покрыть юнит-тестами.


Шаг 6. Фронтенд: React/Next.js

Детальный гайд по фронтенду — тема отдельной статьи. Здесь — только архитектура token flow.

Для SPA используйте Authorization Code Flow с PKCE (public client spa-frontend). Не password grant — он deprecated в OAuth 2.1.

Token Flow

  1. Пользователь нажимает «Войти».
  2. SPA редиректит на Keycloak: GET /realms/my-app/protocol/openid-connect/auth?response_type=code&client_id=spa-frontend&redirect_uri=...&code_challenge=....
  3. Keycloak показывает форму логина.
  4. После успешного входа — редирект обратно на SPA с ?code=....
  5. SPA обменивает code на access_token + refresh_token (POST к token endpoint).
  6. access_token отправляется в заголовке Authorization: Bearer ... к Spring Boot API.

keycloak-js или oidc-client-ts

Для React/Next.js рекомендуем oidc-client-ts — он поддерживает PKCE из коробки, не привязан к Keycloak и работает с любым OIDC-провайдером.

import { UserManager } from "oidc-client-ts";

const userManager = new UserManager({
  authority: "http://localhost:8180/realms/my-app",
  client_id: "spa-frontend",
  redirect_uri: "http://localhost:3000/callback",
  response_type: "code",
  scope: "openid profile email",
  automaticSilentRenew: true,
});

automaticSilentRenew: true — автоматически обновляет токен через iframe до его истечения. Без этого у пользователей будет «выбрасывание» из системы через 5-15 минут (дефолтный TTL access_token).


Шаг 7. Custom Claims и User Attributes

Keycloak из коробки кладёт в JWT стандартные поля: sub, email, preferred_username, realm_access. Но часто нужны кастомные: department, tenant_id, employee_number.

Шаг 7.1. Добавляем атрибут пользователю

Users → выбрать пользователя → Attributes → Add attribute:

  • Key: department
  • Value: engineering

Шаг 7.2. Client Scope + Protocol Mapper

Чтобы атрибут попал в JWT:

  1. Client scopes → Create client scope → Name: custom-claims, Protocol: openid-connect.
  2. Внутри scope → Mappers → Configure a new mapperUser Attribute.
  3. Name: department, User Attribute: department, Token Claim Name: department, Claim JSON Type: String, Add to ID token: ON, Add to access token: ON.
  4. Clients → spring-backend → Client scopes → Add client scope → выберите custom-claims, тип: Default.

Теперь в JWT появится:

{
  "sub": "a1b2c3...",
  "email": "test@example.com",
  "department": "engineering",
  "realm_access": { "roles": ["ROLE_USER"] }
}

Чтение в Spring Boot

@GetMapping("/profile/department")
fun department(authentication: JwtAuthenticationToken): String {
    return authentication.token.getClaimAsString("department") ?: "not set"
}

Кастомные claims — мощный инструмент для SaaS-приложений. Вместо того чтобы лезть в базу за tenant_id на каждый запрос, вы кладёте его в токен. Один claim заменяет один SQL-запрос на каждом HTTP-запросе.


Шаг 8. Multi-Tenant Keycloak

Если вы строите SaaS-платформу с несколькими клиентами-организациями, нужен multi-tenancy. В Keycloak два подхода:

Вариант A: Realm-per-Tenant

Каждый тенант — отдельный realm: tenant-acme, tenant-globex, tenant-initech.

Плюсы:

  • Полная изоляция данных, ролей, тем, email-шаблонов.
  • Один тенант не может случайно увидеть пользователей другого.
  • Отдельные identity providers (LDAP/AD) для каждого тенанта.

Минусы:

  • При 100+ тенантах — 100+ realms. Keycloak справляется, но управление через UI становится неудобным.
  • SSO между тенантами не работает (пользователь логинится отдельно в каждый realm).
  • Сложнее автоматизация: при каждом новом тенанте нужно создавать realm через Admin REST API.

Подходит для: B2B-продуктов с жёсткими требованиями к изоляции (финтех, медицина, госсектор).

Вариант B: Single Realm + Groups

Один realm saas, тенанты — это группы: /tenants/acme, /tenants/globex. У каждой группы свои роли.

Плюсы:

  • Одна точка управления.
  • SSO работает между тенантами (если нужно — суперадмин видит всё).
  • Проще автоматизация: создал группу → готово.

Минусы:

  • Изоляция — ваша ответственность. Без тщательного RBAC один тенант увидит данные другого.
  • Нельзя настроить разные identity providers на разные группы.

Подходит для: внутренних корпоративных порталов, систем с умеренными требованиями к изоляции.

Конфигурация Spring Boot для realm-per-tenant

@Component
class TenantJwtIssuerValidator : OAuth2TokenValidator<Jwt> {

    private val trustedIssuers = setOf(
        "http://keycloak:8180/realms/tenant-acme",
        "http://keycloak:8180/realms/tenant-globex",
    )

    override fun validate(token: Jwt): OAuth2TokenValidatorResult {
        val issuer = token.issuer?.toString()
        return if (issuer in trustedIssuers) {
            OAuth2TokenValidatorResult.success()
        } else {
            OAuth2TokenValidatorResult.failure(
                OAuth2Error("invalid_issuer", "Untrusted issuer: $issuer", null)
            )
        }
    }
}

И в SecurityConfig:

@Bean
fun jwtDecoder(tenantValidator: TenantJwtIssuerValidator): JwtDecoder {
    val decoder = JwtDecoders.fromIssuerLocation(
        "http://keycloak:8180/realms/tenant-acme" // default
    ) as NimbusJwtDecoder

    val validators = DelegatingOAuth2TokenValidator(
        JwtTimestampValidator(),
        tenantValidator
    )
    decoder.setJwtValidator(validators)
    return decoder
}

Для динамического определения realm по хосту (acme.myapp.com) или заголовку (X-Tenant-Id) используйте JwtIssuerAuthenticationManagerResolver:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    val authManagerResolver = JwtIssuerAuthenticationManagerResolver(
        "http://keycloak:8180/realms/tenant-acme",
        "http://keycloak:8180/realms/tenant-globex"
    )
    http {
        oauth2ResourceServer {
            authenticationManagerResolver = authManagerResolver
        }
    }
    return http.build()
}

Шаг 9. Production Checklist

Dev-конфигурация, с которой мы начали, не подходит для продакшена. Вот что нужно поменять.

HTTPS (обязательно)

Keycloak работает с OAuth2 redirect URI. Без HTTPS — токены летят plain text. В продакшене:

keycloak:
  command: start
  environment:
    KC_HOSTNAME: auth.yourdomain.com
    KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/certs/tls.crt
    KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/certs/tls.key
    KC_PROXY_HEADERS: xforwarded  # если за nginx/traefik
    KC_HTTP_ENABLED: "false"

Или проще — поставьте Keycloak за reverse proxy (nginx, Traefik, Caddy), который терминирует TLS.

Внешняя база данных

PostgreSQL из docker-compose — для dev. В продакшене — managed PostgreSQL (Yandex Cloud Managed PostgreSQL, AWS RDS) с бэкапами, репликацией и мониторингом.

High Availability

Keycloak кластеризуется через Infinispan (встроен). Для двух и более инстансов:

environment:
  KC_CACHE: ispn
  KC_CACHE_STACK: kubernetes  # или tcp/udp для bare-metal
  JAVA_OPTS_APPEND: "-Djgroups.dns.query=keycloak-headless.default.svc.cluster.local"

В Kubernetes — headless service для discovery.

Бэкапы

Бэкапьте две вещи:

  1. PostgreSQL — стандартный pg_dump по расписанию.
  2. Realm export — через Admin REST API или CLI. Включает клиентов, роли, mappers, но не пароли пользователей (они в базе).

Мониторинг

Keycloak 25+ отдаёт метрики в Prometheus-формате:

environment:
  KC_HEALTH_ENABLED: "true"
  KC_METRICS_ENABLED: "true"

Эндпоинты: /health/ready, /health/live, /metrics. Подключите в Grafana: количество логинов, ошибок аутентификации, время ответа token endpoint.


Шаг 10. Типичные ошибки

За несколько лет работы с Keycloak в enterprise-проектах на Java мы собрали список ошибок, которые повторяются из проекта в проект.

1. Неправильный Grant Type

SPA-фронтенд использует password grant вместо Authorization Code + PKCE. Password grant deprecated в OAuth 2.1, не поддерживает SSO (нет redirect на Keycloak login page), не работает с social login и MFA.

Правило: SPA → Authorization Code + PKCE. Серверный бэкенд → Client Credentials. Legacy desktop → Device Authorization Grant.

2. Кэширование access_token на бэкенде

Spring Boot валидирует JWT-подпись через JWK Set (публичные ключи Keycloak). По умолчанию Spring кэширует JWK Set на 5 минут (NimbusJwtDecoder). Если вы ротируете ключи в Keycloak (а вы должны) — старые ключи продолжат приниматься до 5 минут.

Для финансовых систем уменьшите кэш:

@Bean
fun jwtDecoder(): JwtDecoder {
    val jwkSetUri = "http://keycloak:8180/realms/my-app/protocol/openid-connect/certs"
    return NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
        .cache(Duration.ofSeconds(60)) // 1 минута вместо 5
        .build()
}

3. Realm Roles vs Client Roles

Создали роль ADMIN как client role у клиента spring-backend, а в KeycloakRoleConverter читаете только realm_access.roles. Результат: hasRole('ADMIN') всегда false, но в JWT роль есть (в resource_access).

Решение: или используйте realm roles, или настройте конвертер читать resource_access.{client-id}.roles (как в нашем KeycloakRoleConverter выше — он читает оба).

4. Redirect URI Mismatch

Valid redirect URIs в клиенте Keycloak не совпадает с реальным URL. Частые причины:

  • Trailing slash: http://localhost:3000 vs http://localhost:3000/.
  • HTTPS vs HTTP.
  • Порт: localhost:3000 vs localhost:80.

Keycloak отдаёт ошибку «Invalid redirect_uri», но не показывает, какой URI пришёл. Смотрите в логи Keycloak (docker logs -f keycloak).

5. Забыли Token Refresh

Access token живёт 5 минут (по умолчанию). Если фронтенд не обновляет его через refresh token — пользователь «выпадает» из системы. Включите automaticSilentRenew в oidc-client или настройте interceptor в axios/fetch.

6. CORS

Keycloak не отдаёт Access-Control-Allow-Origin для token endpoint, если Web Origins не настроен в клиенте. В настройках клиента spa-frontend → Web Origins добавьте http://localhost:3000 или +.


Шаг 11. Тесты с TestContainers

Интеграционные тесты без реального Keycloak — это тесты без авторизации. MockMvc с @WithMockUser не проверяет JWT-парсинг, роли из токена, кастомные claims. Для полноценных тестов — TestContainers.

Зависимость

testImplementation("com.github.dasniko:testcontainers-keycloak:3.5.1")

Базовый тест

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class KeycloakIntegrationTest {

    companion object {
        @Container
        @JvmStatic
        val keycloak = KeycloakContainer("quay.io/keycloak/keycloak:25.0")
            .withRealmImportFile("test-realm.json")

        @DynamicPropertySource
        @JvmStatic
        fun overrideProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri") {
                "${keycloak.authServerUrl}/realms/my-app"
            }
            registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri") {
                "${keycloak.authServerUrl}/realms/my-app/protocol/openid-connect/certs"
            }
        }
    }

    @Autowired
    lateinit var restTemplate: TestRestTemplate

    @LocalServerPort
    var port: Int = 0

    private fun getToken(username: String, password: String): String {
        val tokenUrl = "${keycloak.authServerUrl}/realms/my-app" +
            "/protocol/openid-connect/token"

        val headers = HttpHeaders().apply {
            contentType = MediaType.APPLICATION_FORM_URLENCODED
        }

        val body = LinkedMultiValueMap<String, String>().apply {
            add("grant_type", "password")
            add("client_id", "spring-backend")
            add("client_secret", "test-secret")
            add("username", username)
            add("password", password)
        }

        val response = RestTemplate().postForEntity(
            tokenUrl,
            HttpEntity(body, headers),
            Map::class.java
        )

        @Suppress("UNCHECKED_CAST")
        return (response.body as Map<String, Any>)["access_token"] as String
    }

    @Test
    fun `public endpoint works without token`() {
        val response = restTemplate.getForEntity(
            "http://localhost:$port/api/public/health",
            Map::class.java
        )
        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
    }

    @Test
    fun `profile endpoint requires authentication`() {
        val response = restTemplate.getForEntity(
            "http://localhost:$port/api/profile",
            String::class.java
        )
        assertThat(response.statusCode).isEqualTo(HttpStatus.UNAUTHORIZED)
    }

    @Test
    fun `profile endpoint works with valid token`() {
        val token = getToken("testuser", "test123")
        val headers = HttpHeaders().apply {
            setBearerAuth(token)
        }

        val response = restTemplate.exchange(
            "http://localhost:$port/api/profile",
            HttpMethod.GET,
            HttpEntity<Unit>(headers),
            Map::class.java
        )

        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(response.body).containsKey("email")
        assertThat(response.body?.get("roles") as List<*>).contains("ROLE_USER")
    }

    @Test
    fun `admin endpoint returns 403 for non-admin user`() {
        val token = getToken("testuser", "test123")
        val headers = HttpHeaders().apply {
            setBearerAuth(token)
        }

        val response = restTemplate.exchange(
            "http://localhost:$port/api/admin/users",
            HttpMethod.GET,
            HttpEntity<Unit>(headers),
            String::class.java
        )

        assertThat(response.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
    }

    @Test
    fun `admin endpoint works for admin user`() {
        val token = getToken("testadmin", "admin123")
        val headers = HttpHeaders().apply {
            setBearerAuth(token)
        }

        val response = restTemplate.exchange(
            "http://localhost:$port/api/admin/users",
            HttpMethod.GET,
            HttpEntity<Unit>(headers),
            Map::class.java
        )

        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
    }
}

test-realm.json

Положите файл test-realm.json в src/test/resources/. Это realm export из Keycloak с тестовыми пользователями, ролями и клиентом. Экспортируйте его из dev-окружения (Шаг 2) и зафиксируйте в репозитории.

Тест стартует контейнер Keycloak, импортирует realm, получает токены и проверяет, что Spring Security корректно обрабатывает роли. Каждый прогон — чистый контейнер, никаких side-effects.


Что дальше

Keycloak — не «поставил и забыл». После базовой интеграции со Spring Boot вам потребуется:

  • Кастомная тема логина — стилизация под ваш бренд. Keycloak поддерживает FreeMarker-шаблоны и полный CSS override.
  • Event Listeners — отправка событий (логин, logout, failed login) в Kafka или Elasticsearch для аудита.
  • Admin REST API — автоматизация: создание пользователей, назначение ролей, управление realm из CI/CD.
  • Миграция с JWT-авторизации — если у вас уже есть самописная JWT-авторизация, переезд на Keycloak можно сделать плавно: сначала валидировать оба issuer, потом постепенно перевести пользователей.

Для высоконагруженных систем Keycloak кластеризуется горизонтально. При 50K+ пользователей рекомендуем отдельный кластер из 2-3 инстансов с выделенной PostgreSQL.

Если вы строите личный кабинет или B2B-портал и хотите запустить SSO без месяца экспериментов с конфигами — напишите нам. Мы настраивали Keycloak в продакшене для нескольких enterprise-проектов и знаем, где он ломается.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект