Почему 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 — системный, его не трогаем.
- Откройте Admin Console → нажмите на выпадающий список «master» → Create Realm.
- Realm name:
my-app(латиница, lowercase, без пробелов). - Enabled: ON → Create.
Настройка Client
Client в Keycloak — это ваше приложение (бэкенд, фронтенд, мобилка). Для Spring Boot бэкенда нужен confidential client.
- Clients → Create client.
- Client ID:
spring-backend. - Client authentication: ON (это делает клиент confidential — с секретом).
- Authorization: OFF (пока не нужно, включим позже при необходимости).
- Valid redirect URIs:
http://localhost:8080/*(для dev). - Web origins:
http://localhost:3000(если фронт на Next.js).
Сохраните. Перейдите во вкладку Credentials — скопируйте Client secret. Он понадобится в application.yml.
Для SPA-фронтенда создайте второй client:
- Client ID:
spa-frontend. - Client authentication: OFF (public client — SPA не может хранить секрет).
- Valid redirect URIs:
http://localhost:3000/*. - 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:
- Realm roles → Create role →
ROLE_ADMIN. - Повторите для
ROLE_USER,ROLE_MANAGER.
Префикс ROLE_ — конвенция Spring Security. Keycloak его не требует, но Spring по умолчанию добавляет ROLE_ к hasRole(). Проще сразу именовать с префиксом.
Группы
Группы — это способ назначать роли массово. Вместо того чтобы вручную давать 50 пользователям роли ROLE_USER + ROLE_REPORTS, вы создаёте группу employees, назначаете ей эти роли, и добавляете пользователей в группу.
- Groups → Create group →
employees. - Откройте группу → Role mapping → Assign role → выберите
ROLE_USER,ROLE_REPORTS.
Тестовые пользователи
- Users → Create user.
- Username:
testuser, Email:test@example.com, Email verified: ON. - Credentials → Set password →
test123, Temporary: OFF. - 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
- Пользователь нажимает «Войти».
- SPA редиректит на Keycloak:
GET /realms/my-app/protocol/openid-connect/auth?response_type=code&client_id=spa-frontend&redirect_uri=...&code_challenge=.... - Keycloak показывает форму логина.
- После успешного входа — редирект обратно на SPA с
?code=.... - SPA обменивает
codeнаaccess_token+refresh_token(POST к token endpoint). 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:
- Client scopes → Create client scope → Name:
custom-claims, Protocol: openid-connect. - Внутри scope → Mappers → Configure a new mapper → User Attribute.
- Name:
department, User Attribute:department, Token Claim Name:department, Claim JSON Type: String, Add to ID token: ON, Add to access token: ON. - 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.
Бэкапы
Бэкапьте две вещи:
- PostgreSQL — стандартный
pg_dumpпо расписанию. - 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:3000vshttp://localhost:3000/. - HTTPS vs HTTP.
- Порт:
localhost:3000vslocalhost: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-проектов и знаем, где он ломается.