Search by

xzawed / keycloak-sdk

xzawed

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

v1.5.2 2026-11-02 23:10 UTC

This package is auto-updated.

Last update: 2026-11-02 23:37:23 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.5.2 is on Packagist — where ext-curl is enabled, composer require xzawed/keycloak-sdk resolves 1.5.2 under Composer's default minimum-stability: stable. A patch release on top of 1.5.1. Changed: ⚠️ the token grants of AuthClient (clientCredentialsToken, refresh and exchangeCode) now throw KeycloakAuthError instead of KeycloakTransportError when the token endpoint answers with a response that is not JSON (a 503 HTML page, a 200 with a non-JSON body and the like) or with JSON that has no access_token. ClientCredentialsTokenProvider already threw KeycloakAuthError for the same responses, and KeycloakTransportError means a network failure only — code that caught those responses as a transport error to retry them should look at the auth error instead. The messages are unchanged, and so are a response over the cap (KeycloakTransportError) and an OAuth error response (KeycloakAuthError). No public API changed.

1.5.1 was a patch release on top of 1.5.0. Security: the admin lane now holds the access token it sends as a Bearer to one rule shared by all nine SDKs — after leading and trailing SP, HTAB, CR and LF are stripped, it must be a non-empty RFC 6750 b64token (ASCII letters and digits and -._~+/, with = only at the end). Because the underlying admin library builds the Bearer itself, the admin lane accepts the access token of a grant response only when it already is such a b64token, with nothing to strip; anything else fails with KeycloakAdminError (no status), is not cached, and is not quoted. On PHP without sodium, a JWT's inner spaces and tabs used to be sent as is, and the psr7 exception from raw() quoted the whole Bearer. The standalone ClientCredentialsTokenProvider strips the leading and trailing characters and accepts. A JWKS key set now expires 300 seconds after the load that fetched it finished, and an expired set is treated as a cold cache: the next validation fetches it again through the cold-load path (the same single flight and the same failure backoff) and rejects the token if that fetch fails, instead of validating with the stale keys. Until now the PHP SDK gave a key set no lifetime, so it kept accepting a key the IdP had removed until a token with a new kid arrived. ⚠️ If the JWKS cannot be reached for more than 300 seconds, validation fails — by design, because the SDK cannot tell an unreachable JWKS from a key the IdP has removed. The failure backoff's jitter (×[0.5, 1.0)) was drawn again at every check, so the same failure could refuse a validation at 190 ms and send the next one to the IdP at 191 ms; it is now drawn once, when the failure happens. Changed: composer.json now requires ext-curl and raises its floors to guzzlehttp/guzzle ^7.15.2 and guzzlehttp/psr7 ^2.12.3. Without curl, Guzzle fell back to PHP's stream handler, which read a response header line with no length limit (+57 MB for one 16 MiB line, with nothing to configure), and psr7 below 2.11 could not stop a transfer at the cap, so a large error body was received to its end. The two floors also close GHSA-v5mv-p594-2x33 and GHSA-f7vp-7xgx-4w4r (Guzzle) and GHSA-c2w2-prh8-qm98 (psr7). ⚠️ Without ext-curl, Composer does not pick this release: a bare composer require xzawed/keycloak-sdk warns that it cannot use the latest version because it requires ext-curl, and installs 1.5.0 with exit code 0; a constraint naming 1.5.1 is refused (Your requirements could not be resolved …), and so is composer install from a lock that holds 1.5.1 (Your lock file does not contain a compatible set of packages …). The docblocks your IDE shows (classes and their public and protected members) and the comments of examples/quickstart.php are now in English; the code and the PHPDoc tags and types are unchanged. No public API changed.

