mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
JWT Authorization grant idp config (#43841)
Closes #43568 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
parent
d5763b9c0b
commit
4b443f04ee
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -22,4 +22,7 @@ public interface JWTAuthorizationGrantProvider {
|
||||
BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException;
|
||||
|
||||
int getAllowedClockSkew();
|
||||
|
||||
boolean isAssertionReuseAllowed();
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user