diff --git a/docs/documentation/server_admin/topics/identity-broker/oidc.adoc b/docs/documentation/server_admin/topics/identity-broker/oidc.adoc index 95d7c7f8c5f..8a025998fbf 100644 --- a/docs/documentation/server_admin/topics/identity-broker/oidc.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/oidc.adoc @@ -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 `{kc_realms_path}/{realm-name}/.well-known/openid-configuration`. This link is a JSON document describing metadata about the IDP. diff --git a/docs/guides/securing-apps/jwt-authorization-grant.adoc b/docs/guides/securing-apps/jwt-authorization-grant.adoc index 8856fe67336..fb6082cdb8a 100644 --- a/docs/guides/securing-apps/jwt-authorization-grant.adoc +++ b/docs/guides/securing-apps/jwt-authorization-grant.adoc @@ -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] diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index b17ddf6d052..cdda458bf7b 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -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. diff --git a/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx index 23e4ac55175..ab3fe94b784 100644 --- a/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx @@ -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 && ( - <> - + + )} + {isClientAuthFederatedEnabled && + isOIDC && + supportsClientAssertions === "true" && ( - - )} + )} + {isOIDC && + ((isClientAuthFederatedEnabled && + supportsClientAssertions === "true") || + (jwtAuthorizationGrant && + jwtAuthorizationGrantEnabled === "true")) && ( + + )} ); }; diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java index f0b897faa6b..844d0d9f1f6 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientValidator.java @@ -12,12 +12,15 @@ public class FederatedJWTClientValidator extends AbstractJWTClientValidator { private final int allowedClockSkew; private final boolean reusePermitted; private int maximumExpirationTime = 300; + private final List 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 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 diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index cd5e355aed6..06ba47ae9dc 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -1062,8 +1062,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider 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 getAllowedAudienceForJWTGrant() { - return new JWTAuthorizationGrantIdentityProvider(session, getConfig()).getAllowedAudienceForJWTGrant(); + return getConfig().isAllowClientIdAsAudience() && getConfig().getClientId() != null + ? List.of(getConfig().getClientId()) + : new JWTAuthorizationGrantIdentityProvider(session, getConfig()).getAllowedAudienceForJWTGrant(); } @Override diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java index 40c5ff84f01..42f7ed0aa78 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java @@ -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); diff --git a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/BaseClientAuthTest.java b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/BaseClientAuthTest.java index d50f46081a9..3fb29d27d7e 100644 --- a/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/BaseClientAuthTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/client/authentication/external/BaseClientAuthTest.java @@ -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") diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java index ed6e97cb8eb..b0bb51746bd 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OIDCIdentityProviderJWTAuthorizationGrantTest.java @@ -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")