Search by

xzawed / keycloak-sdk

xzawed

Keycloak SDK for PHP — OIDC/OAuth2 authentication + Admin REST API, part of a nine-language polyglot SDK

v1.0.0 2026-08-30 07:19 UTC

This package is auto-updated.

Last update: 2026-08-31 15:15:53 UTC


README

An idiomatic PHP SDK for Keycloak covering both OIDC/OAuth2 authentication and the Admin REST API behind one consistent facade.

Part of a nine-language polyglot SDK (Java · Python · Node · Go · C# · PHP · Rust · Ruby · Kotlin) — one API shape, nine idioms: github.com/xzawed/KeyCloakSDK.

1.0.0 is on Packagistcomposer require xzawed/keycloak-sdk resolves 1.0.0 under Composer's default minimum-stability: stable.

⚠️ Coming from 0.1.0? roles()->update() changed signature. It takes the current name as its first argument — update(string $name, Role $role) — because the old one-argument form could not express a rename at all. See Upgrading from 0.1.0.

Requirements

  • PHP 8.3+ (composer.json requires ^8.3)
  • Keycloak server 26.6.x (verified by the integration suite)

Install

The SDK is developed in the php/ directory of a polyglot monorepo, and Packagist cannot install from a subdirectory. Releases are therefore subtree-split into the dedicated read-only repository xzawed/keycloak-sdk-php, which is what Packagist reads — the package name stays xzawed/keycloak-sdk:

composer require "xzawed/keycloak-sdk:^1.0"
use Xzawed\Keycloak\{KeycloakClient, KeycloakConfig};   // admin lives under Xzawed\Keycloak\Admin

Quickstart

KeycloakClient::create() assembles auth immediately (no network); admin() is created lazily on first call and needs a client secret. Value types are final readonly class, and failures throw the KeycloakException hierarchy.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Fschmtt\Keycloak\Representation\User;
use Xzawed\Keycloak\KeycloakClient;
use Xzawed\Keycloak\KeycloakConfig;

$client = KeycloakClient::create(new KeycloakConfig(
    serverUrl: 'https://kc.example.com',
    realm: 'myrealm',
    clientId: 'my-app',
    clientSecret: '', // load from an env var / secret manager; __toString is auto-masked
));

// 1) client-credentials grant. TokenSet::__toString() masks the tokens (accessToken=***).
$token = $client->auth()->clientCredentialsToken();
echo "token type: {$token->tokenType}, expires in: {$token->expiresIn}s\n";

// 2) hardened verification (alg pinning · exact iss · aud containment · mandatory exp · clock skew).
$validated = $client->auth()->validate($token->accessToken);
echo "subject: {$validated->subject}, issuer: {$validated->issuer}\n";

// 3) admin API — create/update return void (sister-language isomorphism). Look the id up with findIdByUsername().
$users = $client->admin()->users();
$users->create(new User(username: 'alice', enabled: true));
$userId = $users->findIdByUsername('alice');
$users->update($userId, $users->get($userId)->withEmail('alice@example.com'));
echo "created userId={$userId}\n";

Audience: validation requires the token's aud to contain clientId. A stock realm does not put the client id in a client-credentials token's aud, so on a default realm either pass expectedAudience: 'my-api' (the audience your realm actually issues), or add an Audience protocol mapper to the client in Keycloak.

Admin failures surface as KeycloakNotFoundError / KeycloakConflictError / KeycloakForbiddenError (all carrying KeycloakAdminError::getStatusCode()), network failures as KeycloakTransportError. admin()->raw() is the escape hatch to the underlying typed client.

Security defaults

  • Algorithm pinning — the accepted JWT signature algorithms are pinned (RS256 by default, configurable via signatureAlgorithms:); the header-supplied alg, including none, is never trusted. The SDK decodes the raw header segment itself to gate on alg before verification, because firebase/php-jwt only fills its &$headers out-parameter after a successful decode.
  • Hardened claims — exact iss match, aud containment check, mandatory exp (a token without one is rejected), and a bounded clock skew (clockSkew:, default 30s).
  • DoS-safe JWKS — a refetch is triggered only by an unresolved key ID (rotation) and never by a bad signature, and is rate-limited by jwksMinRefetchSeconds: (default 30s) — so no volume of forged random kids makes the SDK issue more than one JWKS request per interval.
  • OIDC nonce / id_token replay protectioncreateAuthorizationRequest() always issues a cryptographic nonce, puts it on the authorization URL, and returns it on AuthorizationRequest::$nonce. Pass that value as the optional third argument to exchangeCode() and the SDK fully validates the id_token (signature · iss · aud · exp) before comparing the nonce claim. Omit it and id_token validation is skipped (same opt-out as the other eight languages).
  • Secret handlingKeycloakConfig and TokenSet mask secrets and tokens fully (***, no prefix) in their __toString(); TLS verification is on by default and both connect and read timeouts are always applied.

Two scope limits worth knowing. The JWKS cache and its rate limit are per-JwksStore in-memory state, so their reach follows your deployment model: under a long-running worker (Swoole, RoadRunner) they span requests, but under classic PHP-FPM every request builds a fresh store and the limit only binds within that one request. And masking covers this SDK's own __toString() — PHP has no erasable string type, so the client secret lives in an ordinary string for its lifetime and masking is defence in depth, not a guarantee about your logs.

Upgrading from 0.1.0

One signature changed, and it is a fix rather than a rearrangement.

roles()->update(Role $role) became roles()->update(string $name, Role $role). The old form took only the representation, and the library underneath builds the request path out of $role->getName() — so the path and the body came from the same value and a rename could not be expressed. Measured on 0.1.0: asking to rename old-name to new-name sent PUT /roles/new-name with body {"name":"new-name"}, and the current name appeared nowhere in the request. Keycloak renames with PUT /{current name} carrying the new name in the body, so that request was not a rename — it was an update aimed at a role that does not exist yet.

// before — the one-argument form could only update a role in place
$admin->roles()->update(new Role(name: 'reporting'));

// now — address by the current name, put the new one in the body
$admin->roles()->update('reporting', new Role(name: 'analytics'));

// updating without renaming: repeat the name
$admin->roles()->update('reporting', new Role(name: 'reporting', description: 'Read-only'));

The other eight language SDKs always took (name, representation); this brings PHP back in line with them. Nothing else changed — users(), clients(), realms() and groups() already took a separate identifier and are untouched.

Upgrading from 0.1.0-rc.1

0.1.0-rc.2 adds OIDC nonce so id_token replay can be detected, which changes two signatures. One of them can break your code:

  1. new AuthorizationRequest(...) gains a required string $nonce. If you construct this type by hand you will get a TypeError for the missing argument. Let the SDK build it instead — $client->auth()->createAuthorizationRequest(...) returns a fully populated instance. Reading fields off the returned object is unaffected (a field was added, none removed).
  2. exchangeCode() gains an optional third argument. Two-argument calls keep working unchanged — but that path still does not verify the id_token. To get replay protection, pass the nonce you were given: exchangeCode($code, $verifier, $req->nonce).

Versioning and support

This SDK is 1.0 and follows SemVer: a breaking change to the public API requires a major bump. That promise is machine-backed — CI diffs this lane's public API against the previously published artifact on every build (php-semver-checker, judged on its report body), and a removal or an incompatible change fails the build. ⚠️ The gate compares the API surface. A change that leaves the surface identical but alters behaviour is not caught by it, so read the release notes before upgrading.

Only the newest released version of each language SDK receives security fixes; there are no long-term-support lines and older releases are not backported to.

Each of the nine languages versions independently. All nine reached 1.0.0 on the same day because they earned the same guarantee at the same time — they do not move in lockstep afterwards.

Documentation

License

Apache-2.0