laravel-keycloak maintained by marchenkovma
Keycloak для Laravel: вход пользователей через OIDC (Authorization Code + PKCE) с ролью приложения в сессии и guard для API, который проверяет токены сервисных клиентов.
- PHP 8.4+ с расширением openssl, Laravel 13.
- Sanctum и Inertia пакету не нужны.
Установка
composer require marchenkovma/laravel-keycloak
Провайдер и фасад Keycloak подключаются автоматически. Драйвер keycloak для Socialite пакет регистрирует сам, поэтому:
services.keycloakвconfig/services.phpне нужен;- слушатель
SocialiteWasCalledвAppServiceProviderне нужен. Если он остался, удалите его: он перекроет драйвер пакета.
Опубликовать конфиг:
php artisan vendor:publish --tag=keycloak-config
Переменные окружения
KEYCLOAK_BASE_URL=https://sso.example.com
KEYCLOAK_REALM=corp
KEYCLOAK_CLIENT_ID=my-app
KEYCLOAK_CLIENT_SECRET=
# Локальная разработка без Keycloak: вход фейковым пользователем.
# В production не действует, что бы ни стояло в env
KEYCLOAK_BYPASS=false
KEYCLOAK_BYPASS_ROLE=admin
KEYCLOAK_BASE_URL задаёт адрес, по которому приложение ходит в Keycloak. Он может быть внутренним.
Keycloak с сертификатом корпоративного CA: корневой сертификат должен быть доверенным в контейнере приложения, пакет его не настраивает.
Вход пользователей
Пакет регистрирует маршруты с middleware web:
| Метод | Адрес | Имя |
|---|---|---|
| GET | /auth/login |
auth.login |
| GET | /auth/callback |
auth.callback |
| POST | /auth/logout |
auth.logout |
Префикс задаётся в keycloak.web.prefix.
Чтобы зарегистрировать маршруты самостоятельно, поставьте keycloak.web.routes = false и укажите Marchenkovma\LaravelKeycloak\Http\Controllers\KeycloakController (методы login, callback, logout). Маршрут callback назовите auth.callback: из него пакет берёт redirect_uri для Keycloak. Без такого имени используется {prefix}/callback.
Настройка клиента для входа
- Клиент приложения (например,
my-app):- Client authentication ON (confidential), Standard flow ON;
- в Advanced: Proof Key for Code Exchange Code Challenge Method = S256;
- Valid redirect URIs:
https://<приложение>/auth/callback; - Valid post logout redirect URIs: адрес страницы после выхода (
after_logout).
- Mapper-ы на клиенте или в его dedicated scope. Socialite читает claims из userinfo, поэтому у обоих включён Add to userinfo:
- Group Membership: Token Claim Name
groups, Full group path ON; - User Client Role: Client ID = этот клиент, Token Claim Name
resource_access.${client_id}.roles, Multivalued ON.
- Group Membership: Token Claim Name
Вместо своего mapper-а ролей можно включить Add to userinfo у mapper-а client roles в client scope roles: он пишет роли в тот же resource_access. Scope общий для realm-а, поэтому роли попадут в userinfo всех клиентов, где он подключён.
Без Add to userinfo групп и ролей в ответе нет, и любой вход закончится 403. Что пришло на самом деле, видно в ответе /realms/{realm}/protocol/openid-connect/userinfo или в Client scopes, вкладка Evaluate, Generated user info.
Роли
Роль приложения определяется при входе и хранится в сессии до выхода. Правила перечисляются по приоритету, берётся первая совпавшая:
groups: полные пути групп (claimgroups, mapper с включённым Full group path);client_roles: роли клиентаkeycloak.client_idизresource_access.{client_id}.roles. Если записи этого клиента вresource_accessнет, берётся плоский claimroles(mapper User Client Role с Token Claim Nameroles).
// config/keycloak.php
'roles' => [
'admin' => ['groups' => ['/Administrators'], 'client_roles' => ['admin']],
'user' => ['groups' => ['/apps/my-app/user']],
],
Если ни одно правило не совпало, вход завершается ответом 403.
use Marchenkovma\LaravelKeycloak\Facades\Keycloak;
Keycloak::role(); // 'admin' | 'user' | null
Keycloak::hasRole('admin'); // true, если роль одна из перечисленных
// Gate проект определяет сам
Gate::define('admin', fn ($user) => Keycloak::hasRole('admin'));
Middleware keycloak.role:
Route::middleware(['auth', 'keycloak.role'])->group(...); // любая роль приложения
Route::middleware(['auth', 'keycloak.role:admin'])->group(...); // только admin
Переадресации
'redirects' => [
'after_login' => 'home', // имя маршрута; redirect()->intended() поверх
'after_logout' => 'home',
'on_failure' => null, // устаревший state при входе; null: на after_logout
],
В сессии остаётся flash auth_notice, по которому страница может объяснить, что произошло:
session_expired: форма входа провисела дольше жизни сессии;login_failed: Keycloak вернулerrorвместо кода, например пользователь отменил вход;signed_out: пользователь вышел.
Выход идёт на end_session Keycloak с id_token_hint. Для Inertia-запроса (заголовок X-Inertia) пакет отвечает 409 с X-Inertia-Location, иначе обычным redirect.
Что прописать в bootstrap/app.php
Гостя web-страницы нужно отправить на вход, а гостю API отдать JSON 401:
->withMiddleware(function (Middleware $middleware): void {
$middleware->redirectGuestsTo(
fn (Request $request) => $request->is('api/*') ? null : route('auth.login'),
);
})
->withExceptions(function (Exceptions $exceptions): void {
// Истёкшая сессия в Inertia-XHR: 302 на /auth/login браузер повёл бы
// внутри XHR дальше на Keycloak и упёрся в CORS
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->header('X-Inertia')) {
return Inertia::location(route('auth.login'));
}
});
})
Свой пользователь
По умолчанию пользователь ищется в модели auth.providers.users.model по полю username (preferred_username Keycloak). Обновляются поля name, email, last_login_at. В таблице users должны быть эти колонки.
Другую логику задайте своей реализацией ResolvesKeycloakUser:
use Marchenkovma\LaravelKeycloak\Contracts\ResolvesKeycloakUser;
$this->app->bind(ResolvesKeycloakUser::class, MyKeycloakUsers::class);
В bypass-режиме в тот же resolve() приходит фейковый пользователь: username bypass, имя из keycloak::messages.bypass_name, почта bypass@localhost. Отдельно обрабатывать bypass не нужно.
Тексты
Текст ответа 403 берётся из переводов keycloak::messages.denied (ru, en). Свой текст положите в lang/vendor/keycloak/ru/messages.php или опубликуйте файлы:
php artisan vendor:publish --tag=keycloak-lang
API: токены сервисных клиентов
Guard keycloak проверяет Authorization: Bearer <jwt>, то есть access-токен клиента Keycloak, полученный по client credentials. Подключите guard в config/auth.php:
'guards' => [
// ...
'keycloak' => ['driver' => 'keycloak'],
],
KEYCLOAK_API_AUDIENCE=orders-api # aud в токене, он же client id API в Keycloak
KEYCLOAK_API_ROLE=api # роль клиента-API, которая даёт доступ
KEYCLOAK_API_CLIENTS= # необязательно: разрешённые azp через запятую
KEYCLOAK_ISSUER= # пусто: {KEYCLOAK_BASE_URL}/realms/{realm}
KEYCLOAK_JWKS_CACHE_TTL=3600
KEYCLOAK_ISSUER нужен, если приложение ходит в Keycloak по внутреннему адресу. iss в токене содержит публичный адрес, по которому токен выдан, а ключи (JWKS) берутся по KEYCLOAK_BASE_URL.
// только Keycloak
Route::middleware('auth:keycloak')->group(...);
// Keycloak или Sanctum: кто узнал токен, тот и пустил
Route::middleware('auth:keycloak,sanctum')->group(...);
На чужой или битый токен guard возвращает «нет пользователя», а не исключение. Поэтому auth:keycloak,sanctum доходит до Sanctum, а одиночный auth:keycloak даёт обычный 401. Токены Sanctum (1|abc…) guard отсеивает сразу, без запросов в Keycloak.
Что проверяется:
- подпись RS256 по JWKS realm-а (
noneи HS* отвергаются). Ключи кешируются, неизвестныйkidперечитывает JWKS (ротация), но не чаще раза в минуту; iss,audсодержитKEYCLOAK_API_AUDIENCE;expобязателен,expиnbfпроверяются с допуском 30 с (keycloak.api.leeway);- правило доступа по роли и списку
azp:
KEYCLOAK_API_ROLE |
KEYCLOAK_API_CLIENTS |
Кого пускает |
|---|---|---|
| задана | пусто | токен с ролью в resource_access.{KEYCLOAK_API_AUDIENCE}.roles |
| задана | задан | токен с ролью и azp из списка |
| пусто | задан | токен с azp из списка |
| пусто | пусто | никого: одного aud для доступа мало |
Роль ищется под ключом KEYCLOAK_API_AUDIENCE, поэтому он должен совпадать с client id API в Keycloak. Роль одного клиента не открывает API другого.
Токен проверяется локально, без introspection: снятая в Keycloak роль перестаёт действовать, когда истечёт exp уже выданного токена (в Keycloak по умолчанию 5 минут).
Если Keycloak недоступен, запрос получает 401, а в лог пишется warning. При отказе по токену в лог идёт info с kid, iss, azp и причиной. Сам токен в лог не пишется.
Пользователь guard-а: Marchenkovma\LaravelKeycloak\Api\KeycloakClient.
$client = $request->user();
$client->getAuthIdentifier(); // azp, например 'reports-api'
$client->claims; // все claims токена
$client->hasRole('orders-api', 'read'); // роль из resource_access
$client->hasScope('email');
Свой класс вместо него задаётся в AppServiceProvider::boot():
Keycloak::resolveApiClientUsing(fn (array $claims) => new ApiClient(['name' => $claims['azp']]));
Токен для исходящих запросов
Keycloak::serviceToken() получает access-токен client credentials, чтобы ходить в другие сервисы от имени клиента:
use Marchenkovma\LaravelKeycloak\Facades\Keycloak;
Http::withToken(Keycloak::serviceToken())->get('https://orders.example.com/api/v1/orders');
Токен кешируется до expires_in минус 30 с, повторные вызовы в Keycloak не ходят. Учётные данные берутся в таком порядке:
- аргументы:
Keycloak::serviceToken('reports-api', $secret); KEYCLOAK_SERVICE_CLIENT_IDиKEYCLOAK_SERVICE_CLIENT_SECRET, если вызовы идут от отдельного клиента (например,reports-api), а вход пользователей от другого;KEYCLOAK_CLIENT_IDиKEYCLOAK_CLIENT_SECRET.
Пара берётся целиком из одного источника. Если задан только KEYCLOAK_SERVICE_CLIENT_ID без секрета или только первый аргумент, бросается исключение: id одного клиента не уйдёт с секретом другого.
Если Keycloak отказал или недоступен, бросается Marchenkovma\LaravelKeycloak\Exceptions\ServiceTokenException. В тексте исключения есть клиент, адрес и error из ответа Keycloak, секрета там нет.
Настройка клиента в Keycloak
Основной способ: доступ по роли. Новый клиент получает доступ выдачей роли, отзыв делается снятием роли, перевыкладывать API не нужно.
- На клиенте принимающего API (
orders-api) в Client roles заведите рольapi. - Клиент, который будет ходить в API (например,
reports-api):- Client authentication ON (confidential);
- Service accounts roles ON;
- Standard flow и Direct access grants OFF;
- на вкладке Service account roles выдайте роль
orders-api:api.
- Client scope
rolesдолжен быть среди Default client scopes вызывающего клиента (по умолчанию он там есть). Его mapper Audience Resolve сам добавляетorders-apiвaud, раз в токене есть роли этого клиента, а у mapper-аclient rolesдолжен быть включён Add to access token. Отдельный audience mapper не нужен. - Проверка: Clients,
reports-api, Client scopes, Evaluate, пользовательservice-account-reports-api, Generated access token. В токене должны бытьaudсorders-apiиresource_access.orders-api.rolesсapi.
Второй способ: список azp. На вызывающем клиенте ставится Audience mapper (Included Custom Audience = orders-api, Add to access token ON), а его client id добавляется в KEYCLOAK_API_CLIENTS принимающего API. Каждый новый клиент требует правки .env и перевыкладки API.
Секрет клиента нужен только вызывающей стороне. Принимающему API он не нужен.
Проверка руками:
TOKEN=$(curl -s -d grant_type=client_credentials -d client_id=reports-api -d client_secret=... \
https://<keycloak>/realms/<realm>/protocol/openid-connect/token | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" https://<api>/api/v1/...
Тесты в проекте
Трейт InteractsWithKeycloak подключается в TestCase проекта:
use Marchenkovma\LaravelKeycloak\Testing\InteractsWithKeycloak;
abstract class TestCase extends BaseTestCase
{
use InteractsWithKeycloak;
}
// API: настоящий RS256-токен, guard проверяет его полностью, без Keycloak
$this->withKeycloakToken()->getJson('/api/v1/orders')->assertOk();
$this->withKeycloakToken(['resource_access' => null])->getJson('/api/v1/orders')->assertUnauthorized();
$this->withKeycloakToken(['resource_access' => ['orders-api' => ['roles' => ['other']]]])
->getJson('/api/v1/orders')->assertUnauthorized();
// Вход пользователя с ролью в сессии, без Socialite
$this->actingAsKeycloakUser($user, 'admin')->get('/')->assertOk();
Трейт берёт claims токена из конфига, поэтому в phpunit.xml проекта должны быть заданы KEYCLOAK_BASE_URL, KEYCLOAK_REALM, KEYCLOAK_API_AUDIENCE и KEYCLOAK_API_ROLE (или KEYCLOAK_API_CLIENTS, если доступ по списку azp). Без KEYCLOAK_API_AUDIENCE guard отвечает 401 на любой токен, как и без KEYCLOAK_API_ROLE и KEYCLOAK_API_CLIENTS одновременно.
<env name="KEYCLOAK_BASE_URL" value="https://keycloak.test"/>
<env name="KEYCLOAK_REALM" value="corp"/>
<env name="KEYCLOAK_API_AUDIENCE" value="orders-api"/>
<env name="KEYCLOAK_API_ROLE" value="api"/>
withKeycloakToken()генерирует ключ RSA и подставляет его публичную часть в guard вместо JWKS Keycloak. Guard не ходит ни в сеть, ни в кеш, поэтомуHttp::fakeпроекта ему не мешает,Http::assertNothingSent()проходит, а хранилище кеша в тестах может быть любым. Claims по умолчанию берутся из конфига:iss,aud, рольKEYCLOAK_API_ROLEвresource_accessиazp, первый изKEYCLOAK_API_CLIENTS(без спискаazpв токене нет). Значениеnullубирает claim.keycloakToken($claims)отдаёт сам токен, например для своих заголовков.- Несколько вызовов
withKeycloakToken()в одном тесте работают: перед каждым трейт сбрасывает запомненного guard-ом пользователя. Если токен изkeycloakToken()отправляется своим заголовком, перед вторым запросом вызовитеapp('auth')->forgetGuards()сами.
Разработка
composer test # Larastan, Pint, покрытие типов, Pest
Демо-приложение (workbench) для ручной проверки входа:
cp workbench/.env.example workbench/.env # KEYCLOAK_BYPASS=true: вход без Keycloak
composer serve # http://localhost:8000
Для входа через настоящий Keycloak заполните KEYCLOAK_* в workbench/.env, поставьте KEYCLOAK_BYPASS=false и разрешите в клиенте redirect URI http://localhost:8000/auth/callback. Роли демо задаются в workbench/app/Providers/WorkbenchServiceProvider.php.
Изменения описаны в CHANGELOG.
Нашли уязвимость? Не пишите в открытый issue, сообщите приватно: Report a vulnerability. Подробнее в политике безопасности.
Лицензия
MIT.