1.5.0 was a minor release on top of 1.4.1, and a security release. Security: validate() and validateIdToken() (and so exchangeCode()) split and JSON-decoded a token before checking its signature, with no length limit — an attacker-chosen 768 KiB token ended the request with a fatal out-of-memory error under PHP's default memory_limit, the kind of attacker-reachable fatal error that PHP2Shell (CVE-2026-109618 / CVE-2026-109619) can turn into code execution on an unpatched runtime. A token longer than the new public constant JwtValidator::JWT_MAX_BYTES (256,000 bytes) is now rejected with TokenValidationError before it is split or decoded and without a JWKS request, and a token without exactly two dots is rejected before it is split. ⚠️ This does not patch your interpreter — see Requirements. The admin facade also stops receiving bodies it does not use: the 2xx body of a write (create, update, delete, and import for clients and realms) is no longer received, and an error body (3xx or above) is received only up to 1,048,576 bytes, so a server that sends 64 MiB no longer makes the call receive all of it or wait out the read timeout; status classes are unchanged and a raw() error response is cut at the same length. ⚠️ Without ext-curl, the stream handler stops the transfer at that cap only with guzzlehttp/psr7 2.11.0 or later. One public API addition (JwtValidator::JWT_MAX_BYTES); the measurements and caveats are in the changelog.

⚠️ 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)
    • ⚠️ Run a PHP build that carries the PHP2Shell fixes (CVE-2026-109618 / CVE-2026-109619) — 8.3.36, 8.4.27, 8.5.12 or later, or your distribution's patched package. The SDK cannot protect an unpatched interpreter, and Composer cannot enforce this for you: distributions backport the fix without changing the version PHP reports, so a version floor in composer.json would also reject patched servers.
  • The curl extension — composer.json requires ext-curl (1.5.0 and earlier did not). Without it Guzzle falls back to PHP's stream wrapper, which reads a response header line of any length. ⚠️ Composer checks extensions when it installs, not at run time (its default platform-check covers only the PHP version), so without the extension a bare composer require xzawed/keycloak-sdk stays on 1.5.0.
  • 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).
  • Bounded token input — validate() and validateIdToken() reject a token longer than JwtValidator::JWT_MAX_BYTES with TokenValidationError before splitting or decoding it (and without a JWKS request), and a token without exactly two dots before splitting it. Verification has to decode the header and payload before it can check the signature, so without that bound an attacker-chosen token made the validator allocate up to about 167 bytes per input byte, and a 768 KiB token ended the request with a fatal out-of-memory error under PHP's default memory_limit — the kind of attacker-reachable fatal error that PHP2Shell (above) can turn into code execution on an unpatched runtime.
  • DoS-safe JWKS — a fetched key set is used for 300 seconds. The first validation after that fetches it again, and if that fetch fails the validation fails (KeycloakTransportError): keys older than 300 seconds are never used, even during an IdP outage. Within those 300 seconds 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 once the key set has been fetched, no volume of forged random kids makes the SDK issue more than one JWKS request per interval on top of that 300-second refetch. A failed fetch is bounded separately: consecutive failures back off exponentially (0.2s, doubling, capped at 5s, with jitter), and inside that window the SDK fails fast without contacting the IdP. So a cold cache during an IdP outage no longer turns every validation into a request (measured: 20 attempts → 1 request, down from 20). ⚠️ The SDK never sleeps — it returns the error immediately, so retry pacing stays the caller's decision. ⚠️ Like the refetch gate itself, this state is per-instance: under classic PHP-FPM a fresh store is built per request, so the bound holds within one request.
  • OIDC nonce / id_token replay protection — createAuthorizationRequest() 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. The id_token's aud must contain clientId (OIDC Core §3.1.3.7) — expectedAudience narrows access-token validation only, so overriding it does not break the exchange. Omit it and id_token validation is skipped (same opt-out as the other eight languages).
  • Secret handling — KeycloakConfig, TokenSet and AuthorizationRequest mask secrets, tokens and the PKCE verifier fully (***, no prefix) in both __toString() and json_encode() (all three implement JsonSerializable); 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() and json_encode(), plus the engine dumps that honour __debugInfo() — var_dump(), print_r() and debug_zval_dump(). It does not cover var_export(), which reads the properties directly and prints the raw value. (⚠️ This line used to say var_dump() and print_r() printed the raw value too; __debugInfo() is implemented on the masked types and SECURITY.md already said so.) PHP also has no erasable string type, so the client secret lives in an ordinary string for its lifetime: 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 in the same release wave because they earned the same guarantee at the same time — they do not move in lockstep afterwards.

Documentation

License

Apache-2.0