New Identity Provider condition for client policies

Closes #44442

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2025-11-26 16:12:42 +01:00 committed by Marek Posolda
parent a9c1bcc9bd
commit ae7e7ba084
14 changed files with 448 additions and 83 deletions

View File

@ -2848,6 +2848,7 @@ searchScope=Search scope
dateFrom=Date(from)
importAdded_one=One record added.
clientAccessType=It uses the client's access type (confidential, public, bearer-only) to determine whether the policy is applied. Condition is checked during most of OpenID Connect requests (Authorization requests, token requests, introspection endpoint request, etc.). Confidential client has enabled client authentication when public client has disabled client authentication. Bearer-only is a deprecated client type.
identityProviderAlias=Condition that checks the Identity Provider that is involved in the client request. Only applies to operations in which an IdP is involved (for example JWT Authorization grant).
firstName=First name
emptySecondaryAction=Configure a new mapper
defaultGroupAdded_one=New group added to the default groups

View File

@ -1,9 +1,5 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import {
HelpItem,
SelectControl,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import { HelpItem, SelectControl } from "@keycloak/keycloak-ui-shared";
import {
Checkbox,
FormGroup,
@ -20,13 +16,8 @@ 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, {
IdentityProviderType,
} 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 { IdentityProviderSelect } from "../../components/identity-provider/IdentityProviderSelect";
import { IdentityProviderType } from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { useAccess } from "../../context/access/Access";
type CapabilityConfigProps = {
@ -38,7 +29,6 @@ export const CapabilityConfig = ({
unWrap,
protocol: type,
}: CapabilityConfigProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { control, watch, setValue } = useFormContext<FormFields>();
const protocol = type || watch("protocol");
@ -51,28 +41,8 @@ export const CapabilityConfig = ({
false,
);
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;
}
params.type = IdentityProviderType.JWT_AUTHORIZATION_GRANT;
return await adminClient.identityProviders.find(params);
},
setIdps,
[search],
);
return (
<FormAccess
isHorizontal
@ -436,17 +406,19 @@ export const CapabilityConfig = ({
{isFeatureEnabled(Feature.JWTAuthorizationGrant) &&
showIdentityProviders &&
jwtAuthorizationGrantEnabled.toString() === "true" && (
<MultiValuedListComponent
<IdentityProviderSelect
name={convertAttributeNameToForm<FormFields>(
"attributes.oauth2.jwt.authorization.grant.idp",
)}
label={t("jwtAuthorizationGrantIdp")}
helpText={t("jwtAuthorizationGrantIdpHelp")}
convertToName={convertAttributeNameToForm}
stringify
identityProviderType={
IdentityProviderType.JWT_AUTHORIZATION_GRANT
}
isDisabled={clientAuthentication}
options={idps.map(({ alias }) => alias ?? "")}
onSearch={setSearch}
realmOnly
stringify
/>
)}
{isFeatureEnabled(Feature.DPoP) && (

View File

@ -0,0 +1,10 @@
import type { ComponentProps } from "./components";
import { IdentityProviderSelect } from "../identity-provider/IdentityProviderSelect";
export const IdentityProviderMultiSelectComponent = (props: ComponentProps) => (
<IdentityProviderSelect
{...props}
convertToName={props.convertToName}
name={props.name!}
/>
);

View File

@ -1,4 +1,5 @@
import {
FormErrorText,
HelpItem,
KeycloakSelect,
SelectVariant,
@ -17,6 +18,10 @@ function toStringValue(formValue: string[]): string {
return formValue.join("##");
}
type MultiValuedListComponentProps = ComponentProps & {
variant?: `${SelectVariant}`;
};
export const MultiValuedListComponent = ({
name,
label,
@ -28,9 +33,13 @@ export const MultiValuedListComponent = ({
required,
convertToName,
onSearch,
}: ComponentProps) => {
variant = SelectVariant.typeaheadMulti,
}: MultiValuedListComponentProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
const {
control,
formState: { errors },
} = useFormContext();
const [open, setOpen] = useState(false);
function setSearch(value: string) {
@ -39,6 +48,14 @@ export const MultiValuedListComponent = ({
}
}
const convertedName = convertToName(name!);
const getError = () => {
return convertedName
.split(".")
.reduce((record: any, key) => record?.[key], errors);
};
return (
<FormGroup
label={t(label!)}
@ -47,53 +64,78 @@ export const MultiValuedListComponent = ({
isRequired={required}
>
<Controller
name={convertToName(name!)}
name={convertedName}
control={control}
defaultValue={
stringify ? defaultValue || "" : defaultValue ? [defaultValue] : []
stringify || variant !== SelectVariant.typeaheadMulti
? defaultValue || ""
: defaultValue
? [defaultValue]
: []
}
rules={{
required: { value: required || false, message: t("required") },
}}
render={({ field }) => (
<KeycloakSelect
toggleId={name}
data-testid={name}
isDisabled={isDisabled}
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
onToggle={(isOpen) => setOpen(isOpen)}
selections={
stringify ? stringToMultiline(field.value) : field.value
}
onSelect={(v) => {
const option = v.toString();
const values = stringify
? stringToMultiline(field.value)
: field.value;
let newValue;
if (values.includes(option)) {
newValue = values.filter((item: string) => item !== option);
} else {
newValue = [...values, option];
<>
<KeycloakSelect
toggleId={name}
data-testid={name}
isDisabled={isDisabled}
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
variant={variant}
typeAheadAriaLabel={t("choose")}
onToggle={setOpen}
selections={
stringify && variant === SelectVariant.typeaheadMulti
? stringToMultiline(field.value)
: field.value
}
field.onChange(stringify ? toStringValue(newValue) : newValue);
}}
onClear={() => {
field.onChange(stringify ? "" : []);
}}
onFilter={(value) => setSearch(value)}
isOpen={open}
aria-label={t(label!)}
>
{options?.map((option) => (
<SelectOption key={option} value={option}>
{option}
</SelectOption>
))}
</KeycloakSelect>
onSelect={(v) => {
const option = v.toString();
if (variant === SelectVariant.typeaheadMulti) {
const values = stringify
? stringToMultiline(field.value)
: field.value;
let newValue;
if (values.includes(option)) {
newValue = values.filter((item: string) => item !== option);
} else if (option !== "") {
newValue = [...values, option];
} else {
newValue = values;
}
field.onChange(
stringify && variant === SelectVariant.typeaheadMulti
? toStringValue(newValue)
: newValue,
);
} else {
field.onChange(option);
}
}}
onClear={() => {
field.onChange(
stringify || variant !== SelectVariant.typeaheadMulti
? ""
: [],
);
}}
onFilter={setSearch}
isOpen={open}
>
{options?.map((option) => (
<SelectOption key={option} value={option}>
{option}
</SelectOption>
))}
</KeycloakSelect>
{getError() && <FormErrorText message={getError().message} />}
</>
)}
/>
</FormGroup>

View File

@ -3,6 +3,7 @@ import { FunctionComponent } from "react";
import { BooleanComponent } from "./BooleanComponent";
import { ClientSelectComponent } from "./ClientSelectComponent";
import { IdentityProviderMultiSelectComponent } from "./IdentityProviderMultiSelectComponent";
import { FileComponent } from "./FileComponent";
import { GroupComponent } from "./GroupComponent";
import { ListComponent } from "./ListComponent";
@ -45,6 +46,7 @@ type ComponentType =
| "Group"
| "MultivaluedList"
| "ClientList"
| "IdentityProviderMultiList"
| "UserProfileAttributeList"
| "MultivaluedString"
| "File"
@ -65,6 +67,7 @@ export const COMPONENTS: {
Map: MapComponent,
Group: GroupComponent,
ClientList: ClientSelectComponent,
IdentityProviderMultiList: IdentityProviderMultiSelectComponent,
UserProfileAttributeList: UserProfileAttributeListComponent,
MultivaluedList: MultiValuedListComponent,
MultivaluedString: MultiValuedStringComponent,

View File

@ -0,0 +1,51 @@
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { IdentityProviderType } from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import type { IdentityProvidersQuery } from "@keycloak/keycloak-admin-client/lib/resources/identityProviders";
import { SelectVariant, useFetch } from "@keycloak/keycloak-ui-shared";
import { useState } from "react";
import { useAdminClient } from "../../admin-client";
import type { ComponentProps } from "../dynamic/components";
import { MultiValuedListComponent } from "../dynamic/MultivaluedListComponent";
type IdentityProviderSelectProps = ComponentProps & {
variant?: `${SelectVariant}`;
identityProviderType?: IdentityProviderType;
realmOnly?: boolean;
};
export const IdentityProviderSelect = ({
identityProviderType = IdentityProviderType.ANY,
realmOnly = false,
...props
}: IdentityProviderSelectProps) => {
const { adminClient } = useAdminClient();
const [identityProviders, setIdentityProviders] = useState<
IdentityProviderRepresentation[]
>([]);
const [search, setSearch] = useState("");
useFetch(
() => {
const params: IdentityProvidersQuery = {
max: 20,
type: identityProviderType,
realmOnly: realmOnly,
};
if (search) {
params.search = search;
}
return adminClient.identityProviders.find(params);
},
(identityProviders) => setIdentityProviders(identityProviders),
[search],
);
return (
<MultiValuedListComponent
{...props}
onSearch={setSearch}
options={identityProviders.map(({ alias }) => alias!)}
/>
);
};

View File

@ -84,6 +84,8 @@ public class ProviderConfigProperty {
*/
public static final String URL_TYPE ="Url";
public static final String IDENTITY_PROVIDER_MULTI_LIST_TYPE="IdentityProviderMultiList"; // only in admin console, not in themes
protected String name;
protected String label;
protected String helpText;

View File

@ -0,0 +1,78 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.clientpolicy.condition;
import java.util.List;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.ClientPolicyVote;
import org.keycloak.services.clientpolicy.context.JWTAuthorizationGrantContext;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
*
* @author rmartinc
*/
public class IdentityProviderCondition extends AbstractClientPolicyConditionProvider<IdentityProviderCondition.Configuration> {
public static class Configuration extends ClientPolicyConditionConfigurationRepresentation {
@JsonProperty(IdentityProviderConditionFactory.IDENTITY_PROVIDERS_ALIASES)
protected List<String> identityProviderAliases;
public List<String> getIdentityProviderAliases() {
return identityProviderAliases;
}
public void setIdentityProviderAliases(List<String> identityProviderAliases) {
this.identityProviderAliases = identityProviderAliases;
}
}
public IdentityProviderCondition(KeycloakSession session) {
super(session);
}
@Override
public Class<Configuration> getConditionConfigurationClass() {
return Configuration.class;
}
@Override
public String getProviderId() {
return IdentityProviderConditionFactory.PROVIDER_ID;
}
@Override
public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException {
return switch (context.getEvent()) {
case JWT_AUTHORIZATION_GRANT -> isIdentityProvider(((JWTAuthorizationGrantContext) context).getIdentityProvider().getAlias())
? ClientPolicyVote.YES
: ClientPolicyVote.NO;
default -> ClientPolicyVote.ABSTAIN;
};
}
private boolean isIdentityProvider(String identityProviderAlias) throws ClientPolicyException {
return configuration.identityProviderAliases.contains(identityProviderAlias);
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.clientpolicy.condition;
import java.util.List;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
* <p>Condition that defines a list of Identity Provider aliases and checks if the
* alias in the client policy context is (or is not) part of that list.</p>
*
* @author rmartinc
*/
public class IdentityProviderConditionFactory extends AbstractClientPolicyConditionProviderFactory {
public static final String PROVIDER_ID = "identity-provider-alias";
public static final String IDENTITY_PROVIDERS_ALIASES = "identity_provider_aliases";
@Override
public ClientPolicyConditionProvider create(KeycloakSession session) {
return new IdentityProviderCondition(session);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return """
Condition that checks the Identity Provider that is involved in the client request.
Only applies to operations in which an IdP is involved (for example JWT Authorization grant).
""";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> properties = ProviderConfigurationBuilder.create()
.property()
.name(IDENTITY_PROVIDERS_ALIASES)
.type(ProviderConfigProperty.IDENTITY_PROVIDER_MULTI_LIST_TYPE)
.label("Identity provider aliases")
.helpText("List of Identity Provider aliases to take into consideration for the condition.")
.required(Boolean.TRUE)
.add()
.build();
addCommonConfigProperties(properties);
return properties;
}
}

View File

@ -10,3 +10,4 @@ org.keycloak.services.clientpolicy.condition.ClientProtocolConditionFactory
org.keycloak.services.clientpolicy.condition.ClientAttributesConditionFactory
org.keycloak.services.clientpolicy.condition.AcrConditionFactory
org.keycloak.services.clientpolicy.condition.GrantTypeConditionFactory
org.keycloak.services.clientpolicy.condition.IdentityProviderConditionFactory

View File

@ -8,6 +8,7 @@ import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepres
import org.keycloak.representations.idm.ClientPolicyConditionRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.services.clientpolicy.condition.GrantTypeCondition;
import org.keycloak.services.clientpolicy.condition.IdentityProviderCondition;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.JsonNode;
@ -30,14 +31,24 @@ public class ClientPolicyBuilder {
return new ClientPolicyBuilder(rep);
}
public static GrantTypeCondition.Configuration grantTypeConditionConfiguration(String... types) {
public static GrantTypeCondition.Configuration grantTypeConditionConfiguration(boolean negativeLogic, String... types) {
GrantTypeCondition.Configuration config = new GrantTypeCondition.Configuration();
config.setNegativeLogic(negativeLogic);
if (types != null && types.length > 0) {
config.setGrantTypes(List.of(types));
}
return config;
}
public static IdentityProviderCondition.Configuration identityProviderConditionConfiguration(boolean negativeLogic, String... aliases) {
IdentityProviderCondition.Configuration config = new IdentityProviderCondition.Configuration();
config.setNegativeLogic(negativeLogic);
if (aliases != null && aliases.length > 0) {
config.setIdentityProviderAliases(List.of(aliases));
}
return config;
}
public static ClientPolicyBuilder update(ClientPolicyRepresentation rep) {
return new ClientPolicyBuilder(rep);
}

View File

@ -75,7 +75,7 @@ public class JWTAuthorizationGrantDownscopeClientPoliciesTest extends BaseAbstra
.name("policy")
.description("description of policy")
.condition(GrantTypeConditionFactory.PROVIDER_ID, ClientPolicyBuilder.grantTypeConditionConfiguration(
OAuth2Constants.JWT_AUTHORIZATION_GRANT))
false, OAuth2Constants.JWT_AUTHORIZATION_GRANT))
.profile("executor")
.build());

View File

@ -0,0 +1,62 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.oauth;
import org.keycloak.services.clientpolicy.condition.IdentityProviderConditionFactory;
import org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ClientPolicyBuilder;
import org.keycloak.testframework.realm.ClientProfileBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfigBuilder;
/**
*
* @author rmartinc
*/
@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class)
public class JWTIdentityProviderConditionDownscopeClientPoliciesTest extends JWTAuthorizationGrantDownscopeClientPoliciesTest {
@InjectRealm(config = JWTAuthorizationGranthRealmConfig.class)
protected ManagedRealm realm;
public static class JWTAuthorizationGranthRealmConfig extends OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
super.configure(realm);
realm.clientProfile(ClientProfileBuilder.create()
.name("executor")
.description("executor description")
.executor(DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID, null)
.build());
realm.clientPolicy(ClientPolicyBuilder.create()
.name("policy")
.description("description of policy")
.condition(IdentityProviderConditionFactory.PROVIDER_ID, ClientPolicyBuilder.identityProviderConditionConfiguration(
false, "other", IDP_ALIAS))
.profile("executor")
.build());
return realm;
}
}
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.oauth;
import org.keycloak.services.clientpolicy.condition.IdentityProviderConditionFactory;
import org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ClientPolicyBuilder;
import org.keycloak.testframework.realm.ClientProfileBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfigBuilder;
/**
*
* @author rmartinc
*/
@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class)
public class JWTNegativeIdentityProviderConditionDownscopeClientPoliciesTest extends JWTAuthorizationGrantDownscopeClientPoliciesTest {
@InjectRealm(config = JWTAuthorizationGranthRealmConfig.class)
protected ManagedRealm realm;
public static class JWTAuthorizationGranthRealmConfig extends OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
super.configure(realm);
realm.clientProfile(ClientProfileBuilder.create()
.name("executor")
.description("executor description")
.executor(DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID, null)
.build());
realm.clientPolicy(ClientPolicyBuilder.create()
.name("policy")
.description("description of policy")
.condition(IdentityProviderConditionFactory.PROVIDER_ID, ClientPolicyBuilder.identityProviderConditionConfiguration(
true, "other"))
.profile("executor")
.build());
return realm;
}
}
}