Реализация SSO (Single Sign-On) для веб-приложения
SSO — это не просто «войти через Google». Это архитектурное решение, которое затрагивает сессионную модель, безопасность токенов, жизненный цикл аутентификации и интеграцию с корпоративной инфраструктурой. Неправильно спроектированный SSO становится единой точкой отказа — в буквальном смысле: если Identity Provider недоступен, пользователи не могут войти ни в одно приложение.
Протоколы и их применимость
Два актуальных стандарта: SAML 2.0 и OpenID Connect (OIDC). SAML используется в корпоративной среде (Azure AD, Okta, ADFS) — XML-based, громоздкий, но повсеместно поддерживаемый. OIDC — поверх OAuth 2.0, JSON, нативен для веба и мобайла.
Для новых проектов выбор почти всегда OIDC. Исключение — интеграция с legacy enterprise IdP, которые умеют только SAML. В этом случае можно поставить брокер (Keycloak, Dex), который принимает SAML и отдаёт OIDC дальше.
Базовый flow Authorization Code с PKCE:
Browser → /authorize?response_type=code&code_challenge=... → IdP
IdP → callback?code=AUTH_CODE → App
App → POST /token (code + code_verifier) → IdP
IdP → { access_token, id_token, refresh_token }
App → validate id_token signature → create session
PKCE обязателен для публичных клиентов (SPA, мобайл) — защита от перехвата кода авторизации.
Архитектура с несколькими приложениями
В классическом SSO центральная сессия хранится у IdP, приложения держат свои короткоживущие сессии. Механизм Single Logout (SLO) требует отдельного внимания: при выходе из одного приложения IdP должен уведомить остальные через backchannel (server-to-server) или front-channel (редиректы через браузер).
Схема с несколькими Service Provider:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App A │ │ App B │ │ App C │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┴───────────────┘
│
┌──────▼──────┐
│ IdP │
│ (Keycloak) │
└─────────────┘
Каждое приложение — это client в терминологии IdP с собственными настройками: разрешённые redirect URI, области (scopes), время жизни токена.
Валидация токенов
id_token — это JWT. Валидация обязательна на каждом запросе, если токен передаётся как credentials. Минимальный набор проверок:
from jwt import PyJWT, algorithms
import requests
def validate_id_token(token: str, client_id: str, issuer: str) -> dict:
# Получаем публичные ключи IdP
jwks_uri = f"{issuer}/.well-known/openid-configuration"
config = requests.get(jwks_uri).json()
jwks = requests.get(config["jwks_uri"]).json()
header = PyJWT.decode_header(token)
key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
public_key = algorithms.RSAAlgorithm.from_jwk(key)
claims = PyJWT.decode(
token,
public_key,
algorithms=["RS256"],
audience=client_id,
issuer=issuer,
options={"verify_exp": True}
)
return claims
Ключи IdP нужно кешировать с TTL, а не запрашивать при каждом вызове. Одновременно нужна возможность инвалидировать кеш при ротации ключей (событие keys_changed или просто короткий TTL 1–6 часов).
Интеграция в Laravel-приложение
Для Laravel есть пакет socialite для простых провайдеров (Google, GitHub) и league/oauth2-client для кастомных OIDC. Для корпоративного SSO чаще используют aacotroneo/laravel-saml2 или прямую интеграцию через firebase/php-jwt.
Пример OIDC callback:
// routes/web.php
Route::get('/auth/callback', [SsoController::class, 'callback']);
// SsoController.php
public function callback(Request $request): RedirectResponse
{
$code = $request->input('code');
$state = $request->input('state');
// Проверка state против CSRF
if ($state !== session('oauth_state')) {
abort(400, 'Invalid state');
}
$tokens = $this->oidcClient->exchangeCode($code);
$claims = $this->oidcClient->validateIdToken($tokens['id_token']);
$user = User::updateOrCreate(
['sub' => $claims['sub']],
[
'email' => $claims['email'],
'name' => $claims['name'],
'provider' => 'corporate_sso',
'last_login' => now(),
]
);
Auth::login($user, remember: true);
return redirect()->intended('/dashboard');
}
Поле sub (subject) — стабильный идентификатор пользователя от IdP. Email может меняться, sub — нет. Линковать аккаунты нужно по sub, а не по email.
Single Logout
SLO — сложнее, чем кажется. Backchannel logout: IdP отправляет POST на logout endpoint каждого приложения с logout_token (JWT с sid). Приложение находит сессию по sid и уничтожает её.
Route::post('/auth/backchannel-logout', function (Request $request) {
$logoutToken = $request->input('logout_token');
$claims = validateLogoutToken($logoutToken); // аналогично id_token
$sessionId = $claims['sid'];
// Удаляем все сессии с данным SSO session ID
DB::table('sessions')
->where('sso_session_id', $sessionId)
->delete();
return response()->noContent();
})->middleware('throttle:60,1');
Front-channel logout работает через iframe: IdP открывает logout URL каждого приложения в скрытых фреймах. Надёжность ниже — зависит от браузерной политики (ITP в Safari блокирует сторонние cookies в iframe).
Обработка ошибок и edge cases
- IdP недоступен: нужен fallback — локальная форма логина с паролем или graceful degradation с сообщением «SSO временно недоступен»
- Истечение сессии IdP при активной работе: token refresh через refresh_token должен происходить прозрачно, без прерывания пользователя
- Смена email у пользователя: если IdP обновил email, приложение должно реагировать корректно — не создавать дубль аккаунта
-
Мультитенантность: у разных клиентов могут быть разные IdP. Определяем IdP по домену email или по tenant_id в URL (
app.example.com/{tenant}/login)
Сроки реализации
- Интеграция с одним OIDC провайдером (Google Workspace, Azure AD) — 3–5 дней
- Собственный IdP на Keycloak с несколькими приложениями — 2–3 недели
- SAML + OIDC брокер с мультитенантностью — от 4 недель
Основное время уходит не на код, а на конфигурацию IdP, тестирование edge cases аутентификации и настройку SLO. Токены, которые «вроде работают», могут содержать неверные claims или не проходить валидацию в продакшне из-за расхождения часов серверов (claim iat/exp).







