brittle protocol mapper: the last row of the brittle matrix, loud at use
- Database
- PostgreSQL 16, fresh empty DB
- Host
- local laptop Docker (not Hetzner); 1k-class, no scale component
- Condition
- Custom SPI vs old internals — untested row: *brittle (removed-API) protocol mapper — load vs use, silent vs loud*
Why this run existed
2026-08-26-provider-types-protocol-mapper-brittle closed three of the four
brittle rows (listener = lazy + silent; authenticator = lazy + loud;
user-storage = lazy + loud) and left one ☐: the protocol mapper. The stated
blocker was that "token-transform methods moved out of the base ProtocolMapper
interface in 26, so the removed helper has no natural home there."
That blocker is refuted. The base ProtocolMapper interface
(keycloak-server-spi-private) has never carried a token-transform method — its
only abstract methods across 21.1.2 → 26.7.1 are getProtocol /
getDisplayCategory / getDisplayType (plus a couple of defaults that grow
over time, validateConfig from the start and getEffectiveModel from 23.x). The work method lives on the
OIDC marker interface org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper
(transformAccessToken), which sits in keycloak-services, not
keycloak-server-spi-private, and whose signature is stable across the
25 → 26 boundary. So there is a natural home; the previous note mistook "the
work method is not in the artifact we build against" for "the work method moved
in 26".
This run builds fixtures/spi-brittle-pm/ — a mapper that implements
ProtocolMapper + OIDCAccessTokenMapper and calls the same removed
three-argument runJobInTransactionWithResult overload the other brittle
fixtures use, from inside transformAccessToken().
Environment
| Field | Value |
|---|---|
| Keycloak | built against 25.0.6, loaded on 26.7.1 (crosses the 26.0.0 API-removal boundary) |
| Fixture | fixtures/spi-brittle-pm/ (new) — lab-brittle-protocolmapper |
| Build | bin/build-spi.sh 25.0.6 --module spi-brittle-pm (Maven in Docker) |
| New dependency | keycloak-services (provided) — for OIDCAccessTokenMapper, the one artifact the other three brittle fixtures do not need |
| Database | PostgreSQL 16, fresh empty DB |
| Host | local laptop Docker (not Hetzner); 1k-class, no scale component |
| JVM heap | -Xms1g -Xmx4g (compose default) |
The removed API is the same vehicle used across the whole brittle matrix: the
three-argument overload KeycloakModelUtils.runJobInTransactionWithResult( KeycloakSessionFactory, KeycloakContext, KeycloakSessionTaskWithResult) —
present in 25.0.6, absent in 26.0.0 (replaced by a four-argument overload),
living in keycloak-server-spi-private (no compatibility promise).
Procedure
cd lab
# fixture is checked in; build the brittle mapper against the OLD major
./bin/build-spi.sh 25.0.6 --module spi-brittle-pm
./bin/lab-up.sh 26.7.1 # jar is bind-mounted into /opt/keycloak/providers
# verify it LOADED (no ERROR, internal-SPI warning, registered)
docker compose logs keycloak | grep -E 'lab-brittle-pm|KC-SERVICES0047'
# KC-SERVICES0047: lab-brittle-protocolmapper ... internal SPI protocol-mapper ...
# [lab-brittle-pm] protocol mapper init -- provider loaded
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/admin/serverinfo \
| jq '.providers["protocol-mapper"]' # internal:true, "lab-brittle-protocolmapper" present
# trigger token issuance (the mapper's work method) with a service account:
# realm pmtest, confidential client brittle-client w/ the mapper attached, serviceAccountsEnabled
curl -s -o /tmp/tok.out -w '%{http_code}' \
-d client_id=brittle-client -d client_secret=brittle-secret -d grant_type=client_credentials \
http://localhost:8080/realms/pmtest/protocol/openid-connect/token
# -> HTTP 500, {"error":"unknown_error",...}
Attach the mapper by including it in the client's protocolMappers at create
time ({"protocolMapper":"lab-brittle-protocolmapper","config":{}}), then
enable serviceAccountsEnabled and issue a client-credentials grant. Any token
grant exercises transformAccessToken; the client-credentials grant is the
cleanest trigger because it does not involve the user required-actions
machinery (see note under Outcome).
Findings
Finding 1 — the token-transform work method is in keycloak-services, not server-spi-private, and it is stable across 25 → 26 ⭐
The base ProtocolMapper interface carries no token-transform method at any of
21.1.2, 23.0.7, 24.0.5, 25.0.6, 26.0.0 or 26.7.1 — its abstract methods are
getProtocol, getDisplayCategory, getDisplayType (with getPriority and
validateConfig defaults from the start, getEffectiveModel from 23.x). So the
earlier "moved out of the base interface in 26" note does not describe a real
change.
The work method is OIDCAccessTokenMapper.transformAccessToken (and
OIDCIDTokenMapper.transformIDToken), which live in keycloak-services:
public interface org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper {
public abstract AccessToken transformAccessToken(AccessToken,
ProtocolMapperModel, KeycloakSession, UserSessionModel, ClientSessionContext);
}
That signature is byte-for-byte identical in 25.0.6, 26.0.0 and 26.7.1. The
practical consequence: a customer's OIDC mapper that extends
AbstractOIDCProtocolMapper (also keycloak-services) or implements
OIDCAccessTokenMapper has a stable work-method home to override — which is
exactly where a removed-API call would live, and therefore the right place to
ask the load-vs-use question.
Finding 2 — a brittle protocol mapper fails at use, loudly (HTTP 500) ⭐
fixtures/spi-brittle-pm calls the removed three-argument overload from inside
transformAccessToken().
It loads. On 26.7.1 the server starts with zero ERRORs, the factory init()
runs, KC-SERVICES0047 fires, and the provider registers in
/admin/serverinfo under protocol-mapper (SPI internal: true):
WARN KC-SERVICES0047: lab-brittle-protocolmapper (lab.LabBrittleProtocolMapper)
is implementing the internal SPI protocol-mapper. This SPI is internal and may
change without notice
[lab-brittle-pm] protocol mapper init -- provider loaded
It dies at first use, loudly. A client-credentials grant returns HTTP 500
with the standard unknown_error body, and the log carries the uncaught error —
thrown from transformAccessToken and propagating straight up through the token
endpoint:
ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-1)
Uncaught server error: java.lang.NoSuchMethodError: 'java.lang.Object
org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
org.keycloak.models.KeycloakSessionTaskWithResult)'
at lab.LabBrittleProtocolMapper.transformAccessToken(LabBrittleProtocolMapper.java:89)
at org.keycloak.protocol.oidc.TokenManager.transformAccessToken(TokenManager.java:827)
at org.keycloak.protocol.oidc.TokenManager.createClientAccessToken(TokenManager.java:429)
at org.keycloak.protocol.oidc.grants.ClientCredentialsGrantType.process(ClientCredentialsGrantType.java:147)
at org.keycloak.protocol.oidc.endpoints.TokenEndpoint.processGrantRequest(TokenEndpoint.java:174)
Finding 3 — the brittle matrix is now complete, and it splits two ways ⭐
All four provider types now have a brittle row, and none fails at load:
| Type | Fails at | Visibility | Record |
|---|---|---|---|
| event listener | use | silent (request succeeds) | S7 |
| authenticator | use | loud (HTTP 500) | …-provider-types-protocol-mapper-brittle.md F2 |
| user-storage | use | loud (HTTP 500) | …-provider-types-protocol-mapper-brittle.md F4 |
| protocol mapper | use | loud (HTTP 500) | this run, F2 |
Keycloak never invokes a provider's work method during startup, so a removed API that only the work method reaches never breaks the upgrade itself. The visibility split is: event dispatch swallows the error (listener); every other path propagates it to HTTP 500. A protocol mapper fails exactly the way an authenticator and a user-federation provider do, not the way a listener does.
Outcome
- Reached ready (26.7.1, fresh DB)
-
Custom SPI loaded (brittle protocol mapper registered,
internal:true) -
Failure reproduced at use, loudly (HTTP 500 +
NoSuchMethodError) - Login flow works — n/a: the mapper is wired to fail on purpose
- Realm config intact — n/a: fresh throwaway realm
Operational note — password grant is the wrong trigger for a mapper
A password (ROPC) grant against a hand-rolled realm returned
400 invalid_grant: "Account is not fully set up" (error=resolve_required_actions)
even with the user's requiredActions empty and emailVerified=true; clearing
required actions and toggling realm flags did not move it. The client-credentials
grant sidesteps this and exercises transformAccessToken directly, so it is the
canonical trigger for a protocol-mapper fixture. The ROPC quirk is not
diagnosed here and is not a finding of this run — it is the auth-session
required-actions path, upstream of the mapper, and deserves its own look if the
lab ever needs ROPC against a hand-rolled realm.
Verification
| Claim | Primary source | Checked |
|---|---|---|
Base ProtocolMapper has no token-transform method at any version | javap of org.keycloak.protocol.ProtocolMapper in server-spi-private 21.1.2 / 23.0.7 / 24.0.5 / 25.0.6 / 26.0.0 / 26.7.1 — abstract methods only getProtocol/getDisplayCategory/getDisplayType throughout | ☑ |
OIDCAccessTokenMapper lives in keycloak-services, not server-spi-private | jar listing: class present in keycloak-services, absent from the other three artifacts | ☑ |
transformAccessToken signature stable 25 → 26 | javap of OIDCAccessTokenMapper in keycloak-services 25.0.6 / 26.0.0 / 26.7.1 — identical | ☑ |
| Brittle mapper loads on 26.7.1 (not load-failing) | startup log: init -- provider loaded, 0 ERRORs, KC-SERVICES0047, registered in serverinfo | ☑ |
| Brittle mapper dies at use, loudly | HTTP 500 + unknown_error body + NoSuchMethodError stack quoted above | ☑ |
| Throw site is the work method | stack: LabBrittleProtocolMapper.transformAccessToken → TokenManager.transformAccessToken → createClientAccessToken → TokenEndpoint | ☑ |
| The exact 24.x release that split model/storage | still not narrowed — bracketed to (23.0.7, 24.0.5] | ☐ |
Publishable extract
A custom protocol mapper that outlived its API breaks your next token, not your upgrade.
We built an OIDC protocol mapper against Keycloak 25.0.6 that calls a helper removed in 26.0.0, then loaded it on 26.7.1. The server started cleanly — the mapper registered,
/admin/serverinfolisted it, zero errors. The first token request that reached itstransformAccessTokenmethod returned HTTP 500 and a fullNoSuchMethodErrorstack as an uncaught server error.That is the same shape as a broken authenticator and a broken user-federation provider, and the opposite of a broken event listener: event dispatch swallows the exception and the login still succeeds, while the mapper's failure propagates out of the token endpoint. So a "can users still get a token" check catches a broken mapper and misses a broken listener. The mapper's work method lives in Keycloak's
keycloak-servicesartifact, not the SPI artifact — a mapper that registers cleanly can still be sitting on an API that no longer exists.