JWT Authorization grant client configuration (#43685)

closes #43567

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2025-10-29 08:45:51 +01:00 committed by GitHub
parent 47288a9643
commit 759e062131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 169 additions and 3 deletions

View File

@ -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

View File

@ -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<FormFields>();
const protocol = type || watch("protocol");
const clientAuthentication = watch("publicClient");
const authorization = watch("authorizationServicesEnabled");
const isFeatureEnabled = useIsFeatureEnabled();
const [idps, setIdps] = useState<IdentityProviderRepresentation[]>([]);
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 (
<FormAccess
isHorizontal
@ -82,6 +113,12 @@ export const CapabilityConfig = ({
),
false,
);
setValue(
convertAttributeNameToForm<FormFields>(
"attributes.oauth2.jwt.authorization.grant.enabled",
),
false,
);
}
}}
aria-label={t("clientAuthentication")}
@ -275,6 +312,41 @@ export const CapabilityConfig = ({
/>
</GridItem>
)}
{isFeatureEnabled(Feature.JWTAuthorizationGrant) && (
<GridItem lg={8} sm={6}>
<Controller
name={convertAttributeNameToForm<
Required<ClientRepresentation["attributes"]>
>("attributes.oauth2.jwt.authorization.grant.enabled")}
defaultValue={false}
control={control}
render={({ field }) => (
<InputGroup>
<InputGroupItem>
<Checkbox
data-testid="jwt-authorization-grant-enabled"
label={t("jwtAuthorizationGrantEnabled")}
id="kc-jwt-authorization-grant-enabled"
name="jwt-authorization-grant-enabled"
isChecked={
field.value.toString() === "true" &&
!clientAuthentication
}
onChange={field.onChange}
isDisabled={clientAuthentication}
/>
</InputGroupItem>
<InputGroupItem>
<HelpItem
helpText={t("jwtAuthorizationGrantEnabledHelp")}
fieldLabelId="jwtAuthorizationGrantEnabled"
/>
</InputGroupItem>
</InputGroup>
)}
/>
</GridItem>
)}
{isFeatureEnabled(Feature.DeviceFlow) && (
<GridItem lg={8} sm={6}>
<Controller
@ -352,6 +424,21 @@ export const CapabilityConfig = ({
{ key: "plain", value: "plain" },
]}
/>
{isFeatureEnabled(Feature.JWTAuthorizationGrant) &&
showIdentityProviders && (
<MultiValuedListComponent
name={convertAttributeNameToForm<FormFields>(
"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) && (
<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(

View File

@ -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 (
<FormGroup
label={t(label!)}
@ -77,6 +84,7 @@ export const MultiValuedListComponent = ({
onClear={() => {
field.onChange(stringify ? "" : []);
}}
onFilter={(value) => setSearch(value)}
isOpen={open}
aria-label={t(label!)}
>

View File

@ -24,6 +24,7 @@ export type ComponentProps = Omit<ConfigPropertyRepresentation, "type"> & {
isNew?: boolean;
stringify?: boolean;
convertToName: (name: string) => string;
onSearch?: (search: string) => void;
};
export type NumberComponentProps = ComponentProps & {

View File

@ -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",

View File

@ -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;

View File

@ -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() {

View File

@ -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() {
}

View File

@ -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<String> getJWTAuthorizationGrantAllowedIdentityProviders() {
List<String> allowedIDPs = getAttributeMultivalued(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_IDP);
return allowedIDPs == null ? Collections.emptyList() : allowedIDPs;
}
public String getTlsClientAuthSubjectDn() {
return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN);
}

View File

@ -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");

View File

@ -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;
}

View File

@ -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");
}

View File

@ -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)