Allow client_id as an audience in the JWT Authorization Grant and Client Assertions

Closes #45178

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2026-01-14 13:36:47 +01:00 committed by Marek Posolda
parent fccd46f7ba
commit 07b9b9656b
9 changed files with 102 additions and 12 deletions

View file

@ -91,6 +91,9 @@ to forward are any non OpenID Connect/OAuth standard parameter or standard param
|Allows client assertions to be re-used
|By default, a client assertion can not be used multiple times. If the client is not able to retrieve a new client assertion for each request this option can be enabled to allow re-use of the same client assertion.
|Allows Client ID as audience for assertions
|If enabled, the Client ID configured in the Identity Provider is the only valid audience for assertions used in Federated client authentication and in JWT Authorization Grants (Client Assertions and JWT Authorization Grant). The client ID is used instead of the token-url/issuer-url defined in the respective specifications. Note this behavior is not covered by any standard.
|===
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.

View file

@ -60,6 +60,8 @@ The Identity Provider (both types commented in the introduction) needs to also b
* **Allowed clock skew**: Clock skew in seconds that is tolerated when validating identity provider tokens. Default value is zero.
* **Limit access token expiration**: If enabled the access token lifespan will be limited to the expiration of the JWT assertion but only if the JWT assertion expiration is less than the calculated access token expiration.
NOTE: The OpenID Connect Identity Provider types have one extra configuration switch **Allows Client ID as audience for assertions**, placed in the **Advance settings** section. This option, when enabled, sets the the Client ID of the provider configuration as the only valid audience for assertions used in Federated client authentication and in JWT Authorization Grants. The client ID is used instead of the token-url/issuer-url defined in the respective specifications. This behavior is not covered by any standard.
.OIDC Identity Provider configuration for JWT Authorization Grant
image::jwt-authorization-grant-oidc-provider-config.png[Identity Provider configuration for JWT Authorization Grant]

View file

@ -3287,6 +3287,8 @@ caseSensitiveOriginalUsername=Case-sensitive username
caseSensitiveOriginalUsernameHelp=If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case.
supportsClientAssertions=Supports client assertions
supportsClientAssertionReuse=Allows client assertions to be re-used
allowClientIdAsAudience=Allows Client ID as audience for assertions
allowClientIdAsAudienceHelp=If enabled, the Client ID configured in the Identity Provider is the only valid audience for assertions used in Federated client authentication and in JWT Authorization Grants (Client Assertions and JWT Authorization Grant). The client ID is used instead of the token-url/issuer-url defined in the respective specifications. Note this behavior is not covered by any standard.
organizationsExplain=Manage your organizations and members.
emptyOrganizations=No organizations
emptyOrganizationsInstructions=There is no organization yet. Please create an organization and manage it.

View file

