ksami/keycloak-security-bundle
Symfony bundle that integrates Keycloak with your application's security layer, enabling OpenID Connect/SSO authentication, role mapping, and access control. Simplifies configuring Keycloak as an identity provider for Symfony apps.
This package wires Laravel's auth layer to Keycloak so that an incoming bearer access token is validated and turned into an authenticated user, without you hand-rolling JWT parsing. Start here:
composer require <vendor>/<package> # must be >= v1.1.1 if your realm runs Keycloak 10+
php artisan vendor:publish --tag=keycloak-config
config/keycloak.php first. The only values you normally need are the realm base URL, realm name, client id, and client secret (only if you use the confidential/introspection path). Everything else is tuning.config/auth.php:
'guards' => [
'api' => ['driver' => 'keycloak', 'provider' => 'users'],
],
Keep a users provider even if you never hit the database — the guard needs a provider to resolve or instantiate the user model.auth:api middleware. Nothing Keycloak-specific is required:
Route::middleware('auth:api')->get('/me', fn (Request $r) => $r->user());
First use case: an SPA or mobile app obtains a token from Keycloak, sends Authorization: Bearer <token>, and your Laravel API returns the mapped user plus their realm/client roles. Get that single /me endpoint working end-to-end before touching roles or user provisioning.
Version pinning is the first decision. v1.1.1 exists specifically to fix behaviour against Keycloak >= v10, where the token claim layout shifted. If your realm is Keycloak 10 or newer, pin >=1.1.1 in composer.json and verify with a decoded token before shipping; older versions will silently mis-read roles or user attributes.
User resolution: stateless vs. local records. The guard can operate in two modes, and picking one up front avoids churn later:
sub, preferred_username, email) on every request. Fast, no writes, but no relationships, no id foreign keys, and no can() against persisted models.sub to a keycloak_id column and firstOrCreate() on login. Do this in a small listener or a custom user provider so the guard stays untouched:
// AppServiceProvider or a dedicated resolver
$user = User::firstOrCreate(
['keycloak_id' => $token->sub],
['name' => $token->name, 'email' => $token->email]
);
Make keycloak_id unique and indexed, and never key off email — emails change, sub does not.Role and permission mapping. Realm roles and client roles live in different claim branches, so normalise them once into a Gate rather than scattering in_array() checks:
Gate::define('admin', fn ($user) => in_array('admin', $user->roles ?? []));
Then use $this->authorize('admin') or @can('admin') as normal. If your package exposes a hasRole()/hasAnyRole() helper on the user model, prefer it over raw claim access — it is the part most likely to be patched for Keycloak version differences.
Coarse gate + fine policy. A common split: middleware enforces "is authenticated and has the api-user client role" at the route-group level, while Policies handle per-resource ownership. This keeps token validation concerns out of business logic and makes the app testable without Keycloak in the loop.
Service-to-service tokens. For machine clients using client-credentials grants, there is usually no realm_access block on the token. Guard against this by defaulting role lookups to an empty array rather than assuming the key exists — a null here throws inside middleware and produces a confusing 500 instead of a clean 403.
Multi-guard apps. If you also have session-based web auth, register the Keycloak guard under a distinct name and never let web and api share a provider that assumes a password column. Mixing them is the most frequent source of "user is null but the token is valid" reports.
Testing. Do not boot Keycloak in CI. Either:
Http::fake() the JWKS/public-key or introspection endpoint with a fixture response, then sign a token with a test keypair; or$this->actingAs($user, 'api') and cover token validation separately in a handful of focused unit tests (valid, expired, wrong audience, wrong signature).Keep a small fixture set of real tokens from each Keycloak version you support — this is what catches regressions like the v10 claim change before production does.
base64 -d and diff the claim names against what your pin of this package expects. Claim locations, not just values, have moved between versions.php artisan cache:clear or a narrower tag flush) for incidents.exp/nbf claims when debugging intermittent 401s on otherwise-fine tokens.iss matches the realm URL exactly (trailing slash and http vs https both bite) and that the audience check targets your client id, not account.email and name are not guaranteed. They appear only if the corresponding scopes are requested and the user has them set. Always null-coalesce when provisioning users, and don't make them NOT NULL without a fallback.Auth::logout() only drops the guard state; the token remains valid until expiry. Real logout needs the IdP's end-session endpoint plus client-side token discard.How can I help you explore Laravel packages today?