Назад к блогу

SSO через Keycloak для микросервисов Tirebase

Как я настроил единую авторизацию через Keycloak для сервисов Tirebase — OIDC, FastAPI-middleware, role-based access, социальные логины. Docker-compose, рабочий код, типичные ошибки.

SSO через Keycloak для микросервисов Tirebase

Менеджеры в Tirebase работают с несколькими сервисами каждый день, и каждый сервис требовал отдельный логин. Забыл пароль — меняй в каждом отдельно. Когда вопросы «не могу войти» стали отнимать больше времени, чем баги, я решил сделать единую авторизацию.

Исходная ситуация

Tirebase — платформа для шинного бизнеса. Ключевые сервисы, которым нужна авторизация:

  • Search — поиск и подбор шин для менеджеров (Vue 3 SPA)
  • Autoload Engine — управление маркетплейсами Avito/Ozon/WB (FastAPI)
  • EVC — система событий и уведомлений (FastAPI)
  • OCRM — CRM для б/у шин и Avito-объявлений (FastAPI)
  • TS — POS-терминал шиномонтажа (Vue 3 SPA)

Каждый сервис имел свою авторизацию через ERP API, свои JWT-токены, свою логику валидации. Копирование auth-кода между сервисами — классика. Менеджеры вводили логин/пароль при переключении между Search и TS по несколько раз за смену.

Почему Keycloak

Я рассматривал три варианта:

  • Auth0 — SaaS, удобный, но платный при масштабировании и данные хранятся у них
  • Keycloak — self-hosted, открытый исходный код, полный набор фич
  • Самописный auth-сервис — полный контроль, но это месяцы разработки

Keycloak победил по соотношению «функциональность / время настройки». SSO, OIDC, RBAC, социальные логины, 2FA, управление сессиями — всё из коробки. И он self-hosted — данные остаются у нас.

Docker Compose: запуск за 5 минут

version: '3.8'

services:
  keycloak-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
    volumes:
      - keycloak_db_data:/var/lib/postgresql/data
    networks:
      - auth-network

  keycloak:
    image: quay.io/keycloak/keycloak:24.0
    command: start
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${KC_DB_PASSWORD}
      KC_HOSTNAME: auth.example.com
      KC_PROXY_HEADERS: xforwarded
      KC_HTTP_ENABLED: "true"
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    ports:
      - "8080:8080"
    depends_on:
      - keycloak-db
    networks:
      - auth-network

volumes:
  keycloak_db_data:

networks:
  auth-network:
    driver: bridge

Важный момент: command: start, а не start-dev. Режим start-dev удобен для локальной разработки, но в продакшене он отключает кеширование и другие оптимизации. Я потерял полдня, отлаживая тормоза Keycloak, прежде чем заметил, что забыл убрать start-dev.

Настройка Realm и клиентов

После запуска — настройка через админ-панель Keycloak:

  1. Realmtirebase (изолированное пространство для платформы)
  2. Ролиadmin, sales_manager, content_manager, tire_fitter
  3. Клиенты — по одному на каждый сервис

Для FastAPI-сервисов (Autoload Engine, EVC, OCRM) я создал клиентов с типом confidential (backend-сервисы), для Vue SPA (Search, TS) — с типом public (фронтенд не может хранить секрет).

Можно настроить через UI, но для воспроизводимости я экспортировал конфигурацию realm в JSON и импортирую при деплое:

# Экспорт
docker exec keycloak /opt/keycloak/bin/kc.sh export \
    --dir /opt/keycloak/data/export \
    --realm tirebase

# Импорт при старте
docker run ... quay.io/keycloak/keycloak:24.0 \
    start --import-realm \
    --spi-import-dir=/opt/keycloak/data/import

FastAPI Middleware: валидация токенов

Центральная часть — middleware, который валидирует JWT-токены Keycloak в каждом сервисе. Я написал его один раз и подключил как общий пакет.

import httpx
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer
from jose import jwt, JWTError
from functools import lru_cache