@ -125,12 +125,21 @@ export const AdvancedSettings = ({
const isClientAuthFederatedEnabled = isFeatureEnabled(
Feature.ClientAuthFederated,
);
const jwtAuthorizationGrant = isFeatureEnabled(Feature.JWTAuthorizationGrant);
const transientUsers = useWatch({
control,
name: "config.doNotStoreUsers",
defaultValue: "false",
});
const syncModeAvailable = transientUsers === "false";
const jwtAuthorizationGrantEnabled = useWatch({
control,
name: "config.jwtAuthorizationGrantEnabled",
});
const supportsClientAssertions = useWatch({
control,
name: "config.supportsClientAssertions",
});
return (
<>
{!isOIDC && !isSAML && !isOAuth2 && (
@ -315,17 +324,29 @@ export const AdvancedSettings = ({
label="caseSensitiveOriginalUsername"
/>
{isClientAuthFederatedEnabled && isOIDC && (
<>
<SwitchField
field="config.supportsClientAssertions"
label="supportsClientAssertions"
/>
<SwitchField
field="config.supportsClientAssertions"
label="supportsClientAssertions"
/>
)}
{isClientAuthFederatedEnabled &&
isOIDC &&
supportsClientAssertions === "true" && (
<SwitchField
field="config.supportsClientAssertionReuse"
label="supportsClientAssertionReuse"
/>
</>
)}
)}
{isOIDC &&
((isClientAuthFederatedEnabled &&
supportsClientAssertions === "true") ||
(jwtAuthorizationGrant &&
jwtAuthorizationGrantEnabled === "true")) && (
<SwitchField
field="config.allowClientIdAsAudience"
label="allowClientIdAsAudience"
/>
)}
</>
);
};

View file

@ -12,12 +12,15 @@ public class FederatedJWTClientValidator extends AbstractJWTClientValidator {
private final int allowedClockSkew;
private final boolean reusePermitted;
private int maximumExpirationTime = 300;
private final List<String> validAudiences;
public FederatedJWTClientValidator(ClientAuthenticationFlowContext context, SignatureValidator signatureValidator, String expectedTokenIssuer, int allowedClockSkew, boolean reusePermitted) throws Exception {
public FederatedJWTClientValidator(ClientAuthenticationFlowContext context, SignatureValidator signatureValidator,
String expectedTokenIssuer, int allowedClockSkew, boolean reusePermitted, String... validAudiences) throws Exception {
super(context, signatureValidator, null);
this.expectedTokenIssuer = expectedTokenIssuer;
this.allowedClockSkew = allowedClockSkew;
this.reusePermitted = reusePermitted;
this.validAudiences = validAudiences == null ? Collections.emptyList() : List.of(validAudiences);
}
@Override
@ -27,7 +30,9 @@ public class FederatedJWTClientValidator extends AbstractJWTClientValidator {
@Override
protected List<String> getExpectedAudiences() {
return Collections.singletonList(Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName()));
return validAudiences.isEmpty()
? List.of(Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName()))
: validAudiences;
}
@Override

View file

@ -1062,8 +1062,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public boolean verifyClientAssertion(ClientAuthenticationFlowContext context) throws Exception {
OIDCIdentityProviderConfig config = getConfig();
FederatedJWTClientValidator validator = new FederatedJWTClientValidator(context, v -> verifySignature(v.getJws()),
config.getIssuer(), config.getAllowedClockSkew(), config.isSupportsClientAssertionReuse());
FederatedJWTClientValidator validator = config.isAllowClientIdAsAudience() && config.getClientId() != null
? new FederatedJWTClientValidator(context, v -> verifySignature(v.getJws()), config.getIssuer(),
config.getAllowedClockSkew(), config.isSupportsClientAssertionReuse(), config.getClientId())
: new FederatedJWTClientValidator(context, v -> verifySignature(v.getJws()), config.getIssuer(),
config.getAllowedClockSkew(), config.isSupportsClientAssertionReuse());
if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_FEDERATED)) {
return false;
@ -1104,7 +1107,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
@Override
public List<String> getAllowedAudienceForJWTGrant() {
return new JWTAuthorizationGrantIdentityProvider(session, getConfig()).getAllowedAudienceForJWTGrant();
return getConfig().isAllowClientIdAsAudience() && getConfig().getClientId() != null
? List.of(getConfig().getClientId())
: new JWTAuthorizationGrantIdentityProvider(session, getConfig()).getAllowedAudienceForJWTGrant();
}
@Override

View file

@ -35,6 +35,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public static final String SUPPORTS_CLIENT_ASSERTIONS = "supportsClientAssertions";
public static final String SUPPORTS_CLIENT_ASSERTION_REUSE = "supportsClientAssertionReuse";
public static final String ALLOW_CLIENT_ID_AS_AUDIENCE = "allowClientIdAsAudience";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
@ -153,6 +154,14 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp
return Boolean.parseBoolean(getConfig().get(SUPPORTS_CLIENT_ASSERTION_REUSE));
}
public boolean isAllowClientIdAsAudience() {
return Boolean.parseBoolean(getConfig().getOrDefault(ALLOW_CLIENT_ID_AS_AUDIENCE, "false"));
}
public void setAllowClientIdAsAudience(boolean allowClientIdAsAudience) {
getConfig().put(ALLOW_CLIENT_ID_AS_AUDIENCE, String.valueOf(allowClientIdAsAudience));
}
@Override
public void validate(RealmModel realm) {
super.validate(realm);

View file

@ -96,11 +96,33 @@ public class BaseClientAuthTest extends AbstractBaseClientAuthTest {
assertFailure(null, TOKEN_ISSUER, EXTERNAL_CLIENT_ID, jwt.getId(), "client_not_found", events.poll());
}
@Test
public void testClientIdAllowedAsAudience() {
JsonWebToken jwt = createDefaultToken();
jwt.audience("test-client");
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(INTERNAL_CLIENT_ID, TOKEN_ISSUER, EXTERNAL_CLIENT_ID, jwt.getId(), events.poll());
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(OIDCIdentityProviderConfig.ALLOW_CLIENT_ID_AS_AUDIENCE, Boolean.TRUE.toString());
});
jwt = createDefaultToken();
jwt.audience("test-client");
assertSuccess(INTERNAL_CLIENT_ID, doClientGrant(jwt));
assertSuccess(INTERNAL_CLIENT_ID, jwt.getId(), TOKEN_ISSUER, EXTERNAL_CLIENT_ID, events.poll());
jwt = createDefaultToken();
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(INTERNAL_CLIENT_ID, TOKEN_ISSUER, EXTERNAL_CLIENT_ID, jwt.getId(), events.poll());
}
@Override
protected OAuthIdentityProvider getIdentityProvider() {
return identityProvider;
}
@Override
protected JsonWebToken createDefaultToken() {
JsonWebToken token = new JsonWebToken();
token.id(UUID.randomUUID().toString());
@ -120,6 +142,7 @@ public class BaseClientAuthTest extends AbstractBaseClientAuthTest {
IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute("clientId", "test-client")
.setAttribute("issuer", "http://127.0.0.1:8500")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")

View file

@ -52,6 +52,25 @@ public class OIDCIdentityProviderJWTAuthorizationGrantTest extends AbstractJWTAu
assertSuccess("test-app", response);
}
@Test
public void testClientIdAllowedAsAudience() {
String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "test-client", IDP_ISSUER));
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Invalid token audience", response, events.poll());
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(OIDCIdentityProviderConfig.ALLOW_CLIENT_ID_AS_AUDIENCE, Boolean.TRUE.toString());
});
jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "test-client", IDP_ISSUER));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", response);
jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken());
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Invalid token audience", response, events.poll());
}
public static class JWTAuthorizationGrantRealmConfig extends AbstractJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig {
@Override
@ -61,6 +80,7 @@ public class OIDCIdentityProviderJWTAuthorizationGrantTest extends AbstractJWTAu
IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute("clientId", "test-client")
.setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER)
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, Boolean.TRUE.toString())
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")