dmstr / keycloak-security-bundle
Keycloak-backed Symfony Security: KeycloakUser + KeycloakUserProvider for JWT-authenticated users managed in Keycloak, plus a CLI for token retrieval.
Package info
github.com/dmstr/keycloak-security-bundle
Type:symfony-bundle
pkg:composer/dmstr/keycloak-security-bundle
Requires
- php: >=8.4
- lexik/jwt-authentication-bundle: ^3.0
- symfony/framework-bundle: ^7.0
- symfony/security-bundle: ^7.0
Requires (Dev)
- phpunit/phpunit: ^12.0
- symfony/yaml: ^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-27 08:38:59 UTC
README
dmstr/keycloak-security-bundle
Keycloak-backed Symfony Security.
Features
KeycloakUser— user model populated from the JWT claims issued by KeycloakKeycloakUserProvider— Symfony Security user provider; maps the Keycloakrealm_access.rolesclaim toROLE_*rolesTestAuthenticator— header-driven authenticator for functional tests, available only in thetestenvironment (see Test mode)app:auth:tokenCLI — retrieves a JWT for a configured Keycloak user- RFC 9728 protected resource metadata endpoint (see RFC 9728 Protected Resource Metadata)
Test mode (TestAuthenticator)
Functional tests of a consuming API should not need a running Keycloak or a
real token. Dmstr\KeycloakSecurity\Security\TestAuthenticator authenticates
requests without any JWT validation for that purpose. The bundle registers
this service only in the test environment (a when@test block in the
bundle's config/services.yaml). A reference to it from dev or prod
therefore fails at container compile time with a ServiceNotFoundException,
instead of silently authenticating every request as an administrator.
Warning: never wire
TestAuthenticatoroutside awhen@testblock. It accepts every request without checking any credential.
Wiring
Add the authenticator to your API firewall in the application's
config/packages/security.yaml, inside when@test:
when@test: security: firewalls: api: custom_authenticators: - Dmstr\KeycloakSecurity\Security\TestAuthenticator
Under when@test the firewall definition is merged into the base definition
of the api firewall, so jwt: ~ (or an entry_point) of the base firewall
stays in place and the test authenticator is added next to the JWT
authenticator. The test authenticator supports every request unless the
anonymous header (see below) is set. The Lexik JWT authenticator, in contrast,
only supports requests that carry an Authorization: Bearer header, so a test
request without a token is handled by the test authenticator alone. A request
that does send a bearer token still goes through the JWT authenticator as
well, so tests should not send one.
Default behaviour
Without any of the headers below, every request is authenticated as the
identifier test-admin with the role ROLE_ADMIN (plus ROLE_USER, which
KeycloakUser always adds), the e-mail admin@test.example.com and the name
Test Admin. This is unchanged from earlier versions.
Headers
Three request headers adjust the authenticated user per request. Their names
are a public contract and are also available as class constants
(TestAuthenticator::HEADER_ROLES, HEADER_USER, HEADER_AUTH), as are the
two derived identifiers (ADMIN_IDENTIFIER, USER_IDENTIFIER).
| Header | Effect |
|---|---|
X-Test-Roles: ROLE_FOO, ROLE_BAR |
Comma-separated list, whitespace trimmed, empty entries ignored; replaces ROLE_ADMIN. The identifier becomes test-user (e-mail user@test.example.com, name Test User) unless the list contains ROLE_ADMIN, in which case it stays test-admin. An empty value means authenticated with only ROLE_USER. |
X-Test-User: alice |
Replaces the derived identifier, for example to test ownership checks with two users. Roles, e-mail and name still follow X-Test-Roles. An empty or whitespace-only value falls back to the derived identifier. |
X-Test-Auth: anonymous |
Case-insensitive. The authenticator steps back (supports() returns false), no token is created, and access_control answers 401 regardless of the other headers. Any other value is ignored. |
Anonymous requests and the entry point
The bundle ships no entry point. Symfony refuses two entry-point
authenticators on one firewall without an explicit entry_point, and most
applications already use the Lexik JWT authenticator, which is one. A 401 is
produced in any case: without an entry point Symfony throws an
HttpException(401). The entry point only decides the response body and
headers. There are two documented ways to shape it:
- The
jwt: ~authenticator of the base firewall is the entry point. An anonymous test request then receives the Lexik JSON body and aWWW-Authenticate: Bearerheader, exactly like a real client without a token. - The application sets
entry_point: App\Security\MyEntryPointon the firewall explicitly, and that service answers.
Example
A functional test with Symfony's test client passes the headers as HTTP_*
server parameters:
$client->request('GET', '/api/things', server: [ 'HTTP_X_TEST_ROLES' => 'ROLE_EDITOR', 'HTTP_X_TEST_USER' => 'alice', ]); self::assertResponseIsSuccessful(); $client->request('GET', '/api/things', server: [ 'HTTP_X_TEST_AUTH' => 'anonymous', ]); self::assertResponseStatusCodeSame(401);
RFC 9728 Protected Resource Metadata
Lets clients discover the responsible authorization server knowing nothing but the API entrypoint (RFC 9728):
GET /.well-known/oauth-protected-resource
{
"resource": "https://api.example.com",
"authorization_servers": ["https://kc.example.com/realms/acme"],
"bearer_methods_supported": ["header"]
}
authorization_servers[0] is the OIDC issuer — standard OIDC discovery
({issuer}/.well-known/openid-configuration) takes over from there. In
addition, every 401 response advertises the metadata document via the
WWW-Authenticate challenge (RFC 9728 section 5.1):
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"
The feature is opt-in — configure the issuer and import the route:
# config/packages/dmstr_keycloak_security.yaml dmstr_keycloak_security: realm_url: '%env(KEYCLOAK_REALM_URL)%' # resource: 'https://api.example.com' # optional, defaults to request scheme+host # config/routes.yaml dmstr_keycloak_security: resource: '@KeycloakSecurityBundle/config/routes.yaml'
The endpoint is public metadata: it responds with Access-Control-Allow-Origin: *
and is cacheable (public, max-age=3600). Make sure your firewall leaves
/.well-known/ anonymous.
License
MIT © diemeisterei GmbH