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

CaseExpected and observed
Same user and Keycloak session, two browser bindingsDistinct signed claims, both preserved through later introspection
Replacement binding supplied during introspectionOriginal token's binding retained
Login token introspected with an injected bindingActive login token has no desktop binding
Missing, empty, malformed or oversized binding400 `invalid_request`
Duplicate binding or audience parameters400 `invalid_request`
Exchange of an already issued desktop delegation400 `invalid_request`; cannot rebind it
Wrong target audience or requested refresh-token type400 `invalid_request`
Wrong configured client, using the otherwise valid certificate400 `invalid_request`
Unconfigured trusted certificate with its own matching source token400 `invalid_request`
Source certificate mismatch or no certificate400 `invalid_request`
Separate login client's bearer token exchanged by the configured BridgeCertificate-bound gateway token with the requested browser binding
Browser binding edited inside the JWT without resigningIntrospection inactive
Logout with wrong certificate401; exchanged token remains active
Logout with matching certificate204; exchanged token becomes inactive before expiry
Application listenerHTTPS 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.