Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sandstorm/keycloak-admin-api

A framework-agnostic PHP client for the Keycloak Admin REST API (target: Keycloak 26.5 or newer).

The whole package is work in progress, and is extended as needed.

Thanks to BroodfondsMakers for sponsoring the development of this package, and for agreeing to Open Source it!

It exposes the admin API in modern PHP, using immutable typed DTOs and collections. It has no framework coupling.

Requirements

  • PHP 8.3+
  • A PSR-18 HTTP client, like Guzzle

Usage

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Sandstorm\KeycloakAdminApi\Connection\Auth\ServiceAccountTokenProvider;
use Sandstorm\KeycloakAdminApi\Connection\KeycloakSettings;
use Sandstorm\KeycloakAdminApi\Connection\KeycloakSettingsProvider;
use Sandstorm\KeycloakAdminApi\Connection\KeycloakTransport;
use Sandstorm\KeycloakAdminApi\Features\KeycloakUsersApi\KeycloakUsersApiImplementation;
use Sandstorm\KeycloakAdminApi\SharedModel\KeycloakUserId;

$settings = new class implements KeycloakSettingsProvider {
    public function get(): KeycloakSettings {
        return new KeycloakSettings('https://keycloak.example', 'my-realm', 'admin-api', $secret);
    }
};

// Any PSR-18 client + PSR-17 factories work; here Guzzle provides both. Inject a client that never
// body-logs - admin responses carry user PII.
$client = new Client();
$httpFactory = new HttpFactory(); // PSR-17 request + stream factory
$transport = new KeycloakTransport(
    $settings,
    $client,
    $httpFactory,
    $httpFactory,
    new ServiceAccountTokenProvider($settings, $client, $httpFactory, $httpFactory),
);

$users = new KeycloakUsersApiImplementation($transport);
foreach ($users->list(search: 'jane', first: 0, max: 20, enabled: true) as $user) {
    echo $user->username, ' ', $user->fullName() ?? '', PHP_EOL;
}

// Update: read-modify-write. Fetch the full user, apply edits with the immutable `with*()` mutators,
// then PUT it back. toRepresentation() overlays only the edited fields onto the original response, so
// any Keycloak field this DTO does not model round-trips untouched (never clobbered).
$user = $users->getById(new KeycloakUserId(''));
$users->update(
    $user->withFirstName('Janet')
         ->withEnabled(false)
         ->withEmailVerified(true)
         ->withAttribute('nickname', ['J']),
);

Also on KeycloakUsersApi:

  • findByUsername(string): ?KeycloakUser - exact identity lookup (exact=true), full representation; more than one match is refused, never silently picked from.
  • create(CreateKeycloakUserCommand): KeycloakUser - POST /users (attributes, credentials, required actions), returned read back through the API.
  • KeycloakUser::withCredentials() + update() - replace all stored credentials of an existing user.

Keycloak REST API coverage

This is based on the Keycloak Admin REST API: The vast majority is not implemented, because so far this is focused upon user-administration.

Legend: ✅ implemented · 🟡 partial · ❌ not implemented (candidate)

# KC resource group Status What we cover / our slice
1 Attack Detection brute-force status/clear - none
2 Authentication Management realm auth-flow config
3 Client Attribute Certificate client keystores
4 Client Initial Access dynamic client registration tokens
5 Client Registration Policy -
6 Client Role Mappings a user's client-role grants - candidate
7 Client Scopes -
8 Clients 🟡 GET /clients (KeycloakClientsApi::list); client CRUD - ❌
9 Component user-federation / key providers
10 default (realm root) GET/PUT /admin/realms/{realm} - planned KeycloakRealmApi
11 Groups 🟡 GET /groups (KeycloakGroupsApi::listRealmGroups); group CRUD, /groups/{id}/members, children - ❌
12 Identity Providers -
13 Key realm keys
14 Organizations -
15 Protocol Mappers -
16 Realms Admin 🟡 GET /events, GET /admin-events (KeycloakEventsApi); realm config + /health - ❌ (planned KeycloakRealmApi)
17 Role Mapper a user's realm-role grants - candidate
18 Roles realm/client role definitions
19 Roles (by ID) -
20 Scope Mappings -
21 Users 🟡 read + update + user-profile schema + credentials + sessions + membership (see detail below); create/delete/reset-password and many sub-resources - ❌
22 Workflows -
- OIDC token endpoint POST /realms/{realm}/protocol/openid-connect/token - ServiceAccountTokenProvider

#21 - Users (paths under /admin/realms/{realm})

