mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
JWT Authorization grant client configuration (#43685)
closes #43567 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
parent
47288a9643
commit
759e062131
@ -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
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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!)}
|
||||
>
|
||||
|
||||
@ -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 & {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user