FFFF Align JwtClient authentication with latest OIDC spec updates by thomasdarimont · Pull Request #38754 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Closed
11 changes: 9 additions & 2 deletions docs/documentation/release_notes/topics/26_2_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ For information on how to upgrade from the legacy token exchange used in previou

= Fine-grained admin permissions supported

This release introduces support for a new version of fine-grained admin permissions. Version 2 (V2) provides enhanced flexibility and control over administrative access within realms.
This release introduces support for a new version of fine-grained admin permissions. Version 2 (V2) provides enhanced flexibility and control over administrative access within realms.
With this feature, administrators can define permissions for administering users, groups, clients, and roles without relying on broad administrative roles. V2 offers the same level of access control over realm resources as the previous version, with plans to extend its capabilities in future versions. Some key points follow:

* *Centralized Admin Console Management* - New *Permissions* section was introduced to allow management from a single place without having to navigate to different places in the Admin Console.
* *Centralized Admin Console Management* - New *Permissions* section was introduced to allow management from a single place without having to navigate to different places in the Admin Console.
* *Improved manageability* - Administrators can more easily search and evaluate permissions when building a permission model for realm resources.
* *Resource-Specific and Global Permissions* – Permissions can be defined for individual resources (such as specific users or groups), or entire resource types (such as all users or all groups).
* *Explicit Operation Scoping* – Permissions are now independent, removing hidden dependencies between operations. Administrators must assign each scope explicitly, making it easier to see what is granted without needing prior knowledge of implicit relationships.
Expand Down Expand Up @@ -96,6 +96,13 @@ For more information, check the link:https://www.keycloak.org/server/management-
Introduced the ability to dynamically select authentication flows based on conditions such as requested scopes, ACR (Authentication Context Class Reference) and others.
This can be achieved using link:{adminguide_link}#_client_policies[Client Policies] by combining the new `AuthenticationFlowSelectorExecutor` with conditions like the new `ACRCondition`. For more details, see the link:{adminguide_link}#_client-policy-auth-flow[{adminguide_name}].

= JWT Client authentication aligned with the latest OIDC specification

The latest version of the link:https://openid.net/specs/openid-connect-core-1_0-36.html#rfc.section.9[OpenID Connect Core Specification] tightened the rules for
audience validation in JWT client assertions for the Client Authentication methods `private_key_jwt` and `client_secret_jwt` which {project_name} now applies by default.

For information on the changed audience validation in JWT Client authentication {project_name} versions, see the link:{upgradingguide_link}[{upgradingguide_name}].

= Federated credentials are available now when fetching user credentials

Until now, querying user credentials using the User API will not return credentials managed by user storage providers and, as a consequence,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ image:images/oidc-add-identity-provider.png[Add Identity Provider]
In the case of JWT signed with private key or Client secret as jwt, it is required. If no algorithm is specified, the following algorithm is adapted. `RS256` is adapted in the case of JWT signed with private key. `HS256` is adapted in the case of Client secret as jwt.

|Client Assertion Audience
|The audience to use for the client assertion. The default value is the IDP's token endpoint URL.
|The audience to use for the client assertion. The default value is the IDP's issuer URL.

|Issuer
|{project_name} validates issuer claims, in responses from the IDP, against this value.
Expand Down Expand Up @@ -82,4 +82,4 @@ If the user is unauthenticated in the IDP, the client still receives a `login_re

You can import all this configuration data by providing a URL or file that points to OpenID Provider Metadata. If you connect to a {project_name} external IDP, you can import the IDP settings from `<root>{kc_realms_path}/{realm-name}/.well-known/openid-configuration`. This link is a JSON document describing metadata about the IDP.

If you want to use https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] ID Tokens or UserInfo responses in the provider, the IDP needs to know the public key to use with {project_name}. The provider uses the <<realm_keys, realm keys>> defined for the different encryption algorithms to decrypt the tokens. {project_name} provides a standard xref:con-server-oidc-uri-endpoints_{context}[JWKS endpoint] which the IDP can use for downloading the keys automatically.
If you want to use https://datatracker.ietf.org/doc/html/rfc7516[Json Web Encryption (JWE)] ID Tokens or UserInfo responses in the provider, the IDP needs to know the public key to use with {project_name}. The provider uses the <<realm_keys, realm keys>> defined for the different encryption algorithms to decrypt the tokens. {project_name} provides a standard xref:con-server-oidc-uri-endpoints_{context}[JWKS endpoint] which the IDP can use for downloading the keys automatically.
29 changes: 29 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_2_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@ the `X-Forwarded-Port` header with the desired port.
The required JAR for the Oracle JDBC driver that needs to be explicitly added to the distribution has changed.
Instead of providing `ojdbc11` JAR, use `ojdbc17` JAR as stated in the https://www.keycloak.org/server/db#_installing_the_oracle_database_driver[Installing the Oracle Database driver] guide.

=== JWT Client authentication aligned with the latest OIDC specification

The latest version of the link:https://openid.net/specs/openid-connect-core-1_0-36.html#rfc.section.9[OpenID Connect core specification] changed the rules for
audience validation in JWT client assertions for the Client Authentication methods `private_key_jwt` and `client_secret_jwt`.

Previously, the `aud` claim of a JWT client assertion was loosely defined as `The Audience SHOULD be the URL of the Authorization Server's Token Endpoint`, which did not exclude the usage of other URLs.