Method & path Status Notes
GET /users KeycloakUsersApi::list (infix search), ::findByUsername (exact=true)
POST /users KeycloakUsersApi::create (read back by exact username)
GET /users/count KeycloakUsersApi::count
GET /users/profile KeycloakRealmApi::getUserProfile (attribute schema + per-role perms)
PUT /users/profile user-profile schema authoring
GET /users/profile/metadata user-profile metadata (drives proactive form rendering)
GET /users/{user-id} KeycloakUsersApi::getById
PUT /users/{user-id} KeycloakUsersApi::update (lossless read-modify-write; also writes replacement credentials)
DELETE /users/{user-id} user deletion
GET /users/{user-id}/configured-user-storage-credential-types -
GET /users/{user-id}/consents -
DELETE /users/{user-id}/consents/{client} -
GET /users/{user-id}/credentials KeycloakCredentialsApi::get
DELETE /users/{user-id}/credentials/{credentialId} KeycloakCredentialsApi::delete
POST /users/{user-id}/credentials/{credentialId}/moveAfter/{newPreviousCredentialId} reorder credential
POST /users/{user-id}/credentials/{credentialId}/moveToFirst reorder credential
PUT /users/{user-id}/credentials/{credentialId}/userLabel rename credential
PUT /users/{user-id}/disable-credential-types -
PUT /users/{user-id}/execute-actions-email KeycloakCredentialsApi::executeActionsEmail (array body)
GET /users/{user-id}/federated-identity -
POST /users/{user-id}/federated-identity/{provider} -
DELETE /users/{user-id}/federated-identity/{provider} -
GET /users/{user-id}/groups KeycloakGroupsApi::getUserGroups
GET /users/{user-id}/groups/count -
PUT /users/{user-id}/groups/{groupId} KeycloakGroupsApi::addUserToGroup (body-less)
DELETE /users/{user-id}/groups/{groupId} KeycloakGroupsApi::removeUserFromGroup
POST /users/{user-id}/impersonation deliberately not supported: it plants an SSO cookie in the browser (so a server-side call is useless), and it backchannel-logs-out the caller's own session when caller and target share a realm.
POST /users/{user-id}/logout KeycloakSessionsApi::logoutAll
GET /users/{user-id}/offline-sessions/{clientUuid} -
PUT /users/{user-id}/reset-password not needed: KeycloakUser::withCredentials() + update covers the admin-set / pre-hashed case; execute-actions-email preferred
PUT /users/{user-id}/reset-password-email deprecated alias of execute-actions-email
PUT /users/{user-id}/send-verify-email
GET /users/{user-id}/sessions KeycloakSessionsApi::getSessions
GET /users/{user-id}/unmanagedAttributes -

#11 - Groups (paths under /admin/realms/{realm})

Method & path Status Notes
GET /groups KeycloakGroupsApi::listRealmGroups
POST /groups group CRUD
GET /groups/count -
GET /groups/{group-id} single group
PUT /groups/{group-id} group CRUD
DELETE /groups/{group-id} group CRUD
GET /groups/{group-id}/children sub-group listing
POST /groups/{group-id}/children sub-group create
GET /groups/{group-id}/members list users in a group (group-filter data source)
GET /groups/{group-id}/management/permissions FGAP admin permissions
PUT /groups/{group-id}/management/permissions FGAP admin permissions

#8 - Clients (paths under /admin/realms/{realm})

Method & path Status Notes
GET /clients KeycloakClientsApi::list — the realm's applications; KeycloakClients::browserLoginable() filters to the browser-loginable ones, KeycloakClient::resolvedUrl() yields an openable URL (rootUrl/baseUrl join, ${authBaseUrl}/${authAdminUrl} substitution, redirect-URI origin fallback)
GET /clients/{id} single client
POST /clients client CRUD
PUT /clients/{id} client CRUD
DELETE /clients/{id} client CRUD

#16 - Realms Admin (paths under /admin/realms)

