diff --git a/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java index 5d6c6586143..b9bcbbe904b 100755 --- a/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/IdentityProviderRepresentation.java @@ -16,7 +16,9 @@ */ package org.keycloak.representations.idm; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -59,6 +61,7 @@ public class IdentityProviderRepresentation { protected String postBrokerLoginFlowAlias; protected String organizationId; protected Map config = new HashMap<>(); + protected List types = new ArrayList<>(); public String getInternalId() { return this.internalId; @@ -212,4 +215,11 @@ public class IdentityProviderRepresentation { this.organizationId = organizationId; } + public List getTypes() { + return this.types; + } + public void setTypes(List types) { + this.types = types; + } + } 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 93063058414..72cfcd7668b 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 @@ -895,6 +895,11 @@ groupNameLdapAttributeHelp=Name of LDAP attribute that is used in group objects deleteError=Could not delete the provider {{error}} attributeDisplayName=Display name pkceEnabled=Use PKCE +authorizationGrantSettings=Authorization Grant Settings +jwtAuthorizationGrantIdpEnabled=JWT Authorization Grant +jwtAuthorizationGrantIdpEnabledHelp=Enable the identity provider to act as a trust provider to validate authorization grant JWT assertions according to RFC 7523. +jwtAuthorizationGrantAssertionReuseAllowed=Allow assertion reuse +jwtAuthorizationGrantAssertionReuseAllowedHelp=If enabled, the jti claim is not required and assertions can be reused. userProviderSaveSuccess=User federation provider successfully saved month=Month valueLabel=Value diff --git a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx index ad4bd65930e..6078ad20ba7 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx @@ -1,8 +1,12 @@ import type IdentityProviderMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderMapperRepresentation"; -import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import IdentityProviderRepresentation, { + IdentityProviderType, +} from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; import { Action, KeycloakDataTable, + KeycloakSpinner, + ListEmptyState, ScrollForm, useAlerts, useFetch, @@ -34,8 +38,6 @@ import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog" import { DynamicComponents } from "../../components/dynamic/DynamicComponents"; import { FixedButtonsGroup } from "../../components/form/FixedButtonGroup"; import { FormAccess } from "../../components/form/FormAccess"; -import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared"; -import { ListEmptyState } from "@keycloak/keycloak-ui-shared"; import { PermissionsTab } from "../../components/permission-tab/PermissionTab"; import { RoutableTabs, @@ -70,6 +72,7 @@ import { SpiffeSettings } from "./SpiffeSettings"; import { AdminEvents } from "../../events/AdminEvents"; import { UserProfileClaimsSettings } from "./OAuth2UserProfileClaimsSettings"; import { KubernetesSettings } from "./KubernetesSettings"; +import { JwtAuthorizationGrantSettings } from "./JwtAuthorizationGrantSettings"; type HeaderProps = { onChange: (value: boolean) => void; @@ -417,6 +420,10 @@ export default function DetailSettings() { const isSPIFFE = provider.providerId!.includes("spiffe"); const isKubernetes = provider.providerId!.includes("kubernetes"); const isSocial = !isOIDC && !isSAML && !isOAuth2; + const isJwtAuthorizationGrantSupported = + (isOAuth2 || isOIDC) && + !!provider?.types?.includes(IdentityProviderType.JWT_AUTHORIZATION_GRANT) && + isFeatureEnabled(Feature.JWTAuthorizationGrant); const loader = async () => { const [loaderMappers, loaderMapperTypes] = await Promise.all([ @@ -491,6 +498,19 @@ export default function DetailSettings() { ), }, + { + title: t("authorizationGrantSettings"), + isHidden: !isJwtAuthorizationGrantSupported, + panel: ( +
+ + + ), + }, { title: t("generalSettings"), isHidden: !isSPIFFE, diff --git a/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx new file mode 100644 index 00000000000..691ae107112 --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/JwtAuthorizationGrantSettings.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import { DefaultSwitchControl } from "../../components/SwitchControl"; +import { Divider } from "@patternfly/react-core"; +import { useWatch, useFormContext } from "react-hook-form"; + +export const JwtAuthorizationGrantSettings = () => { + const { t } = useTranslation(); + const { control } = useFormContext(); + const authorizationGrantEnabled = useWatch({ + control, + name: "config.jwtAuthorizationGrantEnabled", + }); + return ( + <> + + {authorizationGrantEnabled === "true" && ( + + )} + + + ); +}; diff --git a/js/libs/keycloak-admin-client/src/defs/identityProviderRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/identityProviderRepresentation.ts index cef88107f4f..9a2fc258838 100644 --- a/js/libs/keycloak-admin-client/src/defs/identityProviderRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/identityProviderRepresentation.ts @@ -2,6 +2,14 @@ * https://www.keycloak.org/docs-api/11.0/rest-api/index.html#_identityproviderrepresentation */ +export enum IdentityProviderType { + ANY = "ANY", + USER_AUTHENTICATION = "USER_AUTHENTICATION", + CLIENT_ASSERTION = "CLIENT_ASSERTION", + EXCHANGE_EXTERNAL_TOKEN = "EXCHANGE_EXTERNAL_TOKEN", + JWT_AUTHORIZATION_GRANT = "JWT_AUTHORIZATION_GRANT", +} + export default interface IdentityProviderRepresentation { addReadTokenRoleOnCreate?: boolean; alias?: string; @@ -17,4 +25,5 @@ export default interface IdentityProviderRepresentation { storeToken?: boolean; trustEmail?: boolean; organizationId?: string; + types?: string[]; } diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java index fdc791b154e..89da73a80f1 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -22,4 +22,7 @@ public interface JWTAuthorizationGrantProvider { BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException; int getAllowedClockSkew(); + + boolean isAssertionReuseAllowed(); + } diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java index 5a663dc6d60..0993becb01b 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java @@ -24,6 +24,18 @@ public class IdentityProviderTypeUtil { private IdentityProviderTypeUtil() { } + public static List listTypesFromFactory(KeycloakSession session, String factoryId) { + KeycloakSessionFactory sf = session.getKeycloakSessionFactory(); + ProviderFactory factory = sf.getProviderFactory(IdentityProvider.class, factoryId); + if (factory == null) { + return List.of(); + } + Class providerType = getType(factory); + return Arrays.stream(IdentityProviderType.values()) + .filter(t -> !t.equals(IdentityProviderType.ANY) && toTypeClass(t).isAssignableFrom(providerType)) + .collect(Collectors.toList()); + } + public static List listFactoriesByCapability(KeycloakSession session, IdentityProviderCapability capability) { Set types = Arrays.stream(IdentityProviderType.values()).filter(t -> t.getCapabilities().contains(capability)).collect(Collectors.toSet()); return listFactoriesByTypes(session, types); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 1fce9cb78e8..ef73e75b1a2 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -35,6 +35,7 @@ import org.keycloak.authorization.model.Scope; import org.keycloak.authorization.policy.provider.PolicyProviderFactory; import org.keycloak.authorization.store.PolicyStore; import org.keycloak.authorization.store.StoreFactory; +import org.keycloak.broker.provider.util.IdentityProviderTypeUtil; import org.keycloak.common.Profile; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.Time; @@ -562,7 +563,7 @@ public class ModelToRepresentation { if (export) { List identityProviders = session.identityProviders().getAllStream(IdentityProviderQuery.any()) - .map(provider -> toRepresentation(realm, provider, export)).collect(Collectors.toList()); + .map(provider -> toRepresentation(session, realm, provider, export)).collect(Collectors.toList()); rep.setIdentityProviders(identityProviders); List identityProviderMappers = session.identityProviders().getMappersStream() .map(ModelToRepresentation::toRepresentation).collect(Collectors.toList()); @@ -873,11 +874,11 @@ public class ModelToRepresentation { return providerRep; } - public static IdentityProviderRepresentation toRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel) { - return toRepresentation(realm, identityProviderModel, false); + public static IdentityProviderRepresentation toRepresentation(KeycloakSession session, RealmModel realm, IdentityProviderModel identityProviderModel) { + return toRepresentation(session, realm, identityProviderModel, false); } - public static IdentityProviderRepresentation toRepresentation(RealmModel realm, IdentityProviderModel identityProviderModel, boolean export) { + public static IdentityProviderRepresentation toRepresentation(KeycloakSession session, RealmModel realm, IdentityProviderModel identityProviderModel, boolean export) { IdentityProviderRepresentation providerRep = toBriefRepresentation(realm, identityProviderModel); providerRep.setLinkOnly(identityProviderModel.isLinkOnly()); @@ -916,6 +917,8 @@ public class ModelToRepresentation { providerRep.setOrganizationId(identityProviderModel.getOrganizationId()); } + List identityProviderTypes = IdentityProviderTypeUtil.listTypesFromFactory(session, identityProviderModel.getProviderId()); + providerRep.setTypes(identityProviderTypes.stream().map(Enum::toString).collect(Collectors.toList())); return providerRep; } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java index 7a84de55faa..f85747cd52e 100644 --- a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java @@ -41,6 +41,10 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel { public static final String REQUIRES_SHORT_STATE_PARAMETER = "requiresShortStateParameter"; + public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "jwtAuthorizationGrantEnabled"; + + public static final String JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED = "jwtAuthorizationGrantAssertionReuseAllowed"; + public OAuth2IdentityProviderConfig(IdentityProviderModel model) { super(model); } @@ -161,6 +165,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel { return Boolean.parseBoolean(getConfig().getOrDefault(PKCE_ENABLED, "false")); } + public boolean getJwtAuthorizationGrantEnabled() { + return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ENABLED, "false")); + } + + public boolean getJwtAuthorizationGrantAssertionReuseAllowed() { + return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "false")); + } + public void setPkceEnabled(boolean enabled) { getConfig().put(PKCE_ENABLED, String.valueOf(enabled)); } 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 8367f83687a..136152ad8bb 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -1070,6 +1070,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider toRepresentation = Optional.ofNullable(briefRepresentation).orElse(false) ? m -> ModelToRepresentation.toBriefRepresentation(realm, m) - : m -> StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(realm, m)); + : m -> StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(session, realm, m)); boolean searchRealmOnlyIDPs = Optional.ofNullable(realmOnly).orElse(false); diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java index 127a558ab1f..706453befe6 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java @@ -79,6 +79,17 @@ public class JWTAuthorizationGrantTest { oAuthClient.client("test-app", "test-secret"); } + @Test + public void testNotAllowedIdentityProvider() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, "false"); + }); + + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("JWT Authorization Granted is not enabled for the identity provider", response, events.poll()); + } + @Test public void testNotAllowedClient() { String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); @@ -152,6 +163,17 @@ public class JWTAuthorizationGrantTest { response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); assertFailure("Token reuse detected", response, events.poll()); + + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ASSERTION_REUSE_ALLOWED, "true"); + }); + + jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); } @Test @@ -229,6 +251,7 @@ public class JWTAuthorizationGrantTest { .setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, Boolean.TRUE.toString()) .setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks") .setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.TRUE.toString()) + .setAttribute(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_ENABLED, Boolean.TRUE.toString()) .build()); return realm; }