Keycloak browser-binding provider qualification (#853)
Keycloak browser-binding provider qualification (#853)
The opt-in provider implements the browser-session claim missing from the native experiment. It authenticates the configured confidential Bridge client and exact certificate, checks verified source/issued user and issuer linkage, and issues a distinct browser binding in each signed desktop delegation. Introspection preserves the original signed binding instead of reading mutable session notes or replacement request parameters.
This qualifies the issuer component in a disposable realm. It does not qualify the Bridge's browser authentication, production realm configuration, the Rust adapter's fresh membership/session API calls, or authenticated RDP. The Bridge must still generate the digest from its authenticated browser session, use the same certificate for issuer and gateway requests, and verify the returned binding. Workspace/action mappings remain a separate requirement. No gateway fallback or production configuration was changed.
Live cases
| Case | Expected and observed |
|---|---|
| Same user and Keycloak session, two browser bindings | Distinct signed claims, both preserved through later introspection |
| Replacement binding supplied during introspection | Original token's binding retained |
| Login token introspected with an injected binding | Active login token has no desktop binding |
| Missing, empty, malformed or oversized binding | 400 `invalid_request` |
| Duplicate binding or audience parameters | 400 `invalid_request` |
| Exchange of an already issued desktop delegation | 400 `invalid_request`; cannot rebind it |
| Wrong target audience or requested refresh-token type | 400 `invalid_request` |
| Wrong configured client, using the otherwise valid certificate | 400 `invalid_request` |
| Unconfigured trusted certificate with its own matching source token | 400 `invalid_request` |
| Source certificate mismatch or no certificate | 400 `invalid_request` |
| Separate login client's bearer token exchanged by the configured Bridge | Certificate-bound gateway token with the requested browser binding |
| Browser binding edited inside the JWT without resigning | Introspection inactive |
| Logout with wrong certificate | 401; exchanged token remains active |
| Logout with matching certificate | 204; exchanged token becomes inactive before expiry |
| Application listener | HTTPS only |
The separate login test uses a synthetic password grant to obtain its bearer source. It demonstrates native bearer-to-bound exchange, not authorization-code, PKCE or browser login acceptance. The other-client case deliberately attaches the same mapper configuration to a different confidential client, proving that mapper attachment alone does not authorize that requester.
Build, failed attempts and limits
The Dockerfile pins Keycloak 26.7.3 and Temurin JDK 21 by immutable image digest. Compilation uses only libraries from that runtime image with network disabled; the JAR uses fixed archive timestamps. The provider uses an internal Keycloak SPI, so issuer upgrades require explicit requalification.
The first compilation used an integer where Keycloak's error constructor requires an HTTP status enum. That API mismatch was corrected. The first live run correctly rejected a source token lacking `sub`: the fixture's custom scope list had omitted Keycloak's native subject mapper. The fixture now includes it explicitly. The subject check was retained, and source/issued subject and issuer equality were also enforced during review. Fresh containers were used for the corrected realm and final provider; no failed server was silently treated as ready.
The native baseline was rerun without the provider after the fixture correction. Its certificate exchange, introspection and logout tests still pass, and it still has no browser binding. The earlier native result was evidence for certificate mechanics only; its missing subject also made it insufficient for gateway use.
The manifest, provider result, native regression and cleanup record record source/JAR hashes, sanitized results and all four containers verified absent. The cleanup assertion initially expected Docker's older `No such object` message; explicit container inspection returned `No such container` and verified each exact ID. New Java/Python coverage is not measured; the overall changed-module coverage gate remains unmet. Full issuer-to-Bridge and issuer-to-Rust qualification remain open, as do the other desktop issues.