Method & path Status Notes
GET / list realms
POST / create realm
GET /{realm} realm config (editUsernameAllowed, events flags) - planned KeycloakRealmApi
PUT /{realm} realm config authoring
DELETE /{realm} delete realm
GET /{realm}/admin-events KeycloakEventsApi::getAdminEventsForUser
DELETE /{realm}/admin-events clear admin events
POST /{realm}/client-description-converter -
GET /{realm}/client-policies/policies -
PUT /{realm}/client-policies/policies -
GET /{realm}/client-policies/profiles -
PUT /{realm}/client-policies/profiles -
GET /{realm}/client-session-stats -
GET /{realm}/client-types -
PUT /{realm}/client-types -
GET /{realm}/credential-registrators -
GET /{realm}/default-default-client-scopes -
PUT /{realm}/default-default-client-scopes/{clientScopeId} -
DELETE /{realm}/default-default-client-scopes/{clientScopeId} -
GET /{realm}/default-groups -
PUT /{realm}/default-groups/{groupId} -
DELETE /{realm}/default-groups/{groupId} -
GET /{realm}/default-optional-client-scopes -
PUT /{realm}/default-optional-client-scopes/{clientScopeId} -
DELETE /{realm}/default-optional-client-scopes/{clientScopeId} -
GET /{realm}/events KeycloakEventsApi::getUserEvents
DELETE /{realm}/events clear login events
GET /{realm}/events/config events flags
PUT /{realm}/events/config events config authoring
GET /{realm}/group-by-path/{path} candidate
GET /{realm}/localization realm i18n
GET /{realm}/localization/{locale} realm i18n
POST /{realm}/localization/{locale} realm i18n
DELETE /{realm}/localization/{locale} realm i18n
GET /{realm}/localization/{locale}/{key} realm i18n
PUT /{realm}/localization/{locale}/{key} realm i18n
DELETE /{realm}/localization/{locale}/{key} realm i18n
POST /{realm}/logout-all
POST /{realm}/partial-export -
POST /{realm}/partialImport -
POST /{realm}/push-revocation -
DELETE /{realm}/sessions/{session}
POST /{realm}/testSMTPConnection realm SMTP config
GET /{realm}/users-management-permissions FGAP admin permissions
PUT /{realm}/users-management-permissions FGAP admin permissions

Development Ideas

Package layout (feature-first)

src/
  Connection/          transport + auth
    Auth/
  SharedModel/         KeycloakUserId, KeycloakTimestamp, KeycloakCollection (core value objects)
  Features/
    KeycloakUsersApi.php          + KeycloakUsersApi/{…Implementation, Dto/…}
    KeycloakGroupsApi.php         + KeycloakGroupsApi/{…}
    KeycloakCredentialsApi.php    + KeycloakCredentialsApi/{…}
    KeycloakSessionsApi.php       + KeycloakSessionsApi/{…}
    KeycloakClientsApi.php        + KeycloakClientsApi/{…Implementation, Dto/KeycloakClient(s)}
    KeycloakEventsApi.php         + KeycloakEventsApi/{…}
    KeycloakRealmApi.php          + KeycloakRealmApi/{…Implementation, Dto/KeycloakUserProfile…}

Each feature keeps its interface, implementation, and DTOs together. Interfaces carry the Keycloak prefix so an imported symbol is self-describing inside a larger host codebase.

Unit and Integration Tests

mise run install          # composer install
mise run test             # unit suite - no I/O, no Keycloak
mise run analyse          # phpstan (level 6)

Two tiers:

  • Unit (tests/Unit) - fast, hermetic. Real logic only (DTO/collection parsing tolerance, token cache, array-body encoding, query building, the non-2xx → UnexpectedKeycloakResponseException (with statusCode) mapping) driven through a PSR-18 mock.
  • Integration / E2E (tests/Integration) - runs the client against a real Keycloak 26.5.3 in Docker (tests/Integration/docker-compose.yml imports two self-contained realms). This proves the wire contract unit tests cannot. The write suite (KeycloakUserWritesE2ETest) creates uniquely named users and proves a written credential by really logging in with it, so it is re-runnable against a long-lived instance.
mise run e2e              # boot Keycloak (both realms), run the integration suite, tear down
# or step by step:
mise run e2e:up
mise run test:integration
mise run e2e:down

Log into the Keycloak admin console at http://localhost:9911 with admin / admin. Seeded users all have password changeit.

Two realms are imported so the wire contract is proven in both authorization modes — classic realm-management roles and Fine-Grained Admin Permissions (FGAP). The FGAP realm drives the caller-relative access map (KeycloakUser::$access) and per-caller write authorisation: a user bearer is obtained via the public e2e-login direct-grant client (tests/Support/DirectGrantTokenProvider), so calls run as that user and Keycloak evaluates that user's own grants.

Realm Admin Permissions (FGAP) Notable seeded users / caller identity
test-realm off (classic roles) service account (admin-api, realm-management roles), login-user (none), jane in /staff
test-realm-fgap on admin-user (roles), login-user (none), sarah + jane in /staff, emma in /endusers

FGAP staff policy (baked into realm-import-fgap.json)

Staff read everyone, edit endusers, can't touch other staff:

match-staff  = Group policy → members of /staff
                │
   ┌────────────┴─────────────────────────────┐
   ▼                                           ▼
"staff can view all"                  "staff can manage endusers"
 Users · view · All users              Groups · manage-members · group /endusers
   │                                           │
   ▼                                           ▼
 sarah ──view──▶ everyone            sarah ──manage──▶ emma (∈/endusers)  ✔
                                     sarah ──manage──▶ jane (∈/staff)     ✘ 403

License

MIT

About

PHP based Keycloak Admin API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages