bannerstop / keycloak
Framework-agnostic Keycloak / OpenID Connect client: login with PKCE, logout, token verification against JWKS, role mapping and the admin user directory
Requires
- php: ^8.5
- ext-json: *
- ext-openssl: *
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- nyholm/psr7: ^1.8.2
- phpunit/phpunit: ^12.0
Suggests
- ext-sodium: To verify EdDSA (Ed25519) signed tokens
- bannerstop/keycloak-bundle: Symfony integration
- bannerstop/keycloak-laravel: Laravel integration
- guzzlehttp/guzzle: A PSR-18 HTTP client (^7.0)
- php-http/guzzle6-adapter: PSR-18 adapter for projects that are stuck on Guzzle 6
- symfony/http-client: A PSR-18 HTTP client (Psr18Client)
Provides
None
Conflicts
None
Replaces
None
README
A small, framework-agnostic Keycloak / OpenID Connect client for PHP. It does the security-critical parts once and in one place:
- Browser login: authorization code flow with PKCE, state and nonce
- Token verification against the realm's JWKS: signature, issuer, audience, expiry
- Bearer tokens for APIs
- Logout (RP-initiated), refresh and revocation
- Role mapping from realm roles, client roles and groups to your application's roles
- User directory through the admin REST API and a service account
It only depends on PSR interfaces, so it runs with any HTTP client and inside any framework. Ready-made integrations:
- Symfony: bannerstop/keycloak-bundle
- Laravel: bannerstop/keycloak-laravel
Versions
Each major version targets one minimum PHP version and uses the language
features that come with it. Pick the highest major your PHP version allows;
Composer does this for you with a * or a wide constraint.
| Version | PHP |
|---|---|
| 1.x | ≥ 7.1.3 |
| 2.x | ≥ 7.2 |
| 3.x | ≥ 7.3 |
| 4.x | ≥ 7.4 |
| 5.x | ≥ 8.0 |
| 6.x | ≥ 8.1 |
| 7.x | ≥ 8.2 |
| 8.x | ≥ 8.3 |
| 9.x | ≥ 8.4 |
| 10.x | ≥ 8.5 |
Installation
composer require bannerstop/keycloak
You also need a PSR-18 HTTP client and PSR-17 factories, for example:
composer require guzzlehttp/guzzle # Guzzle 7, ships both composer require symfony/http-client nyholm/psr7 # Symfony HttpClient composer require php-http/guzzle6-adapter # projects stuck on Guzzle 6
Keycloak setup
- Create an OpenID Connect client with Client authentication on (confidential) and the Standard flow enabled.
- Add your callback URL to Valid redirect URIs and your logout target to Valid post logout redirect URIs.
- Under Advanced, set Proof Key for Code Exchange Code Challenge Method to
S256. - Optional:
- Groups: add a Group Membership mapper (claim
groups) to the client's dedicated scope. - APIs: add an Audience mapper so that access tokens carry the API's audience.
- Directory: enable Service accounts roles and assign the client role
realm-management→view-usersto the service account.
- Groups: add a Group Membership mapper (claim
Usage
Create the client
use Bannerstop\Keycloak\KeycloakClient; use Bannerstop\Keycloak\KeycloakConfig; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; $config = new KeycloakConfig( 'https://sso.example.com', // server URL 'example', // realm 'my-app', // client id getenv('KEYCLOAK_CLIENT_SECRET') // null for public clients ); $factory = new HttpFactory(); $keycloak = new KeycloakClient($config, new Client(['timeout' => 10]), $factory, $factory, $psr16Cache);
Pass a PSR-16 cache (the 5th argument) in production: the discovery document and the signing keys are then fetched once per hour instead of once per request. After a key rotation, the new key is picked up automatically.
Browser login
use Bannerstop\Keycloak\Exception\LoginException; use Bannerstop\Keycloak\Login\LoginFlow; use Bannerstop\Keycloak\Login\NativeSessionStateStore; use Bannerstop\Keycloak\Login\RedirectTarget; use Bannerstop\Keycloak\Policy\EmailDomainPolicy; session_start(); $flow = new LoginFlow($keycloak, new NativeSessionStateStore(), [new EmailDomainPolicy(['example.com'])]); // login.php header('Location: ' . $flow->start('https://app.example.com/callback.php', $_GET['return_to'] ?? null)); // callback.php try { $result = $flow->finish($_GET); } catch (LoginException $exception) { // $exception->getReason() is a LoginFailure: StateMismatch, Cancelled, ProviderError, InvalidToken, NotAllowed } session_regenerate_id(true); $identity = $result->getIdentity(); $_SESSION['user'] = $identity->getSubject(); // stable id, use it to link accounts $_SESSION['tokens'] = $result->getTokens()->toArray(); $target = RedirectTarget::isLocal($result->getReturnTo()) ? $result->getReturnTo() : '/'; header('Location: ' . $target);
Identity offers getSubject(), getEmail(), isEmailVerified(),
getDisplayName(), getUsername(), getRealmRoles(), getClientRoles($clientId),
getGroups() and getClaims() for everything else.
Logout
use Bannerstop\Keycloak\Token\TokenSet; $tokens = TokenSet::fromArray($_SESSION['tokens']); session_destroy(); header('Location: ' . $keycloak->getLogoutUrl('https://app.example.com/', $tokens->getIdToken()));
Roles
Only roles and groups you map explicitly are granted:
use Bannerstop\Keycloak\Role\RoleMapper; $roles = RoleMapper::fromArray([ 'default_roles' => ['ROLE_USER'], 'realm_roles' => ['admin' => ['ROLE_ADMIN']], 'client_roles' => ['my-app' => ['editor' => ['ROLE_EDITOR']]], 'groups' => ['/staff/it' => ['ROLE_IT']], ])->map($identity);
Bearer tokens for APIs
use Bannerstop\Keycloak\Bearer\BearerToken; use Bannerstop\Keycloak\Exception\InvalidTokenException; $token = BearerToken::fromAuthorizationHeader($_SERVER['HTTP_AUTHORIZATION'] ?? null); try { $identity = $keycloak->verifyAccessToken((string) $token, 'my-api'); // audience } catch (InvalidTokenException $exception) { http_response_code(401); exit; }
User directory
use Bannerstop\Keycloak\Admin\UserDirectory; foreach ((new UserDirectory($keycloak))->users() as $user) { // $user->getId() equals the "sub" of the user's tokens }
Security
See SECURITY.md for what the library checks, what is left to you, and how to report a vulnerability.
Development
composer install vendor/bin/phpunit
tests-e2e/ runs the library against a real Keycloak in Docker, see its README.
License
MIT, see LICENSE.