robsontenorio/laravel-keycloak-guard
Laravel Keycloak Guard provides a Keycloak authentication guard for Laravel, validating JWT access tokens and exposing roles and permissions. It integrates with Laravel's native auth system, making it easy to protect routes and APIs with Keycloak-issued tokens.
composer require robsontenorio/laravel-keycloak-guard — v2.x targets modern PHP/Laravel; confirm the constraint matrix in composer.json before dropping it into an older app.php artisan vendor:publish --tag=keycloak → config/keycloak.php. This file is the whole mental model of the package; read it before writing any code..env:
KEYCLOAK_REALM_PUBLIC_KEY — from Keycloak admin → Realm Settings → Keys → RS256 → Public key (full PEM, including the -----BEGIN PUBLIC KEY----- lines).KEYCLOAK_TOKEN_ISSUER — must match the token's iss claim exactly, e.g. https://kc.example.com/realms/myrealm.KEYCLOAK_LOAD_USER_FROM_DATABASE — true if your users table mirrors Keycloak users, false if you want to work straight off token claims.config/auth.php:
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'api' => ['driver' => 'keycloak', 'provider' => 'users'],
],
->middleware('auth:api'). The client (SPA/mobile) obtains a token from Keycloak via authorization code + PKCE and sends Authorization: Bearer <token>./api/me route, dd(auth('api')->user()), and confirm you see claims (no-DB mode) or an Eloquent model (DB mode) plus auth('api')->id() returning the Keycloak sub.load_user_from_database = true the guard resolves an Eloquent user through the provider, so policies, relations, $request->user()->is_admin etc. all work normally. With false you get the decoded token (claims object) — fast, stateless, but no model behaviour. Mixing the two in one app is a common source of confusion.retrieveById receives the identifier derived from the token. If your realm issues UUID subjects (the default for modern Keycloak), store sub in a keycloak_id/uuid column and resolve on that — not on an auto-increment id.// AppServiceProvider::boot()
Gate::before(function ($user, $ability) {
$roles = data_get($user, 'realm_access.roles', []);
return in_array($ability, $roles, true) ? true : null;
});
Remember client roles live under resource_access.{client-id}.roles, not realm_access.azp, group) and binds it to the request/container is the cleanest way to do multi-tenant APIs without a DB round trip.auth:api after CORS/preflight handling; return 401 for missing/expired tokens and let the SPA's interceptor refresh and retry.web (session) and api (keycloak) guards side by side and use auth()->guard('api') explicitly — auth()->user() with no guard picks the default and will silently be null.$this->actingAs($user, 'api') works. In claim mode, inject a prepared decoded token/claims object via Auth::guard('api')->setUser(...), or ship a long-lived signed JWT fixture (generate once with a throwaway RSA key pair committed to tests/Fixtures).TokenHolder singleton rather than re-reading Authorization in every client.id() (fixed in 2.0.1). If you are on 2.0.0 or earlier, auth()->id() / getAuthIdentifier() on realms with UUID subjects can be coerced to an integer or blow up with a type error. Upgrade to 2.0.1 and add a test asserting auth('api')->id() equals the exact sub string — then compare with === in policies and route-model-binding guards ((int) casts elsewhere will silently break)..env, multi-line values must be quoted and newline-escaped: KEYCLOAK_REALM_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----". Docker/CI env injection frequently strips or re-wraps newlines. php artisan config:clear (and re-run config:cache) after every change; a cached config with a stale key is invisible otherwise./realms/{realm}/protocol/openid-connect/certs).iss must match character-for-character (trailing slashes, http vs https, port). Validate aud/azp against your expected client — otherwise tokens minted for a different client in the same realm are accepted. Use allowed_resources (or an equivalent audience check) if you expose more than one client.leeway to a small positive value in config/keycloak.php rather than extending token lifespan.load_user_from_database = false, auth()->user() is not an Eloquent model — ->save(), ->roles(), ->update() all fail. Don't mix modes inside one feature.OPTIONS requests hitting auth:api return 401 and look like a CORS bug in the browser. Exclude preflight in CORS middleware, not by unprotecting the route.base64 -d the payload) → check iss, aud, exp, azp, sub; confirm the signature algorithm is RS256 and the app isn't silently accepting none; log the guard exception class (TokenException variants) rather than a generic 401 message; verify storage/bootstrap/cache writability so config caching isn't silently skipped.Auth::extend driver if you need extra claim validation), the config's user-retrieval hook for provisioning, and a Gate::before/policy layer for roles. Keep Keycloak-specific logic in one service provider so swapping identity providers later stays a single-file change.How can I help you explore Laravel packages today?