The revised OIDC Core specification uses a stricter audience check: "The Audience value MUST be the OP's Issuer Identifier passed as a string, and not a single-element array.".

We adapted the implementations of `JWTClientAuthenticator` and `JWTClientSecretAuthenticator` to follow the new audience check rules.
By default, we now require the issuer URL to be the sole value of the audience.

This strict audience check can be reverted to the previous more lenient check with a new 'Lenient Audience Validation'
configuration in the respective client authentication flows. Additionally, server administrators can use two new SPI configuration options to configure the default audience validation behavior.

The following SPI configuration enables `lenient-audience-validation` by default:

** `--spi-client-authenticator-client-jwt-lenient-audience-validation=true`
** `--spi-client-authenticator-client-secret-jwt-lenient-audience-validation=true`

=== New Audience in JWT Client Assertion for OIDC Identity Providers with JWT based client authentication

Related to the change in OIDC JWT Client Authentication alignment, we now use the OIDC Identity Provider `issuer` URL by default
as the audience of the generated JWT Client Assertion when the client authentication methods `JWT signed with private key` or `JWT signed with client secret` is used.

Previously, the audience in the JWT client assertion was set to the token endpoint URL of the OIDC Identity Provider.

Users who want to use the old behavior can explicitly set the audience to the token endpoint URL via the `Client assertion audience` configuration property of the OIDC Identity Provider.

== Notable changes