class KeycloakAuth:
    def __init__(self, server_url: str, realm: str, client_id: str):
        self.server_url = server_url
        self.realm = realm
        self.client_id = client_id
        self.jwks_url = (
            f"{server_url}/realms/{realm}/protocol/openid-connect/certs"
        )
        self._jwks_client = None

    @lru_cache(maxsize=1)
    def _get_signing_keys(self) -> dict:
        """Кешируем JWKS — они меняются редко"""
        response = httpx.get(self.jwks_url, timeout=10)
        response.raise_for_status()
        return response.json()

    def decode_token(self, token: str) -> dict:
        try:
            jwks = self._get_signing_keys()
            header = jwt.get_unverified_header(token)
            # Находим нужный ключ по kid
            key = None
            for k in jwks["keys"]:
                if k["kid"] == header["kid"]:
                    key = k
                    break
            if not key:
                # Инвалидируем кеш — возможно, ключи ротировались
                self._get_signing_keys.cache_clear()
                raise HTTPException(401, "Неизвестный ключ подписи")

            payload = jwt.decode(
                token,
                key,
                algorithms=["RS256"],
                audience=self.client_id,
                issuer=f"{self.server_url}/realms/{self.realm}",
            )
            return payload
        except JWTError as e:
            raise HTTPException(401, f"Невалидный токен: {e}")

    def get_roles(self, payload: dict) -> list[str]:
        """Извлекаем роли из токена Keycloak"""
        realm_roles = (
            payload
            .get("realm_access", {})
            .get("roles", [])
        )
        client_roles = (
            payload
            .get("resource_access", {})
            .get(self.client_id, {})
            .get("roles", [])
        )
        return realm_roles + client_roles

Подключение к FastAPI через dependency injection:

from fastapi import Depends, FastAPI

app = FastAPI()
keycloak = KeycloakAuth(
    server_url="https://auth.example.com",
    realm="tirebase",
    client_id="autoload-engine",
)
security = HTTPBearer()

async def get_current_user(credentials = Depends(security)) -> dict:
    payload = keycloak.decode_token(credentials.credentials)
    return {
        "id": payload["sub"],
        "email": payload.get("email"),
        "roles": keycloak.get_roles(payload),
        "name": payload.get("preferred_username"),
    }

def require_role(role: str):
    async def checker(user: dict = Depends(get_current_user)):
        if role not in user["roles"]:
            raise HTTPException(403, f"Требуется роль: {role}")
        return user
    return checker

@app.get("/api/tires")
async def get_tires(user: dict = Depends(get_current_user)):
    # Любой авторизованный пользователь
    return await fetch_tires()

@app.post("/api/feeds/generate")
async def generate_feeds(user: dict = Depends(require_role("content_manager"))):
    # Только контент-менеджеры
    return await trigger_feed_generation()

@app.delete("/api/products/{product_id}")
async def delete_product(user: dict = Depends(require_role("admin"))):
    # Только администраторы
    return await remove_product(product_id)

Социальные логины

Для внешних пользователей (клиенты, подрядчики, поставщики) я подключил вход через Google и Яндекс. В Keycloak это делается без единой строки кода — через Identity Providers в админке.

Для Яндекс ID, который не в стандартном списке Keycloak, пришлось настроить Generic OIDC Provider:

  • Authorization URL: https://oauth.yandex.ru/authorize
  • Token URL: https://oauth.yandex.ru/token
  • User Info URL: https://login.yandex.ru/info

После настройки — кнопка «Войти через Яндекс» появляется на форме логина Keycloak автоматически. Фронтенд ничего не знает о провайдерах — он просто редиректит на Keycloak, а тот разбирается.

Типичные ошибки, на которые я наступил

CORS. Keycloak по умолчанию не отдаёт CORS-заголовки для вашего фронтенда. В настройках клиента нужно явно указать Web Origins — https://app.example.com. Или + чтобы разрешить все Redirect URIs как origins.

Время жизни токена. По умолчанию access token живёт 5 минут. Для SPA это мало — пользователь открыл вкладку, отвлёкся на 10 минут, вернулся — разлогинен. Я увеличил до 15 минут и реализовал silent refresh через iframe (штатный механизм keycloak-js adapter). Не уверен, что 15 минут — оптимальный TTL, возможно стоит попробовать 10 минут с более агрессивным refresh, но пока работает нормально.

Ротация ключей. Keycloak периодически ротирует ключи подписи. Если middleware кеширует JWKS без инвалидации — после ротации все запросы начинают падать с 401. Отсюда cache_clear() в коде выше при неизвестном kid.

Realm export. Экспорт realm через kc.sh export не включает пользователей по умолчанию. Для полного бэкапа нужен флаг --users realm_file. Я узнал это, когда пришлось восстанавливать конфигурацию после неудачного обновления.

Результат

После миграции на Keycloak:

  • Один логин для Search, Autoload Engine, EVC, OCRM и TS
  • Одно место для управления пользователями и ролями
  • Социальные логины без кода на нашей стороне
  • Жалобы на авторизацию почти исчезли
  • Код auth из каждого сервиса — удалён, осталась middleware на 60 строк

Настройка заняла один вечер для базового варианта, ещё пару дней на миграцию пользователей и настройку ролей. Ретроспективно, я бы сразу делал экспорт realm в JSON и автоматический импорт при деплое — первые недели настраивал вручную через UI и пару раз терял изменения при обновлении контейнера.

Если у вас больше двух сервисов и пользователи жалуются на повторные логины — Keycloak решает эту проблему. Self-hosted, бесплатный, с экосистемой, которую сложно переписать за разумное время.