socialiteproviders/keycloak
Keycloak OAuth2 provider for Laravel Socialite. Adds a Keycloak driver so your app can authenticate users through a self-hosted or cloud Keycloak realm, handling the OAuth redirect, callback, and user retrieval with Socialite's familiar API.
Install the split package (pull in Socialite and the manager if you don't already have them):
composer require socialiteproviders/keycloak
You will also need laravel/socialite and socialiteproviders/manager — the split only ships the Keycloak provider class.
Register the provider listener. On Laravel ≤10, add to app/Providers/EventServiceProvider.php:
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\Keycloak\KeycloakExtendSocialite::class.'@handle',
],
];
On Laravel 11+ (no default EventServiceProvider), do it in AppServiceProvider::boot():
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Keycloak\KeycloakExtendSocialite;
Event::listen(SocialiteWasCalled::class, [KeycloakExtendSocialite::class, 'handle']);
Add credentials to config/services.php (env-backed is easiest):
'keycloak' => [
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
'redirect' => env('KEYCLOAK_REDIRECT_URI'),
'base_url' => env('KEYCLOAK_BASE_URL'), // e.g. https://sso.example.com
'realms' => env('KEYCLOAK_REALM'), // e.g. my-realm
],
Double-check the exact key names against the README in SocialiteProviders/Providers — older versions of the split used a slightly different shape.
Minimal controller:
Route::get('/auth/redirect', fn () => Socialite::driver('keycloak')->redirect());
Route::get('/auth/callback', function () {
$kcUser = Socialite::driver('keycloak')->user();
// $kcUser->getId() -> "sub" (stable, use this as your key)
// $kcUser->getNickname() -> "preferred_username"
// $kcUser->getEmail(), getName(), getAvatar()
// $kcUser->getRaw() -> full userinfo payload
});
Where to look first. This repo is a read-only subtree split of SocialiteProviders/Providers. The real README, issues, and PR history live in the monorepo — read there before opening anything here. The provider class itself (Provider.php) is short; skim it to confirm which config keys and endpoints are actually used by your version.
First use case: the "log in with Keycloak" button. Configure a confidential client in your Keycloak realm, set its Valid Redirect URIs to exactly your callback URL, and you should be able to complete a login in under ten minutes.
Identify users by sub, not email. $user->getId() maps to the OIDC sub claim, which is stable across username/email changes. Store it as a unique column (keycloak_id or similar) and reconcile on it. Email changes happen in real deployments and will otherwise orphan accounts.
Map claims to your user model explicitly. A common pattern:
$kc = Socialite::driver('keycloak')->user();
$user = User::updateOrCreate(
['keycloak_id' => $kc->getId()],
[
'name' => $kc->getName() ?? $kc->getNickname(),
'email' => $kc->getEmail(),
]
);
Auth::login($user);
Stateless for API / SPA backends. If the frontend is a SPA or mobile app, call ->stateless() so Socialite doesn't try to use a session state:
return Socialite::driver('keycloak')->stateless()->redirect();
// and on callback:
$user = Socialite::driver('keycloak')->stateless()->user();
This is common when Keycloak is the OIDC authority for an SPA but you still want a Laravel session on callback.
Pull roles/groups out of the raw payload, or decode the access token. $user->getRaw() gives you the userinfo response. Keycloak roles (realm_access.roles, resource_access.<client>.roles, groups) frequently live in the access token / ID token rather than userinfo. Two practical options:
openid scope and decode the ID token ($user->accessTokenResponseBody['id_token']) to read claims.$user->accessTokenResponseBody['access_token'].Roles are not modelled by the provider — plan to do this mapping yourself.
Logout is not handled by the provider. Keycloak uses the end-session endpoint, so log the user out of Laravel then redirect:
Auth::logout();
$url = config('services.keycloak.base_url')
.'/realms/'.config('services.keycloak.realms')
.'/protocol/openid-connect/logout?redirect_uri='.urlencode(route('home'));
return redirect()->away($url);
Multi-tenant / multi-realm. The provider reads config at build time, so for dynamic realms use Socialite::buildProvider(Provider::class, $config) with a per-tenant array, or register additional driver names via Socialite::extend.
Downstream API calls. Grab the access token from $user->accessTokenResponseBody['access_token'] (or $user->token) and attach it as Bearer when calling services behind Keycloak. Remember Keycloak access tokens are short-lived (default 5 minutes) — plan token refresh yourself if you need long-lived access; the provider does not refresh for you.
Testing. Don't hit a real Keycloak in tests. Mock the driver:
Socialite::shouldReceive('driver->user')->andReturn($fakeUser);
Or better, put the callback logic behind a small service and test the service with a Laravel\Socialite\Two\User stub.
SocialiteProviders/Providers. If you need a fix, fork the monorepo, not this repo.base_url and the /auth prefix. Keycloak 17+ dropped the /auth path segment from the base URL; older Quarkus builds kept it. If you get 404s from /realms/.../protocol/openid-connect/auth, your base_url almost certainly has an extra or missing /auth. Test the endpoint by hand with curl first.EventServiceProvider no longer exists, so the classic snippet silently does nothing — register in AppServiceProvider::boot() instead.http vs https, and www vs bare host all break the flow with a Keycloak-side 400 that gives you almost no info. Log the URL being requested and compare it to what's in Keycloak.openid scope is mandatory for Keycloak to return an ID token / proper OIDC flow. Default scope in the provider includes it, but if you override scope in config/services.php and drop openid, you'll get weird failures downstream.laravel/socialite version; if composer require complains about constraints, you may need --with-all-dependencies or a temporary fork. The provider itself is tiny — patching a version constraint locally is trivial.config/services.php is cached; after adding or editing the keycloak block run php artisan config:clear (or config:cache in prod) or you'll get mysterious "missing config" errors.user() typically calls the userinfo endpoint. Claims present in the ID token (roles, groups, custom attributes) may not be present in getRaw(). Don't assume everything your Keycloak mapper configured will show up there — inspect the raw response before building logic on a claim.Socialite::driver('keycloak')->setHttpClient(new \GuzzleHttp\Client(['verify' => false])) — dev only, never production.client_secret; public clients won't use it and may require PKCE (enable-pkce in Keycloak). Socialite's flow is authorization-code based, not PKCE by default, so use a confidential client for server-side Laravel apps.dd($user->getRaw()) and the full accessTokenResponseBody is the fastest way to understand what your realm actually returns.\SocialiteProviders\Keycloak\Provider and register it as the 'keycloak' driver via Socialite::extend('keycloak', ...). That's cleaner than patching the vendored split.How can I help you explore Laravel packages today?