Реалізація SSO (Single Sign-On) для веб-додатку
SSO — це не просто «вхід через Google». Це архітектурне рішення, яке впливає на модель сесії, безпеку токенів, життєвий цикл аутентифікації та інтеграцію з корпоративною інфраструктурою. Неправильно спроектований SSO стає однією точкою відмови — буквально: якщо Identity Provider недоступний, користувачі не можуть увійти до жодного додатку.
Протоколи та їх застосування
Два актуальні стандарти: SAML 2.0 та OpenID Connect (OIDC). SAML використовується в корпоративному середовищі (Azure AD, Okta, ADFS) — на основі XML, громіздкий, але широко підтримуваний. OIDC — поверх OAuth 2.0, на основі JSON, нативний для вебу та мобайла.
Для нових проектів вибір майже завжди OIDC. Виняток — інтеграція з legacy enterprise IdP, які підтримують тільки SAML. У цьому випадку можна поставити брокер (Keycloak, Dex), який приймає SAML та надає OIDC далі.
Базовий потік 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 URIs, 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 недоступний: потрібна резервна опція — локальна форма входу з паролем або graceful degradation з повідомленням «SSO тимчасово недоступний»
- Закінчення сесії IdP під час активної роботи: оновлення токену через 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).







