JWT Authorization grant idp config (#43841)

Closes #43568

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2025-11-04 14:46:14 +01:00 committed by GitHub
parent d5763b9c0b
commit 4b443f04ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 148 additions and 11 deletions

View File

@ -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<String, String> config = new HashMap<>();
protected List<String> types = new ArrayList<>();
public String getInternalId() {
return this.internalId;
@ -212,4 +215,11 @@ public class IdentityProviderRepresentation {
this.organizationId = organizationId;
}
public List<String> getTypes() {
return this.types;
}
public void setTypes(List<String> types) {
this.types = types;
}
}

View File

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

View File

@ -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: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<JwtAuthorizationGrantSettings />
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isSPIFFE,

View File

@ -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 (
<>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantEnabled"
label={t("jwtAuthorizationGrantIdpEnabled")}
labelIcon={t("jwtAuthorizationGrantIdpEnabledHelp")}
stringify
/>
{authorizationGrantEnabled === "true" && (
<DefaultSwitchControl
name="config.jwtAuthorizationGrantAssertionReuseAllowed"
label={t("jwtAuthorizationGrantAssertionReuseAllowed")}
labelIcon={t("jwtAuthorizationGrantAssertionReuseAllowedHelp")}
stringify
/>
)}
<Divider />
</>
);
};

View File

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

View File

@ -22,4 +22,7 @@ public interface JWTAuthorizationGrantProvider {
BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException;
int getAllowedClockSkew();
boolean isAssertionReuseAllowed();
}

View File

@ -24,6 +24,18 @@ public class IdentityProviderTypeUtil {
private IdentityProviderTypeUtil() {
}
public static List<IdentityProviderType> 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<String> listFactoriesByCapability(KeycloakSession session, IdentityProviderCapability capability) {
Set<IdentityProviderType> types = Arrays.stream(IdentityProviderType.values()).filter(t -> t.getCapabilities().contains(capability)).collect(Collectors.toSet());
return listFactoriesByTypes(session, types);

View File

@ -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<IdentityProviderRepresentation> 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<IdentityProviderMapperRepresentation> 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<IdentityProviderType> identityProviderTypes = IdentityProviderTypeUtil.listTypesFromFactory(session, identityProviderModel.getProviderId());
providerRep.setTypes(identityProviderTypes.stream().map(Enum::toString).collect(Collectors.toList()));
return providerRep;
}

View File

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

View File

@ -1070,6 +1070,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
@Override
public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) throws IdentityBrokerException {
if (!getConfig().getJwtAuthorizationGrantEnabled()) {
throw new IdentityBrokerException("JWT Authorization Granted is not enabled for the identity provider");
}
// verify signature
if (!verify(context.getJws())) {
@ -1087,4 +1090,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public int getAllowedClockSkew() {
return getConfig().getAllowedClockSkew();
}
@Override
public boolean isAssertionReuseAllowed() {
return getConfig().getJwtAuthorizationGrantAssertionReuseAllowed();
}
}

View File

@ -163,7 +163,7 @@ public class OrganizationIdentityProvidersResource {
}
private IdentityProviderRepresentation toRepresentation(IdentityProviderModel idp) {
return StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(realm, idp));
return StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(session, realm, idp));
}
private boolean isOrganizationBroker(IdentityProviderModel broker) {

View File

@ -86,7 +86,7 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
}
// assign the provider and perform validations associated to the jwt grant provider
authorizationGrantContext.validateTokenActive(jwtAuthorizationGrantProvider.getAllowedClockSkew(), 300, false);
authorizationGrantContext.validateTokenActive(jwtAuthorizationGrantProvider.getAllowedClockSkew(), 300, jwtAuthorizationGrantProvider.isAssertionReuseAllowed());
//validate the JWT assertion and get the brokered identity from the idp
BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext);

View File

@ -117,7 +117,7 @@ public class IdentityProviderResource {
throw new jakarta.ws.rs.NotFoundException();
}
return StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(realm, this.identityProviderModel));
return StripSecretsUtils.stripSecrets(session, ModelToRepresentation.toRepresentation(session, realm, this.identityProviderModel));
}
/**

View File

@ -199,7 +199,7 @@ public class IdentityProvidersResource {
Function<IdentityProviderModel, IdentityProviderRepresentation> 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);

View File

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