Notable changes where an internal behavior changed to prevent common misconfigurations, fix bugs or simplify running {project_name}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,7 @@ Monday=Monday
resetCredentialsSuccess=The password has been reset successfully.
added=Added
authnContextDeclRefs=AuthnContext DeclRefs
clientAssertionAudienceHelp=The audience to use for the client assertion. The default value is the IDP's token endpoint URL.
clientAssertionAudienceHelp=The audience to use for the client assertion. The default value is the IDP's issuer endpoint URL.
externalRoleToRole=Looks for an external role in a keycloak access token. If external role exists, grant the user the specified realm or client role.
attributeGroup=Attribute group
deleteExecutionError=Could not delete execution\: {{error}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.authentication.authenticators.client;

import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.Urls;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

public abstract class AbstractJwtClientAuthenticator extends AbstractClientAuthenticator {

private static final Logger LOG = Logger.getLogger(AbstractJwtClientAuthenticator.class);

public static final String LENIENT_AUDIENCE_VALIDATION_PROPERTY = "lenient-audience-validation";

public static final boolean LENIENT_AUDIENCE_VALIDATION_DEFAULT_VALUE = false;

protected boolean useLenientAudienceValidation;

@Override
public boolean isConfigurable() {
return true;
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty allowAudienceIssuerOnly = new ProviderConfigProperty();
allowAudienceIssuerOnly.setName(LENIENT_AUDIENCE_VALIDATION_PROPERTY);
allowAudienceIssuerOnly.setLabel("Lenient Audience Validation");
allowAudienceIssuerOnly.setHelpText("If 'off' requires the audience (aud) claim in private_key_jwt client assertions to be only the OIDC Issuer URL. If this is 'on' the Issuer, Token, Introspection, and PAR endpoint URLs are allowed as audience for backwards compatibility.");
allowAudienceIssuerOnly.setType(ProviderConfigProperty.BOOLEAN_TYPE);
allowAudienceIssuerOnly.setDefaultValue(useLenientAudienceValidation);
return List.of(allowAudienceIssuerOnly);
}

protected boolean isIssuerOnlyAudienceRequired(ClientAuthenticationFlowContext context) {

AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig();
if (authenticatorConfig == null) {
return !isUseLenientAudienceValidation();
}

Map<String, String> config = authenticatorConfig.getConfig();
if (config == null) {
return !isUseLenientAudienceValidation();
}

return "false".equals(config.getOrDefault(LENIENT_AUDIENCE_VALIDATION_PROPERTY, Boolean.toString(isUseLenientAudienceValidation())));
}

protected void validateTokenAudience(ClientAuthenticationFlowContext context, RealmModel realm, JsonWebToken token) {
List<String> expectedAudiences = getExpectedAudiences(context, realm);
if (!token.hasAnyAudience(expectedAudiences)) {
throw new RuntimeException("Token audience doesn't match domain. Expected audiences are any of " + expectedAudiences
+ " but audience from token is '" + Arrays.asList(token.getAudience()) + "'");
}
}

protected List<String> getExpectedAudiences(ClientAuthenticationFlowContext context, RealmModel realm) {

String issuerUrl = Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName());
if (isIssuerOnlyAudienceRequired(context)) {
return List.of(issuerUrl);
}

String tokenUrl = OIDCLoginProtocolService.tokenUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
String tokenIntrospectUrl = OIDCLoginProtocolService.tokenIntrospectionUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
String parEndpointUrl = ParEndpoint.parUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
List<String> expectedAudiences = new ArrayList<>(Arrays.asList(issuerUrl, tokenUrl, tokenIntrospectUrl, parEndpointUrl));
String backchannelAuthenticationUrl = CibaGrantType.authorizationUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
expectedAudiences.add(backchannelAuthenticationUrl);

return expectedAudiences;
}

@Override
public void init(Config.Scope config) {
super.init(config);

// This allows to override the server-wide default for JWT client assertion based audience validation in client authentication, via command line options.
// to explicitly use the lenient audience validation, one can use:
// --spi-client-authenticator-client-jwt-lenient-audience-validation=true
// --spi-client-authenticator-client-secret-jwt-lenient-audience-validation=true
// if nothing is configured, we use the secure default which restricts the audience to be the issuer only audience.
this.useLenientAudienceValidation = config.getBoolean("lenient-audience-validation", LENIENT_AUDIENCE_VALIDATION_DEFAULT_VALUE);

if (useLenientAudienceValidation) {
LOG.warnf("The client authentication '%s' with JWT based client assertions is configured as 'lenient' to allow a broader audience for backward compatibility (issuerUrl, tokenUrl, tokenIntrospectUrl, parEndpointUrl, backchannelAuthenticationUrl). It is recommended that the JWT client authentication configurations be adjusted to allow only the realm issuer in the audience.", getId());
}
}

public boolean isUseLenientAudienceValidation() {
return useLenientAudienceValidation;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,7 @@
package org.keycloak.authentication.authenticators.client;


import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import jakarta.ws.rs.core.Response;

import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
Expand All @@ -42,13 +30,17 @@
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;

import java.security.PublicKey;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.keycloak.models.TokenManager.DEFAULT_VALIDATOR;

Expand All @@ -61,7 +53,7 @@
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class JWTClientAuthenticator extends AbstractClientAuthenticator {
public class JWTClientAuthenticator extends AbstractJwtClientAuthenticator {

public static final String PROVIDER_ID = "client-jwt";
public static final String ATTR_PREFIX = "jwt.credential";
Expand Down Expand Up @@ -113,13 +105,7 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
throw new RuntimeException("Signature on JWT token failed validation");
}

// Allow both "issuer" or "token-endpoint" as audience
List<String> expectedAudiences = getExpectedAudiences(context, realm);

if (!token.hasAnyAudience(expectedAudiences)) {
throw new RuntimeException("Token audience doesn't match domain. Expected audiences are any of " + expectedAudiences
+ " but audience from token is '" + Arrays.asList(token.getAudience()) + "'");
}
validateTokenAudience(context, realm, token);

validator.validateToken();
validator.validateTokenReuse();
Expand Down Expand Up @@ -148,11 +134,6 @@ public String getDisplayType() {
return "Signed Jwt";
}

@Override
public boolean isConfigurable() {
return false;
}

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
Expand All @@ -163,11 +144,6 @@ public String getHelpText() {
return "Validates client based on signed JWT issued by client and signed with the Client private key";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return new LinkedList<>();
}

@Override
public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
// This impl doesn't use generic screen in admin console, but has its own screen. So no need to return anything here
Expand Down Expand Up @@ -208,16 +184,4 @@ public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
return Collections.emptySet();
}
}

private List<String> getExpectedAudiences(ClientAuthenticationFlowContext context, RealmModel realm) {
String issuerUrl = Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName());
String tokenUrl = OIDCLoginProtocolService.tokenUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
String tokenIntrospectUrl = OIDCLoginProtocolService.tokenIntrospectionUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
String parEndpointUrl = ParEndpoint.parUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
List<String> expectedAudiences = new ArrayList<>(Arrays.asList(issuerUrl, tokenUrl, tokenIntrospectUrl, parEndpointUrl));
String backchannelAuthenticationUrl = CibaGrantType.authorizationUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString();
expectedAudiences.add(backchannelAuthenticationUrl);

return expectedAudiences;
}
}
Loading
0