feat: add tls client auth idp brokering (mtls) (#38310) - #51474
Conversation
There was a problem hiding this comment.
Pull request overview
Adds RFC 8705 tls_client_auth support for OIDC identity-provider brokering.
Changes:
- Adds realm-key-based client certificate selection and mTLS endpoint aliases.
- Extends outbound HTTP clients with per-request TLS key material and lifecycle handling.
- Adds Admin UI, documentation, and automated tests.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
tests/base/.../IdentityProviderOidcTlsClientAuthTest.java |
Tests Admin API validation. |
services/.../HttpClientBuilderTest.java |
Tests retained key managers. |
services/.../DefaultHttpClientFactoryTest.java |
Tests dedicated-client settings. |
services/.../OIDCIdentityProviderFactoryTest.java |
Tests discovery aliases. |
services/.../OAuth2IdentityProviderConfigTest.java |
Tests mTLS configuration. |
services/.../IdpMtlsSslContextProviderTest.java |
Tests SSL-context construction. |
services/.../IdpClientCertificateResolverTest.java |
Tests certificate resolution. |
services/.../HttpClientBuilder.java |
Supports custom key managers. |
services/.../DefaultHttpClientFactory.java |
Builds dedicated TLS clients. |
services/.../OIDCIdentityProviderFactory.java |
Imports mTLS aliases. |
services/.../OIDCIdentityProviderConfig.java |
Uses session-aware validation. |
services/.../OIDCIdentityProvider.java |
Routes backchannel requests through mTLS. |
services/.../OAuth2IdentityProviderConfig.java |
Adds mTLS settings and validation. |
services/.../IdpMtlsSslContextProvider.java |
Builds client TLS material. |
services/.../IdpClientCertificateResolver.java |
Resolves realm certificates. |
services/.../AbstractOAuth2IdentityProvider.java |
Authenticates backchannel requests with mTLS. |
server-spi/.../IdentityProviderModel.java |
Adds session-aware validation API. |
server-spi-private/.../SimpleHttpOwnedClientTest.java |
Tests owned-client closure. |
server-spi-private/.../RepresentationToModel.java |
Invokes session-aware validation. |
server-spi-private/.../SimpleHttpResponse.java |
Runs response completion callbacks. |
server-spi-private/.../SimpleHttpRequest.java |
Closes owned clients. |
server-spi-private/.../SimpleHttp.java |
Adds client completion policy. |
server-spi-private/.../HttpClientProvider.java |
Exposes dedicated-client APIs. |
js/.../OIDCAuthentication.tsx |
Adds mTLS configuration controls. |
js/.../messages_en.properties |
Adds mTLS UI labels. |
docs/.../identity-broker/oidc.adoc |
Documents broker mTLS configuration. |
docs/.../26_7_0.adoc |
Adds release notes. |
4bb95fa to
0020f71
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:68
- The
/keysresponse includes disabled keys, whileIdpClientCertificateResolveraccepts only ACTIVE/PASSIVE keys. This selector therefore offers choices that are guaranteed to be rejected on save; filter out disabled or missing statuses when building the options.
if (k.certificate && k.providerId && !seen.has(k.providerId)) {
0020f71 to
6234bba
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (4)
services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java:310
- Replacing the existing
validate(RealmModel)override makes that still-public validation entry point a no-op forOAuth2IdentityProviderConfig(and strips common URL/PKCE checks from subclasses or extensions that call it directly). Keep the realm-only override for validations that do not need a session, and have the new overload delegate to it before adding the key-resolution check;OIDCIdentityProviderConfigshould likewise retain its realm-only checks.
@Override
public void validate(KeycloakSession session, RealmModel realm) {
// Invoke the legacy validate(RealmModel) hook first so provider-specific checks registered by
// subclasses that only override the (RealmModel) overload keep running (e.g. the generic OAuth
services/src/main/java/org/keycloak/broker/oidc/mtls/IdpClientCertificateResolver.java:32
- This accepts any non-null
Key, butbuildKeyManagerslater requires aPrivateKey; a custom key provider can therefore pass create/update validation and then fail every mTLS request. Validate the same type requirement here so unusable configurations are rejected up front.
if (key.getPrivateKey() == null) {
throw new IllegalStateException(
"Realm key " + keyProviderId + " has no private key; cannot be used for tls_client_auth.");
}
model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java:383
- Moving all components ahead of groups breaks imports containing an LDAP group mapper with a custom groups path:
GroupLDAPStorageMapperFactory.validateConfigurationresolves that path duringimportComponentModeland rejects it because groups are not imported until line 474. Import only the key-provider components needed by IdP validation here, and leave other components at their previous post-group position.
if (rep.getComponents() != null) {
MultivaluedHashMap<String, ComponentExportRepresentation> components = rep.getComponents();
String parentId = newRealm.getId();
importComponents(newRealm, components, parentId);
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:68
- The selector includes
DISABLEDcertificate keys, although the backend resolver only acceptsACTIVEorPASSIVEkeys, so choosing such an offered option is guaranteed to fail validation. Filter the metadata by enabled status before adding the option.
if (k.certificate && k.providerId && !seen.has(k.providerId)) {
6234bba to
510a41b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:68
- The selector includes disabled certificate-bearing keys, but
IdpClientCertificateResolveraccepts onlyACTIVEorPASSIVEkeys. Selecting a disabled option therefore guarantees that saving the IdP fails; filter the metadata status to match the backend eligibility check.
if (k.certificate && k.providerId && !seen.has(k.providerId)) {
510a41b to
28b0d41
Compare
28b0d41 to
273148e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/base/src/test/java/org/keycloak/tests/model/ImportTest.java:215
- This lookup still targets the realm attached to the run-on-server request, not the imported
realmfetched above;IdentityProviderStorageProviderderives its realm from the session context, so the assertion will query the test framework's managed realm and returnnull. Set the session context to the imported realm before usingsession.identityProviders().
IdentityProviderModel idp = session.identityProviders().getByAlias("mtls-oidc");
services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java:747
- Each mTLS backchannel call constructs and closes a new pooled client; with the default
max-connection-idle-time-millis, that also starts an eviction thread per token/userinfo request and prevents connection/TLS-session reuse. This creates substantial thread and handshake churn on the login path; reuse clients by IdP/key with lifecycle and rotation invalidation, or build a lightweight non-pooled per-call client without an evictor.
return SimpleHttp.create(buildMtlsHttpClient(httpClientProvider), CLOSE_CLIENT)
.withMaxConsumedResponseSize(httpClientProvider.getMaxConsumedResponseSize());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java:747
- Building and closing a full pooled client for every backchannel call defeats the configured global connection-pool limits and connection reuse; each concurrent login gets its own pool (and idle-evictor thread), so mTLS traffic is no longer capped by
connection-pool-size/max-pooled-per-routeand performs a fresh TLS handshake. Reuse clients keyed by IdP/key material with a managed invalidation/close lifecycle, or provide a dedicated design that preserves an aggregate connection limit.
HttpClientProvider httpClientProvider = session.getProvider(HttpClientProvider.class);
return SimpleHttp.create(buildMtlsHttpClient(httpClientProvider), CLOSE_CLIENT)
.withMaxConsumedResponseSize(httpClientProvider.getMaxConsumedResponseSize());
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:20
- This option is also rendered by
AddOAuth2.tsxand the generic OAuth edit panel, butOAuth2IdentityProvider.fetchUserProfilestill usesgetUserInfoUrl()withSimpleHttp.create(session). A generic OAuth provider configured here can complete the mTLS token request and then fail while fetching the required profile; either route that call through the mTLS endpoint/client helpers or restrict this option to providers whose full backchannel path supports it.
"tls_client_auth",
273148e to
455b0a8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Suppressed comments (3)
services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java:308
- Replacing the existing
validate(RealmModel)override breaks its public behavior: direct callers now reachIdentityProviderModel's no-op, and legacy subclasses that intentionally replaced validation by omittingsuper.validate(realm)are now forced through the generic checks (the Google-specific workaround demonstrates this). Keep the session-independent validation in the old override and have the new overload add only session-dependent mTLS validation via the base delegation, preserving existing SPI semantics.
public void validate(KeycloakSession session, RealmModel realm) {
services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderFactory.java:89
- When a discovery document omits
mtls_endpoint_aliases, these keys are absent from the returned map. The Admin UI merges imported keys withsetValue(OpenIdConnectSettings.tsx:29-32), so importing a second document without aliases leaves the previous hidden mTLS URLs in place and requests keep going to stale endpoints. Ensure import explicitly clears all three alias fields when they are absent.
MTLSEndpointAliases mtlsAliases = rep.getMtlsEndpointAliases();
if (mtlsAliases != null) {
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:68
- The selector includes
DISABLEDcertificate keys, butIdpClientCertificateResolverrejects them (IdpClientCertificateResolver.java:24). This lets the UI offer a value that is guaranteed to fail on save; only include ACTIVE/PASSIVE keys.
if (k.certificate && k.providerId && !seen.has(k.providerId)) {
455b0a8 to
045e54d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Suppressed comments (1)
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:20
- This option is also rendered by the generic OAuth 2 provider (
AddOAuth2.tsx:79andDetailSettings.tsx:538), but that provider is not fully mTLS-aware:OAuth2IdentityProvider.fetchUserProfilestill usesSimpleHttp.create(session)and the regular user-info URL. A generic OAuth login can therefore complete the token exchange and then fail when its user-info endpoint requires the advertised client certificate; either scope this option to OIDC providers or route the OAuth provider's discovery/user-info path through the new mTLS client as well.
"tls_client_auth",
045e54d to
dd1765c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.
Suppressed comments (4)
services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java:287
- When
disable-trust-manageris configured, every per-request mTLS client rebuild executes this warning, turning each token/user-info/refresh/introspection call into a WARN entry. Keep the warning for initialization but avoid logging it for every dedicated client build.
if (disableTrustManager) {
logger.warn("TrustManager is disabled");
builder.disableTrustManager();
services/src/main/java/org/keycloak/broker/oidc/mtls/IdpClientCertificateResolver.java:32
- This accepts any non-null
Key, butbuildKeyManagerslater requires aPrivateKey. A custom key provider returning anotherKeyimplementation therefore passes create/update validation and then deterministically fails every mTLS request; enforce the samePrivateKeypredicate here.
if (key.getPrivateKey() == null) {
throw new IllegalStateException(
"Realm key " + keyProviderId + " has no private key; cannot be used for tls_client_auth.");
services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java:216
- Every dedicated mTLS client runs
configureBuilder, which reloads the configured globalclient-keystorefrom disk even though the subsequently supplied key managers take precedence and discard that keystore inresolveSslContext. This adds keystore I/O and parsing to every token, user-info, refresh, and introspection call; skip loading the global client keystore when custom TLS material is supplied, or cache the loaded store.
CloseableHttpClient buildHttpClient(KeycloakSession session, SSLContext sslContext, KeyManager[] keyManagers) {
HttpClientBuilder builder = newHttpClientBuilder(session);
configureBuilder(builder, session);
services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java:275
configureBuildernow runs for every caller-owned mTLS client, so installations without a Keycloak truststore emit this warning once per backchannel request instead of once during shared-client initialization. Emit it once per factory/configuration (or suppress it for dedicated clients) to avoid log flooding during normal mTLS traffic.
This issue also appears on line 285 of the same file.
if (disableTruststoreProvider) {
logger.warn("TruststoreProvider is disabled");
Signed-off-by: Sebastian Pfahl <eryx@gmx.net>
dd1765c to
a995aa4
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.
Suppressed comments (5)
services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java:287
- This warning is also now reached for every dedicated mTLS backchannel client rather than only during shared-client initialization. With
disable-trust-manager, normal broker traffic will repeatedly emit the same WARN; move it to one-time initialization or guard it so it is logged once.
if (disableTrustManager) {
logger.warn("TrustManager is disabled");
builder.disableTrustManager();
js/apps/admin-ui/src/identity-providers/add/OIDCAuthentication.tsx:61
- The dependency list omits
realm, so after switching realms while this component remains mounted the selector keeps provider IDs from the previous realm and never refetches them. Includerealmso the request and options follow the current realm.
[clientAuthMethod],
server-spi-private/src/main/java/org/keycloak/http/simple/SimpleHttpRequest.java:258
- This failure guard starts after request/URI/entity construction. If any of that setup throws (for example, an invalid URI),
onCloseRequest()is never called and aCLOSE_CLIENTclient leaks; wrap construction and execution in the close-on-failure guard, transferring ownership toSimpleHttpResponseonly afterexecutesucceeds.
try {
return new SimpleHttpResponse(client.execute(httpRequest), maxConsumedResponseSize, objectMapper, this::onCloseRequest);
} catch (IOException | RuntimeException e) {
onCloseRequest();
throw e;
services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java:747
- Every mTLS token, user-info, refresh, or introspection call builds a new pooling HTTP client. With the default idle-eviction setting, each build starts a cleaner thread, reloads configured TLS material, and prevents connection reuse, so broker traffic can create substantial thread and allocation churn; reuse a dedicated client for the session/IdP and close it at session completion, or build a non-pooling client without an evictor.
HttpClientProvider httpClientProvider = session.getProvider(HttpClientProvider.class);
return SimpleHttp.create(buildMtlsHttpClient(httpClientProvider), CLOSE_CLIENT)
.withMaxConsumedResponseSize(httpClientProvider.getMaxConsumedResponseSize());
services/src/main/java/org/keycloak/connections/httpclient/DefaultHttpClientFactory.java:275
configureBuildernow runs for every dedicated mTLS client, so this warning—previously emitted once during lazy initialization—is logged on every backchannel request. A deployment using JVM-default trust will flood WARN logs under broker traffic; keep this diagnostic in the one-time initialization path or otherwise emit it once.
This issue also appears on line 285 of the same file.
if (disableTruststoreProvider) {
logger.warn("TruststoreProvider is disabled");
Closes #38310
Disclaimer: Generative AI was used to support the creation of this PR, but only to support the work.
Thanks @newtork for your review, changes and support