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 8a002d7e45d..e2e089f709b 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 @@ -461,6 +461,8 @@ detailsHelp=This is information about the details. adminEvents=Admin events serviceAccountHelp=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client. standardTokenExchangeEnabledHelp=Enable Standard Token Exchange V2 for this client. +jwtAuthorizationGrantEnabledHelp=Enable JSON Web Token (JWT) Profile for OAuth 2.0 Authorization Grant. +jwtAuthorizationGrantIdpHelp=Select Allowed Identity Providers that can be used to for JWT authorization grant validation enableRefreshRequestedTokenTypeHelp=Controls if the Standard Token Exchange V2 allows to request a refresh token (parameter "requested_token_type" set to value "urn:ietf:params:oauth:token-type:refresh_token"). If this option is "No" (default), the refresh token request type is never allowed and an error is returned. If this option is "Same session", the returned refresh token is enforced to use the same session as the subject token, returning an error if that session is not available (for example if the subject token is transient). sameSession=Same session urisHelp=Set of URIs which are protected by resource. @@ -1141,6 +1143,8 @@ webAuthnPolicyRpId=Relying party ID ldapRolesDnHelp=LDAP DN where roles of this tree are saved. For example, 'ou\=finance,dc\=example,dc\=org'. serviceAccount=Service account roles standardTokenExchangeEnabled=Standard Token Exchange +jwtAuthorizationGrantEnabled=JWT Authorization Grant +jwtAuthorizationGrantIdp=Allowed Identity Providers for JWT Authorization Grant enableRefreshRequestedTokenType=Allow refresh token in Standard Token Exchange providerUpdatedSuccess=Client policy updated successfully assertionConsumerServiceRedirectBindingURL=Assertion Consumer Service Redirect Binding URL diff --git a/js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx b/js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx index 346fe3f81b8..fd3292da2ce 100644 --- a/js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx +++ b/js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx @@ -1,5 +1,9 @@ import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; -import { HelpItem, SelectControl } from "@keycloak/keycloak-ui-shared"; +import { + HelpItem, + SelectControl, + useFetch, +} from "@keycloak/keycloak-ui-shared"; import { Checkbox, FormGroup, @@ -16,6 +20,12 @@ import { FormAccess } from "../../components/form/FormAccess"; import { convertAttributeNameToForm } from "../../util"; import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled"; import { FormFields } from "../ClientDetails"; +import { MultiValuedListComponent } from "../../components/dynamic/MultivaluedListComponent"; +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { useAdminClient } from "../../admin-client"; +import { useState } from "react"; +import { IdentityProvidersQuery } from "@keycloak/keycloak-admin-client/lib/resources/identityProviders"; +import { useAccess } from "../../context/access/Access"; type CapabilityConfigProps = { unWrap?: boolean; @@ -26,13 +36,34 @@ export const CapabilityConfig = ({ unWrap, protocol: type, }: CapabilityConfigProps) => { + const { adminClient } = useAdminClient(); const { t } = useTranslation(); const { control, watch, setValue } = useFormContext(); const protocol = type || watch("protocol"); const clientAuthentication = watch("publicClient"); const authorization = watch("authorizationServicesEnabled"); const isFeatureEnabled = useIsFeatureEnabled(); - + const [idps, setIdps] = useState([]); + const [search, setSearch] = useState(""); + const { hasSomeAccess } = useAccess(); + const showIdentityProviders = hasSomeAccess("view-identity-providers"); + useFetch( + async () => { + if (!showIdentityProviders) { + return []; + } + const params: IdentityProvidersQuery = { + max: 20, + realmOnly: true, + }; + if (search) { + params.search = search; + } + return await adminClient.identityProviders.find(params); + }, + setIdps, + [search], + ); return ( ( + "attributes.oauth2.jwt.authorization.grant.enabled", + ), + false, + ); } }} aria-label={t("clientAuthentication")} @@ -275,6 +312,41 @@ export const CapabilityConfig = ({ /> )} + {isFeatureEnabled(Feature.JWTAuthorizationGrant) && ( + + + >("attributes.oauth2.jwt.authorization.grant.enabled")} + defaultValue={false} + control={control} + render={({ field }) => ( + + + + + + + + + )} + /> + + )} {isFeatureEnabled(Feature.DeviceFlow) && ( + {isFeatureEnabled(Feature.JWTAuthorizationGrant) && + showIdentityProviders && ( + ( + "attributes.oauth2.jwt.authorization.grant.idp", + )} + label={t("jwtAuthorizationGrantIdp")} + helpText={t("jwtAuthorizationGrantIdpHelp")} + convertToName={convertAttributeNameToForm} + stringify + isDisabled={clientAuthentication} + options={idps.map(({ alias }) => alias ?? "")} + onSearch={setSearch} + /> + )} {isFeatureEnabled(Feature.DPoP) && ( ( diff --git a/js/apps/admin-ui/src/components/dynamic/MultivaluedListComponent.tsx b/js/apps/admin-ui/src/components/dynamic/MultivaluedListComponent.tsx index 91f363d79b1..f0586bc9c9e 100644 --- a/js/apps/admin-ui/src/components/dynamic/MultivaluedListComponent.tsx +++ b/js/apps/admin-ui/src/components/dynamic/MultivaluedListComponent.tsx @@ -27,11 +27,18 @@ export const MultiValuedListComponent = ({ stringify, required, convertToName, + onSearch, }: ComponentProps) => { const { t } = useTranslation(); const { control } = useFormContext(); const [open, setOpen] = useState(false); + function setSearch(value: string) { + if (onSearch) { + onSearch(value); + } + } + return ( { field.onChange(stringify ? "" : []); }} + onFilter={(value) => setSearch(value)} isOpen={open} aria-label={t(label!)} > diff --git a/js/apps/admin-ui/src/components/dynamic/components.ts b/js/apps/admin-ui/src/components/dynamic/components.ts index 7e13bfd1f16..a217572a304 100644 --- a/js/apps/admin-ui/src/components/dynamic/components.ts +++ b/js/apps/admin-ui/src/components/dynamic/components.ts @@ -24,6 +24,7 @@ export type ComponentProps = Omit & { isNew?: boolean; stringify?: boolean; convertToName: (name: string) => string; + onSearch?: (search: string) => void; }; export type NumberComponentProps = ComponentProps & { diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index db8dac2999c..de6e614b882 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -16,6 +16,7 @@ export enum Feature { OpenId4VCI = "OID4VC_VCI", QuickTheme = "QUICK_THEME", StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2", + JWTAuthorizationGrant = "JWT_AUTHORIZATION_GRANT", Passkeys = "PASSKEYS", ClientAuthFederated = "CLIENT_AUTH_FEDERATED", Workflows = "WORKFLOWS", diff --git a/js/libs/ui-shared/src/select/KeycloakSelect.tsx b/js/libs/ui-shared/src/select/KeycloakSelect.tsx index dede9869a10..d75a1d51c57 100644 --- a/js/libs/ui-shared/src/select/KeycloakSelect.tsx +++ b/js/libs/ui-shared/src/select/KeycloakSelect.tsx @@ -18,7 +18,7 @@ export type KeycloakSelectProps<> = Omit< "name" | "toggle" | "selected" | "onClick" | "onSelect" | "variant" > & { toggleId?: string; - onFilter?: (value: string) => JSX.Element[]; + onFilter?: (value: string) => void; onClear?: () => void; variant?: Variant; isDisabled?: boolean; diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java index 9e4956b63ac..00eb59557d0 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java @@ -51,6 +51,12 @@ public class JWTAuthorizationGrantValidationContext { if (client.isPublicClient()) { failure("Public client not allowed to use authorization grant"); } + + String val = client.getAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED); + if (!Boolean.parseBoolean(val)) { + throw new RuntimeException("JWT Authorization Grant is not supported for the requested client"); + } + } public void validateTokenActive() { diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index c85f68242a2..d00f4390cf3 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -93,6 +93,10 @@ public final class OIDCConfigAttributes { public static final String STANDARD_TOKEN_EXCHANGE_ENABLED = "standard.token.exchange.enabled"; public static final String STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED = "standard.token.exchange.enableRefreshRequestedTokenType"; + public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "oauth2.jwt.authorization.grant.enabled"; + public static final String JWT_AUTHORIZATION_GRANT_IDP = "oauth2.jwt.authorization.grant.idp"; + + private OIDCConfigAttributes() { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index e6524b3bc5a..c240d8a89e3 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -27,6 +27,7 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.utils.StringUtil; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -264,6 +265,21 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper { enable == null || enable == TokenExchangeRefreshTokenEnabled.NO? null : enable.name()); } + public boolean getJWTAuthorizationGrantEnabled() { + String val = getAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "false"); + return Boolean.parseBoolean(val); + } + + public void setJWTAuthorizationGrantEnabled(boolean enable) { + String val = String.valueOf(enable); + setAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, val); + } + + public List getJWTAuthorizationGrantAllowedIdentityProviders() { + List allowedIDPs = getAttributeMultivalued(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP); + return allowedIDPs == null ? Collections.emptyList() : allowedIDPs; + } + public String getTlsClientAuthSubjectDn() { return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java index 1544b89f5c2..dfc42e8b3a7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -34,6 +34,7 @@ import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.services.CorsErrorResponseException; @@ -57,6 +58,7 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { JWTAuthorizationGrantValidationContext authorizationGrantContext = new JWTAuthorizationGrantValidationContext(assertion, client, expectedAudience); try { + //client must be confidential authorizationGrantContext.validateClient(); @@ -80,6 +82,10 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { throw new RuntimeException("No Identity Provider for provided issuer"); } + if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).getJWTAuthorizationGrantAllowedIdentityProviders().contains(identityProviderModel.getAlias())) { + throw new RuntimeException("Identity Provider is not allowed for the client"); + } + UserAuthenticationIdentityProvider identityProvider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); if (!(identityProvider instanceof JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider)) { throw new RuntimeException("Identity Provider is not configured for JWT Authorization Grant"); diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index 647e5aa11bf..ecc130c2dfd 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -122,6 +122,7 @@ public class DescriptionConverter { setOidcGrantEnabled(client, CibaConfig.OIDC_CIBA_GRANT_ENABLED, oidcGrantTypes.contains(OAuth2Constants.CIBA_GRANT_TYPE)); setOidcGrantEnabled(client, OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, oidcGrantTypes.contains(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)); setOidcGrantEnabled(client, OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, oidcGrantTypes.contains(OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)); + setOidcGrantEnabled(client, OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, oidcGrantTypes.contains(OAuth2Constants.JWT_AUTHORIZATION_GRANT)); client.setAuthorizationServicesEnabled(oidcGrantTypes.contains(OAuth2Constants.UMA_GRANT_TYPE)); configWrapper.setUseRefreshToken(oidcGrantTypes.contains(OAuth2Constants.REFRESH_TOKEN)); } @@ -137,6 +138,9 @@ public class DescriptionConverter { if (oidcGrantTypes != null && oidcGrantTypes.contains(OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)) { throw new ClientRegistrationException("Token Exchange cannot be enabled in a public client"); } + if (oidcGrantTypes != null && oidcGrantTypes.contains(OAuth2Constants.JWT_AUTHORIZATION_GRANT)) { + throw new ClientRegistrationException("JWT authorization grant cannot be enabled in a public client"); + } } else { ClientAuthenticatorFactory clientAuthFactory; if (authMethod == null) { @@ -544,6 +548,9 @@ public class DescriptionConverter { if (!client.isPublicClient() && oidcClient.isStandardTokenExchangeEnabled()) { grantTypes.add(OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); } + if (!client.isPublicClient() && oidcClient.getJWTAuthorizationGrantEnabled()) { + grantTypes.add(OAuth2Constants.JWT_AUTHORIZATION_GRANT); + } return grantTypes; } diff --git a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java index fdc99585fd1..06b570eb586 100644 --- a/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java +++ b/test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/DefaultOAuthClientConfiguration.java @@ -1,5 +1,6 @@ package org.keycloak.testframework.oauth; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.testframework.realm.ClientConfig; import org.keycloak.testframework.realm.ClientConfigBuilder; @@ -10,6 +11,8 @@ public class DefaultOAuthClientConfiguration implements ClientConfig { return client.clientId("test-app") .serviceAccountsEnabled(true) .directAccessGrantsEnabled(true) + .attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true") + .attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP, "authorization-grant-idp") .secret("test-secret"); } 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 7b2aa112672..b3acc217b96 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 @@ -8,6 +8,7 @@ import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.events.EventType; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; @@ -68,6 +69,24 @@ public class JWTAuthorizationGrantTest { oAuthClient.client("test-app", "test-secret"); } + @Test + public void testIdpNotAllowedForClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("authorization-grant-not-allowed-idp-client", "test-secret"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Identity Provider is not allowed for the client", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + + @Test + public void testNotAllowedClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("authorization-grant-disabled-client", "test-secret"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("JWT Authorization Grant is not supported for the requested client", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + @Test public void testMissingAssertionParameter() { AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(null).send(); @@ -176,6 +195,10 @@ public class JWTAuthorizationGrantTest { realm.addClient("test-public").publicClient(true); + realm.addClient("authorization-grant-disabled-client").publicClient(false).secret("test-secret"); + + realm.addClient("authorization-grant-not-allowed-idp-client").publicClient(false).attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true").secret("test-secret"); + realm.identityProvider( IdentityProviderBuilder.create() .providerId(OIDCIdentityProviderFactory.PROVIDER_ID)