Initial security profile SPI to integrate default client policies

Closes #27189

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-04-02 13:23:33 +02:00 committed by Marek Posolda
parent c76cbc94d8
commit 41b706bb6a
33 changed files with 1274 additions and 277 deletions

View file

@ -30,6 +30,7 @@ import org.keycloak.util.JsonSerialization;
*/
public class ClientPoliciesRepresentation {
protected List<ClientPolicyRepresentation> policies = new ArrayList<>();
private List<ClientPolicyRepresentation> globalPolicies;
public List<ClientPolicyRepresentation> getPolicies() {
return policies;
@ -39,6 +40,14 @@ public class ClientPoliciesRepresentation {
this.policies = policies;
}
public List<ClientPolicyRepresentation> getGlobalPolicies() {
return globalPolicies;
}
public void setGlobalPolicies(List<ClientPolicyRepresentation> globalPolicies) {
this.globalPolicies = globalPolicies;
}
@Override
public int hashCode() {
return JsonSerialization.mapper.convertValue(this, JsonNode.class).hashCode();

View file

@ -0,0 +1,80 @@
/*
* Copyright 2024 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.representations.idm;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Default configuration for security profile. For the moment just a name and pointers
* to default global client profiles and policies.
*
* @author rmartinc
*/
public class SecurityProfileConfiguration {
private String name;
@JsonProperty("client-profiles")
private String clientProfiles;
@JsonProperty("client-policies")
private String clientPolicies;
@JsonIgnore
private List<ClientProfileRepresentation> defaultClientProfiles;
@JsonIgnore
private List<ClientPolicyRepresentation> defaultClientPolicies;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getClientProfiles() {
return clientProfiles;
}
public void setClientProfiles(String clientProfiles) {
this.clientProfiles = clientProfiles;
}
public String getClientPolicies() {
return clientPolicies;
}
public void setClientPolicies(String clientPolicies) {
this.clientPolicies = clientPolicies;
}
public List<ClientProfileRepresentation> getDefaultClientProfiles() {
return defaultClientProfiles;
}
public void setDefaultClientProfiles(List<ClientProfileRepresentation> defaultClientProfiles) {
this.defaultClientProfiles = defaultClientProfiles;
}
public List<ClientPolicyRepresentation> getDefaultClientPolicies() {
return defaultClientPolicies;
}
public void setDefaultClientPolicies(List<ClientPolicyRepresentation> defaultClientPolicies) {
this.defaultClientPolicies = defaultClientPolicies;
}
}

View file

@ -4,6 +4,7 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
@ -17,6 +18,10 @@ public interface ClientPoliciesPoliciesResource {
@Produces(MediaType.APPLICATION_JSON)
ClientPoliciesRepresentation getPolicies();
@GET
@Produces(MediaType.APPLICATION_JSON)
ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") Boolean includeGlobalPolicies);
@PUT
@Consumes(MediaType.APPLICATION_JSON)
void updatePolicies(final ClientPoliciesRepresentation clientPolicies);

View file

@ -1586,6 +1586,7 @@ times.minutes=Minutes
disableUserInfo=Disable user info
authorizationEncryptedResponseEnc=Authorization response encryption content encryption algorithm
editCondition=Edit condition
viewCondition=View condition
ssoSessionMaxRememberMe=Max time before a session is expired when a user has set the remember me option. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Max value.
forcePostBinding=Force POST binding
usersExplain=Users are the users in the current realm.

View file

@ -14,6 +14,7 @@ import {
Flex,
FlexItem,
FormGroup,
Label,
PageSection,
Text,
TextVariants,
@ -67,7 +68,12 @@ export default function NewClientPolicy() {
const { t } = useTranslation();
const { realm } = useRealm();
const { addAlert, addError } = useAlerts();
const [isGlobalPolicy, setIsGlobalPolicy] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>();
const [globalPolicies, setGlobalPolicies] =
useState<ClientPolicyRepresentation[]>();
const [allPolicies, setAllPolicies] =
useState<ClientPolicyRepresentation[]>();
const [clientProfiles, setClientProfiles] = useState<
ClientProfileRepresentation[]
>([]);
@ -101,7 +107,9 @@ export default function NewClientPolicy() {
useFetch(
async () => {
const [policies, profiles] = await Promise.all([
adminClient.clientPolicies.listPolicies(),
adminClient.clientPolicies.listPolicies({
includeGlobalPolicies: true,
}),
adminClient.clientPolicies.listProfiles({
includeGlobalProfiles: true,
}),
@ -110,16 +118,29 @@ export default function NewClientPolicy() {
return { policies, profiles };
},
({ policies, profiles }) => {
const currentPolicy = policies.policies?.find(
let currentPolicy = policies.policies?.find(
(item) => item.name === policyName,
);
if (currentPolicy === undefined) {
currentPolicy = policies.globalPolicies?.find(
(item) => item.name === policyName,
);
setIsGlobalPolicy(currentPolicy !== undefined);
}
const allClientProfiles = [
...(profiles.globalProfiles ?? []),
...(profiles.profiles ?? []),
];
const allClientPolicies = [
...(policies.globalPolicies ?? []),
...(policies.policies ?? []),
];
setPolicies(policies.policies ?? []);
setGlobalPolicies(policies.globalPolicies ?? []);
setAllPolicies(allClientPolicies);
if (currentPolicy) {
setupForm(currentPolicy);
setClientProfiles(allClientProfiles);
@ -134,7 +155,7 @@ export default function NewClientPolicy() {
form.reset(policy);
};
const policy = (policies || []).filter(
const policy = (allPolicies || []).filter(
(policy) => policy.name === policyName,
);
const policyConditions = policy[0]?.conditions || [];
@ -151,8 +172,6 @@ export default function NewClientPolicy() {
const createdForm = form.getValues();
const createdPolicy = {
...createdForm,
profiles: [],
conditions: [],
};
const getAllPolicies = () => {
@ -279,6 +298,7 @@ export default function NewClientPolicy() {
policies: policies,
});
addAlert(t("deleteClientPolicyProfileSuccess"), AlertVariant.success);
form.setValue("profiles", currentPolicy?.profiles || []);
navigate(toEditClientPolicy({ realm, policyName: formValues.name! }));
} catch (error) {
addError(t("deleteClientPolicyProfileError"), error);
@ -346,6 +366,10 @@ export default function NewClientPolicy() {
policies: newPolicies,
});
setPolicies(newPolicies);
const allClientPolicies = [...(globalPolicies || []), ...newPolicies];
setAllPolicies(allClientPolicies);
setCurrentPolicy(createdPolicy);
form.setValue("profiles", createdPolicy.profiles);
navigate(toEditClientPolicy({ realm, policyName: formValues.name! }));
addAlert(t("addClientProfileSuccess"), AlertVariant.success);
} catch (error) {
@ -393,9 +417,20 @@ export default function NewClientPolicy() {
? policyName
: "createPolicy"
}
badges={[
{
id: "global-client-policy-badge",
text: isGlobalPolicy ? (
<Label color="blue">{t("global")}</Label>
) : (
""
),
},
]}
divider
dropdownItems={
showAddConditionsAndProfilesForm || policyName
(showAddConditionsAndProfilesForm || policyName) &&
!isGlobalPolicy
? [
<DropdownItem
key="delete"
@ -410,6 +445,7 @@ export default function NewClientPolicy() {
]
: undefined
}
isReadOnly={isGlobalPolicy}
isEnabled={field.value}
onToggle={(value) => {
if (!value) {
@ -455,7 +491,7 @@ export default function NewClientPolicy() {
variant="primary"
type="submit"
data-testid="saveCreatePolicy"
isDisabled={!form.formState.isValid}
isDisabled={!form.formState.isValid || isGlobalPolicy}
>
{t("save")}
</Button>
@ -463,7 +499,8 @@ export default function NewClientPolicy() {
id="cancelCreatePolicy"
variant="link"
onClick={() =>
showAddConditionsAndProfilesForm || policyName
(showAddConditionsAndProfilesForm || policyName) &&
!isGlobalPolicy
? reset()
: navigate(
toClientPolicies({
@ -474,7 +511,9 @@ export default function NewClientPolicy() {
}
data-testid="cancelCreatePolicy"
>
{showAddConditionsAndProfilesForm ? t("reload") : t("cancel")}
{showAddConditionsAndProfilesForm && !isGlobalPolicy
? t("reload")
: t("cancel")}
</Button>
</ActionGroup>
{(showAddConditionsAndProfilesForm ||
@ -490,26 +529,28 @@ export default function NewClientPolicy() {
/>
</Text>
</FlexItem>
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addCondition"
component={(props) => (
<Link
{...props}
to={toNewClientPolicyCondition({
realm,
policyName: policyName!,
})}
></Link>
)}
variant="link"
className="kc-addCondition"
data-testid="addCondition"
icon={<PlusCircleIcon />}
>
{t("addCondition")}
</Button>
</FlexItem>
{!isGlobalPolicy && (
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addCondition"
component={(props) => (
<Link
{...props}
to={toNewClientPolicyCondition({
realm,
policyName: policyName!,
})}
></Link>
)}
variant="link"
className="kc-addCondition"
data-testid="addCondition"
icon={<PlusCircleIcon />}
>
{t("addCondition")}
</Button>
</FlexItem>
)}
</Flex>
{policyConditions.length > 0 ? (
<DataList aria-label={t("conditions")} isCompact>
@ -552,24 +593,26 @@ export default function NewClientPolicy() {
helpText={type.helpText}
fieldLabelId={condition.condition}
/>
<Button
variant="link"
aria-label="remove-condition"
isInline
icon={
<TrashIcon
className="kc-conditionType-trash-icon"
data-testid={`delete-${condition.condition}-condition`}
onClick={() => {
toggleDeleteConditionDialog();
setConditionToDelete({
idx: idx,
name: type.id!,
});
}}
/>
}
></Button>
{!isGlobalPolicy && (
<Button
variant="link"
aria-label="remove-condition"
isInline
icon={
<TrashIcon
className="kc-conditionType-trash-icon"
data-testid={`delete-${condition.condition}-condition`}
onClick={() => {
toggleDeleteConditionDialog();
setConditionToDelete({
idx: idx,
name: type.id!,
});
}}
/>
}
></Button>
)}
</>
),
)}
@ -609,18 +652,20 @@ export default function NewClientPolicy() {
/>
</Text>
</FlexItem>
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addClientProfile"
variant="link"
className="kc-addClientProfile"
data-testid="addClientProfile"
icon={<PlusCircleIcon />}
onClick={toggleModal}
>
{t("addClientProfile")}
</Button>
</FlexItem>
{!isGlobalPolicy && (
<FlexItem align={{ default: "alignRight" }}>
<Button
id="addClientProfile"
variant="link"
className="kc-addClientProfile"
data-testid="addClientProfile"
icon={<PlusCircleIcon />}
onClick={toggleModal}
>
{t("addClientProfile")}
</Button>
</FlexItem>
)}
</Flex>
{policyProfiles.length > 0 ? (
<DataList aria-label={t("profiles")} isCompact>
@ -663,24 +708,26 @@ export default function NewClientPolicy() {
}
fieldLabelId={profile}
/>
<Button
variant="link"
aria-label="remove-client-profile"
isInline
icon={
<TrashIcon
className="kc-conditionType-trash-icon"
data-testid="deleteClientProfileDropdown"
onClick={() => {
toggleDeleteProfileDialog();
setProfileToDelete({
idx: idx,
name: type!,
});
}}
/>
}
></Button>
{!isGlobalPolicy && (
<Button
variant="link"
aria-label="remove-client-profile"
isInline
icon={
<TrashIcon
className="kc-conditionType-trash-icon"
data-testid="deleteClientProfileDropdown"
onClick={() => {
toggleDeleteProfileDialog();
setProfileToDelete({
idx: idx,
name: type!,
});
}}
/>
}
></Button>
)}
</>
))}
</DataListCell>,

View file

@ -18,12 +18,13 @@ import { camelCase } from "lodash-es";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { FormPanel, HelpItem } from "ui-shared";
import { Link, useNavigate, useParams } from "react-router-dom";
import { HelpItem } from "ui-shared";
import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts";
import { DynamicComponents } from "../components/dynamic/DynamicComponents";
import { FormAccess } from "../components/form/FormAccess";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { useFetch } from "../utils/useFetch";
@ -44,6 +45,7 @@ export default function NewClientPolicyCondition() {
const { realm } = useRealm();
const [openConditionType, setOpenConditionType] = useState(false);
const [isGlobalPolicy, setIsGlobalPolicy] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>([]);
const [condition, setCondition] = useState<
@ -72,15 +74,24 @@ export default function NewClientPolicyCondition() {
};
useFetch(
() => adminClient.clientPolicies.listPolicies(),
() =>
adminClient.clientPolicies.listPolicies({
includeGlobalPolicies: true,
}),
(policies) => {
setPolicies(policies.policies ?? []);
if (conditionName) {
const currentPolicy = policies.policies?.find(
let currentPolicy = policies.policies?.find(
(item) => item.name === policyName,
);
if (currentPolicy === undefined) {
currentPolicy = policies.globalPolicies?.find(
(item) => item.name === policyName,
);
setIsGlobalPolicy(currentPolicy !== undefined);
}
const typeAndConfigData = currentPolicy?.conditions?.find(
(item) => item.condition === conditionName,
@ -170,14 +181,22 @@ export default function NewClientPolicyCondition() {
};
return (
<PageSection variant="light">
<FormPanel
className="kc-login-screen"
title={conditionName ? t("editCondition") : t("addCondition")}
>
<>
<ViewHeader
titleKey={
conditionName
? isGlobalPolicy
? t("viewCondition")
: t("editCondition")
: t("addCondition")
}
divider
/>
<PageSection variant="light">
<FormAccess
isHorizontal
role="manage-realm"
isReadOnly={isGlobalPolicy}
className="pf-v5-u-mt-lg"
onSubmit={form.handleSubmit(save)}
>
@ -245,27 +264,48 @@ export default function NewClientPolicyCondition() {
<FormProvider {...form}>
<DynamicComponents properties={conditionProperties} />
</FormProvider>
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="addCondition-saveBtn"
isDisabled={conditionType === "" && !conditionName}
>
{conditionName ? t("save") : t("add")}
</Button>
<Button
variant="link"
data-testid="addCondition-cancelBtn"
onClick={() =>
navigate(toEditClientPolicy({ realm, policyName: policyName! }))
}
>
{t("cancel")}
</Button>
</ActionGroup>
{!isGlobalPolicy && (
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="addCondition-saveBtn"
isDisabled={
conditionType === "" && !conditionName && isGlobalPolicy
}
>
{conditionName ? t("save") : t("add")}
</Button>
<Button
variant="link"
data-testid="addCondition-cancelBtn"
onClick={() =>
navigate(
toEditClientPolicy({ realm, policyName: policyName! }),
)
}
>
{t("cancel")}
</Button>
</ActionGroup>
)}
</FormAccess>
</FormPanel>
</PageSection>
{isGlobalPolicy && (
<div className="kc-backToProfile">
<Button
component={(props) => (
<Link
{...props}
to={toEditClientPolicy({ realm, policyName: policyName! })}
/>
)}
variant="primary"
>
{t("back")}
</Button>
</div>
)}
</PageSection>
</>
);
}

View file

@ -7,6 +7,7 @@ import {
Divider,
Flex,
FlexItem,
Label,
PageSection,
Radio,
Switch,
@ -36,29 +37,48 @@ import { toEditClientPolicy } from "./routes/EditClientPolicy";
import "./realm-settings-section.css";
type ClientPolicy = ClientPolicyRepresentation & {
global?: boolean;
};
export const PoliciesTab = () => {
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const navigate = useNavigate();
const [show, setShow] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>();
const [selectedPolicy, setSelectedPolicy] =
useState<ClientPolicyRepresentation>();
const [policies, setPolicies] = useState<ClientPolicy[]>();
const [selectedPolicy, setSelectedPolicy] = useState<ClientPolicy>();
const [key, setKey] = useState(0);
const [code, setCode] = useState<string>();
const [tablePolicies, setTablePolicies] =
useState<ClientPolicyRepresentation[]>();
const [tablePolicies, setTablePolicies] = useState<ClientPolicy[]>();
const refresh = () => setKey(key + 1);
const form = useForm<Record<string, boolean>>({ mode: "onChange" });
useFetch(
() => adminClient.clientPolicies.listPolicies(),
(policies) => {
setPolicies(policies.policies),
setTablePolicies(policies.policies || []),
setCode(prettyPrintJSON(policies.policies));
() =>
adminClient.clientPolicies.listPolicies({
includeGlobalPolicies: true,
}),
(allPolicies) => {
const globalPolicies = allPolicies.globalPolicies?.map(
(globalPolicies) => ({
...globalPolicies,
global: true,
}),
);
const policies = allPolicies.policies?.map((policies) => ({
...policies,
global: false,
}));
const allClientPolicies = globalPolicies?.concat(policies ?? []);
setPolicies(allClientPolicies),
setTablePolicies(allClientPolicies || []),
setCode(prettyPrintJSON(allClientPolicies));
},
[key],
);
@ -68,16 +88,19 @@ export const PoliciesTab = () => {
const saveStatus = async () => {
const switchValues = form.getValues();
const updatedPolicies = policies?.map<ClientPolicyRepresentation>(
(policy) => {
const updatedPolicies = policies
?.filter((policy) => {
return !policy.global;
})
.map<ClientPolicyRepresentation>((policy) => {
const enabled = switchValues[policy.name!];
return {
const enabledPolicy = {
...policy,
enabled,
};
},
);
delete enabledPolicy.global;
return enabledPolicy;
});
try {
await adminClient.clientPolicies.updatePolicy({
@ -90,15 +113,13 @@ export const PoliciesTab = () => {
}
};
const ClientPolicyDetailLink = ({ name }: ClientPolicyRepresentation) => (
<Link to={toEditClientPolicy({ realm, policyName: name! })}>{name}</Link>
const ClientPolicyDetailLink = (row: ClientPolicy) => (
<Link to={toEditClientPolicy({ realm, policyName: row.name! })}>
{row.name} {row.global && <Label color="blue">{t("global")}</Label>}
</Link>
);
const SwitchRenderer = ({
clientPolicy,
}: {
clientPolicy: ClientPolicyRepresentation;
}) => {
const SwitchRenderer = ({ clientPolicy }: { clientPolicy: ClientPolicy }) => {
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "disablePolicyConfirmTitle",
messageKey: "disablePolicyConfirm",
@ -122,6 +143,7 @@ export const PoliciesTab = () => {
label={t("enabled")}
labelOff={t("disabled")}
isChecked={field.value}
isDisabled={clientPolicy.global}
onChange={(_event, value) => {
if (!value) {
toggleDisableDialog();
@ -144,7 +166,7 @@ export const PoliciesTab = () => {
}
try {
const obj: ClientPolicyRepresentation[] = JSON.parse(code);
const obj: ClientPolicy[] = JSON.parse(code);
try {
await adminClient.clientPolicies.updatePolicy({
@ -169,9 +191,15 @@ export const PoliciesTab = () => {
continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
const updatedPolicies = policies?.filter(
(policy) => policy.name !== selectedPolicy?.name,
);
const updatedPolicies = policies
?.filter((policy) => {
return !policy.global && policy.name !== selectedPolicy?.name;
})
.map<ClientPolicyRepresentation>((policy) => {
const newPolicy = { ...policy };
delete newPolicy.global;
return newPolicy;
});
try {
await adminClient.clientPolicies.updatePolicy({
@ -250,6 +278,7 @@ export const PoliciesTab = () => {
</Button>
</ToolbarItem>
}
isRowDisabled={(value) => !!value.global}
actions={[
{
title: t("delete"),
@ -257,7 +286,7 @@ export const PoliciesTab = () => {
toggleDeleteDialog();
setSelectedPolicy(item);
},
} as Action<ClientPolicyRepresentation>,
} as Action<ClientPolicy>,
]}
columns={[
{

View file

@ -4,5 +4,6 @@ import type ClientPolicyRepresentation from "./clientPolicyRepresentation.js";
* https://www.keycloak.org/docs-api/15.0/rest-api/#_clientpoliciesrepresentation
*/
export default interface ClientPoliciesRepresentation {
globalPolicies?: ClientPolicyRepresentation[];
policies?: ClientPolicyRepresentation[];
}

View file

@ -38,9 +38,16 @@ export class ClientPolicies extends Resource<{ realm?: string }> {
/* Client Policies */
public listPolicies = this.makeRequest<{}, ClientPoliciesRepresentation>({
public listPolicies = this.makeRequest<
{ includeGlobalPolicies?: boolean },
ClientPoliciesRepresentation
>({
method: "GET",
path: "/policies",
queryParamKeys: ["include-global-policies"],
keyTransform: {
includeGlobalPolicies: "include-global-policies",
},
});
public updatePolicy = this.makeRequest<ClientPoliciesRepresentation, void>({

View file

@ -0,0 +1,51 @@
/*
* Copyright 2024 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.securityprofile;
import java.util.List;
import org.keycloak.provider.Provider;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
/**
* The security profile provider is a default security configuration that enforces a
* minimum level of security in the keycloak environment. For the moment the class
* is just used for client policies but it can be extended for password policies
* or any other security configuration in the future.
*
* @author rmartinc
*/
public interface SecurityProfileProvider extends Provider {
/**
* Name of the security profile.
* @return The name
*/
String getName();
/**
* List of default client profiles that the security profile contains.
* @return The list of client profiles defined
*/
List<ClientProfileRepresentation> getDefaultClientProfiles();
/**
* List of default client policies defined in the security profile.
* @return The list of client policies defined
*/
List<ClientPolicyRepresentation> getDefaultClientPolicies();
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 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.securityprofile;
import org.keycloak.provider.ProviderFactory;
/**
*
* @author rmartinc
*/
public interface SecurityProfileProviderFactory extends ProviderFactory<SecurityProfileProvider>{
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2024 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.securityprofile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
*
* @author rmartinc
*/
public class SecurityProfileSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "security-profile";
}
@Override
public Class<? extends Provider> getProviderClass() {
return SecurityProfileProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return SecurityProfileProviderFactory.class;
}
}

View file

@ -99,3 +99,4 @@ org.keycloak.device.DeviceRepresentationSpi
org.keycloak.health.LoadBalancerCheckSpi
org.keycloak.cookie.CookieSpi
org.keycloak.organization.OrganizationSpi
org.keycloak.securityprofile.SecurityProfileSpi

View file

@ -93,9 +93,10 @@ public interface ClientPolicyManager extends Provider {
* when getting client policies via Admin REST API, returns the existing client policies set on the realm.
*
* @param realm - the realm whose client policies is to be returned
* @param includeGlobalPolicies - the json representation will include the default policies
* @return the json representation of the client policies set on the realm
*/
ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException;
ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException;
/**
* when exporting realm, or retrieve the realm for admin REST API, prepares the exported representation of the client profiles and policies.

View file

@ -18,8 +18,13 @@
package org.keycloak.services.clientpolicy;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
@ -27,10 +32,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.JsonConfigComponentModel;
@ -38,14 +40,15 @@ import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider;
import org.keycloak.util.JsonSerialization;
@ -58,6 +61,43 @@ public class ClientPoliciesUtil {
private static final Logger logger = Logger.getLogger(ClientPoliciesUtil.class);
public static InputStream getJsonFileFromClasspathOrConfFolder(String name) throws IOException {
final String fileName = name + ".json";
// first try to read the json configuration file from classpath
InputStream is = ClientPoliciesUtil.class.getResourceAsStream("/" + fileName);
if (is == null) {
Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(fileName);
if (!Files.isReadable(path)) {
throw new IOException(String.format("File \"%s\" does not exists under the config folder", path));
}
is = Files.newInputStream(path);
}
return is;
}
public static List<ClientProfileRepresentation> readGlobalClientProfilesRepresentation(KeycloakSession session, String name) throws ClientPolicyException {
if (name == null) {
return Collections.emptyList();
}
try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) {
return getValidatedGlobalClientProfilesRepresentation(session, is);
} catch (IOException e) {
throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e);
}
}
public static List<ClientPolicyRepresentation> readGlobalClientPoliciesRepresentation(KeycloakSession session, String name,
List<ClientProfileRepresentation> profiles) throws ClientPolicyException {
if (name == null) {
return Collections.emptyList();
}
try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) {
return getValidatedGlobalClientPoliciesRepresentation(session, is, profiles);
} catch (IOException e) {
throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e);
}
}
/**
* gets existing client profiles in a realm as representation.
* not return null.
@ -183,6 +223,24 @@ public class ClientPoliciesUtil {
return updatingProfileList;
}
/**
* get validated and modified global (built-in) client policies set on keycloak app as representation.
* it is loaded from json file enclosed in keycloak's binary.
* not return null.
*/
static List<ClientPolicyRepresentation> getValidatedGlobalClientPoliciesRepresentation(KeycloakSession session, InputStream is, List<ClientProfileRepresentation> profiles) throws ClientPolicyException {
ClientPoliciesRepresentation proposedPoliciesRep = null;
try {
proposedPoliciesRep = JsonSerialization.readValue(is, ClientPoliciesRepresentation.class);
} catch (Exception e) {
throw new ClientPolicyException("failed to deserialize global proposed client profiles json string.", e.getMessage());
}
if (proposedPoliciesRep == null) {
return Collections.emptyList();
}
return validatePolicies(session, proposedPoliciesRep.getPolicies(), profiles, Collections.emptyList());
}
/**
* convert client profiles as representation to json.
* can return null.
@ -240,7 +298,7 @@ public class ClientPoliciesUtil {
Set<String> globalProfileNames = globalClientProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
for (ClientProfileRepresentation clientProfile : proposedProfileRepList) {
if (globalProfileNames.contains(clientProfile.getName())) {
throw new ClientPolicyException("Proposed profile name duplicated as the name of some global profile");
throw new ClientPolicyException("Proposed profile name '" + clientProfile.getName() + "' is duplicated as a global profile");
}
}
@ -298,32 +356,42 @@ public class ClientPoliciesUtil {
return convertClientPoliciesJsonToRepresentation(policiesJson);
}
static List<ClientProfileRepresentation> getGlobalClientProfiles(KeycloakSession session) {
SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class);
return securityProfile.getDefaultClientProfiles();
}
static List<ClientPolicyRepresentation> getGlobalClientPolicies(KeycloakSession session) {
SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class);
return securityProfile.getDefaultClientPolicies();
}
/**
* Gets existing enabled client policies in a realm.
* not return null.
*/
static List<ClientPolicy> getEnabledClientPolicies(KeycloakSession session, RealmModel realm) {
// get the global policies defined in the security profile
List<ClientPolicyRepresentation> policiesRep = new ArrayList<>(getGlobalClientPolicies(session));
// get existing profiles as json
String policiesJson = getClientPoliciesJsonString(realm);
if (policiesJson == null) {
return Collections.emptyList();
if (policiesJson != null) {
// deserialize existing policies (json -> representation)
try {
policiesRep.addAll(convertClientPoliciesJsonToRepresentation(policiesJson).getPolicies());
} catch (ClientPolicyException e) {
logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail());
return Collections.emptyList();
}
}
// deserialize existing policies (json -> representation)
ClientPoliciesRepresentation policiesRep = null;
try {
policiesRep = convertClientPoliciesJsonToRepresentation(policiesJson);
} catch (ClientPolicyException e) {
logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail());
return Collections.emptyList();
}
if (policiesRep == null || policiesRep.getPolicies() == null) {
if (policiesRep.isEmpty()) {
return Collections.emptyList();
}
// constructing existing policies (representation -> model)
List<ClientPolicy> policyList = new ArrayList<>();
for (ClientPolicyRepresentation policyRep: policiesRep.getPolicies()) {
for (ClientPolicyRepresentation policyRep: policiesRep) {
// ignore policy without name
if (policyRep.getName() == null) {
logger.warnf("Ignored client policy without name in the realm %s", realm.getName());
@ -395,6 +463,80 @@ public class ClientPoliciesUtil {
}
}
/**
* Validates the policies passed with the profiles.
* @param session The session
* @param proposedPoliciesRepList The policies to validate
* @param profiles The already validated profiles used by the policies
* @param globalPolicies The global policies defined already validated
* @return The validated policies
* @throws ClientPolicyException Some error in the policies
*/
static private List<ClientPolicyRepresentation> validatePolicies(KeycloakSession session, List<ClientPolicyRepresentation> proposedPoliciesRepList,
List<ClientProfileRepresentation> profiles, List<ClientPolicyRepresentation> globalPolicies) throws ClientPolicyException {
// empty policies is valid
if (proposedPoliciesRepList == null || proposedPoliciesRepList.isEmpty()) {
return Collections.emptyList();
}
// check for duplicated names
if (proposedPoliciesRepList.size() != proposedPoliciesRepList.stream().map(i->i.getName()).distinct().count()) {
throw new ClientPolicyException("proposed client policy name duplicated");
}
// Conflict with any global policy is not allowed
Set<String> globalPolicyNames = globalPolicies.stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toSet());
for (ClientPolicyRepresentation clientPolicy : proposedPoliciesRepList) {
if (globalPolicyNames.contains(clientPolicy.getName())) {
throw new ClientPolicyException("Proposed policy name '" + clientPolicy.getName() + "' is duplicated as a global policy");
}
}
// construct validated and modified profiles from builtin profiles in JSON file enclosed in keycloak binary.
List<ClientPolicyRepresentation> updatingPolicyList = new LinkedList<>();
Set<String> profileNames = profiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRepList) {
if (proposedPolicyRep.getName() == null) {
throw new ClientPolicyException("client policy without name not allowed");
}
ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation();
policyRep.setName(proposedPolicyRep.getName());
policyRep.setDescription(proposedPolicyRep.getDescription());
policyRep.setEnabled(proposedPolicyRep.isEnabled() != null ? proposedPolicyRep.isEnabled() : Boolean.FALSE);
policyRep.setConditions(new ArrayList<>());
if (proposedPolicyRep.getConditions() != null) {
for (ClientPolicyConditionRepresentation condition : proposedPolicyRep.getConditions()) {
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) && !isValidCondition(session, condition.getConditionProviderId())) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid condition " + condition.getConditionProviderId());
}
policyRep.getConditions().add(condition);
}
}
policyRep.setProfiles(new ArrayList<>());
if (proposedPolicyRep.getProfiles() != null) {
if (proposedPolicyRep.getProfiles().size() != proposedPolicyRep.getProfiles().stream().distinct().count()) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains duplicated profiles");
}
for (String profile : proposedPolicyRep.getProfiles()) {
if (!profileNames.contains(profile)) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid profile " + profile);
}
policyRep.getProfiles().add(profile);
}
}
updatingPolicyList.add(policyRep);
}
return updatingPolicyList;
}
/**
* get validated and modified client policies as representation.
* it can be constructed by merging proposed client policies with existing client policies.
@ -405,73 +547,17 @@ public class ClientPoliciesUtil {
* @param proposedPoliciesRep
*/
static ClientPoliciesRepresentation getValidatedClientPoliciesForUpdate(KeycloakSession session, RealmModel realm,
ClientPoliciesRepresentation proposedPoliciesRep, List<ClientProfileRepresentation> existingGlobalProfiles) throws ClientPolicyException {
ClientPoliciesRepresentation proposedPoliciesRep, List<ClientProfileRepresentation> existingGlobalProfiles,
List<ClientPolicyRepresentation> existingGlobalPolicies) throws ClientPolicyException {
if (realm == null) {
throw new ClientPolicyException("realm not specified.");
}
// no policy contained (it is valid)
List<ClientPolicyRepresentation> proposedPolicyRepList = proposedPoliciesRep.getPolicies();
if (proposedPolicyRepList == null || proposedPolicyRepList.isEmpty()) {
proposedPolicyRepList = new ArrayList<>();
proposedPoliciesRep.setPolicies(new ArrayList<>());
}
// Policy without name not allowed
if (proposedPolicyRepList.stream().anyMatch(clientPolicy -> clientPolicy.getName() == null || clientPolicy.getName().isEmpty())) {
throw new ClientPolicyException("proposed client policy name missing.");
}
// duplicated policy name is not allowed.
if (proposedPolicyRepList.size() != proposedPolicyRepList.stream().map(i->i.getName()).distinct().count()) {
throw new ClientPolicyException("proposed client policy name duplicated.");
}
// construct updating policies from existing policies and proposed policies
ClientPoliciesRepresentation updatingPoliciesRep = new ClientPoliciesRepresentation();
updatingPoliciesRep.setPolicies(new ArrayList<>());
List<ClientPolicyRepresentation> updatingPoliciesList = updatingPoliciesRep.getPolicies();
for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRep.getPolicies()) {
// newly proposed builtin policy not allowed because builtin policy cannot added/deleted/modified.
Boolean enabled = (proposedPolicyRep.isEnabled() != null) ? proposedPolicyRep.isEnabled() : Boolean.FALSE;
// basically, proposed policy totally overrides existing policy except for enabled field..
ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation();
policyRep.setName(proposedPolicyRep.getName());
policyRep.setDescription(proposedPolicyRep.getDescription());
policyRep.setEnabled(enabled);
policyRep.setConditions(new ArrayList<>());
if (proposedPolicyRep.getConditions() != null) {
for (ClientPolicyConditionRepresentation conditionRep : proposedPolicyRep.getConditions()) {
if (!isValidCondition(session, conditionRep.getConditionProviderId())) {
throw new ClientPolicyException("the proposed client policy contains the condition with its invalid configuration.");
}
policyRep.getConditions().add(conditionRep);
}
}
Set<String> existingProfileNames = existingGlobalProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
ClientProfilesRepresentation reps = getClientProfilesRepresentation(session, realm);
policyRep.setProfiles(new ArrayList<>());
if (reps.getProfiles() != null) {
existingProfileNames.addAll(reps.getProfiles().stream()
.map(ClientProfileRepresentation::getName)
.collect(Collectors.toSet()));
}
if (proposedPolicyRep.getProfiles() != null) {
for (String profileName : proposedPolicyRep.getProfiles()) {
if (!existingProfileNames.contains(profileName)) {
logger.warnf("Client policy %s referred not existing profile %s");
throw new ClientPolicyException("referring not existing client profile not allowed.");
}
}
proposedPolicyRep.getProfiles().stream().distinct().forEach(profileName->policyRep.getProfiles().add(profileName));
}
updatingPoliciesList.add(policyRep);
}
List<ClientProfileRepresentation> allProfiles = new ArrayList<>(getClientProfilesRepresentation(session, realm).getProfiles());
allProfiles.addAll(existingGlobalProfiles);
updatingPoliciesRep.setPolicies(validatePolicies(session, proposedPoliciesRep.getPolicies(), allProfiles, existingGlobalPolicies));
return updatingPoliciesRep;
}

View file

@ -18,16 +18,13 @@
package org.keycloak.services.clientpolicy;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Supplier;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider;
@ -42,11 +39,9 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
private static final Logger logger = Logger.getLogger(DefaultClientPolicyManager.class);
private final KeycloakSession session;
private final Supplier<List<ClientProfileRepresentation>> globalClientProfilesSupplier;
public DefaultClientPolicyManager(KeycloakSession session, Supplier<List<ClientProfileRepresentation>> globalClientProfilesSupplier) {
public DefaultClientPolicyManager(KeycloakSession session) {
this.session = session;
this.globalClientProfilesSupplier = globalClientProfilesSupplier;
}
@Override
@ -143,7 +138,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm);
for (String profileName : policy.getProfiles()) {
ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles, globalClientProfilesSupplier.get(), profileName);
ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles,
ClientPoliciesUtil.getGlobalClientProfiles(session), profileName);
if (profile == null) {
logger.tracev("PROFILE NOT FOUND :: policy name = {0}, profile name = {1}", policy.getName(), profileName);
continue;
@ -212,7 +208,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
if (clientProfiles == null) {
throw new ClientPolicyException("Passing null clientProfiles not allowed");
}
ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles, globalClientProfilesSupplier.get());
ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles,
ClientPoliciesUtil.getGlobalClientProfiles(session));
String validatedJsonString = ClientPoliciesUtil.convertClientProfilesRepresentationToJson(validatedProfilesRep);
ClientPoliciesUtil.setClientProfilesJsonString(realm, validatedJsonString);
logger.tracev("UPDATE PROFILES :: realm = {0}, validated and modified PUT = {1}", realm.getName(), validatedJsonString);
@ -227,7 +224,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
try {
ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm);
if (includeGlobalProfiles) {
clientProfiles.setGlobalProfiles(new LinkedList<>(globalClientProfilesSupplier.get()));
clientProfiles.setGlobalProfiles(ClientPoliciesUtil.getGlobalClientProfiles(session));
}
if (logger.isTraceEnabled()) {
@ -250,7 +247,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
if (clientPolicies == null) {
throw new ClientPolicyException("Passing null clientPolicies not allowed");
}
ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies, globalClientProfilesSupplier.get());
ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies,
ClientPoliciesUtil.getGlobalClientProfiles(session), ClientPoliciesUtil.getGlobalClientPolicies(session));
validatedJsonString = ClientPoliciesUtil.convertClientPoliciesRepresentationToJson(clientPoliciesRep);
} catch (ClientPolicyException e) {
logger.warnv("VALIDATE SERIALIZE POLICIES FAILED :: error = {0}, error detail = {1}", e.getError(), e.getErrorDetail());
@ -261,9 +259,12 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
}
@Override
public ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException {
public ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException {
try {
ClientPoliciesRepresentation clientPolicies = ClientPoliciesUtil.getClientPoliciesRepresentation(session, realm);
if (includeGlobalPolicies) {
clientPolicies.setGlobalPolicies(ClientPoliciesUtil.getGlobalClientPolicies(session));
}
if (logger.isTraceEnabled()) {
logger.tracev("GET POLICIES :: realm = {0}, GET = {1}", realm.getName(), JsonSerialization.writeValueAsString(clientPolicies));
}
@ -283,7 +284,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
ClientProfilesRepresentation filteredOutProfiles = getClientProfiles(realm, false);
rep.setParsedClientProfiles(filteredOutProfiles);
ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm);
ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm, false);
rep.setParsedClientPolicies(filteredOutPolicies);
} catch (ClientPolicyException cpe) {
throw new IllegalStateException("Exception during export client profiles or client policies", cpe);

View file

@ -18,13 +18,10 @@
package org.keycloak.services.clientpolicy;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.idm.ClientProfileRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -33,14 +30,9 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac
private static final Logger logger = Logger.getLogger(DefaultClientPolicyManagerFactory.class);
// Global (builtin) profiles are loaded on booting keycloak at once.
// therefore, their representations are kept and remain unchanged.
// these are shared among all realms.
private volatile List<ClientProfileRepresentation> globalClientProfiles;
@Override
public ClientPolicyManager create(KeycloakSession session) {
return new DefaultClientPolicyManager(session, () -> getGlobalClientProfiles(session));
return new DefaultClientPolicyManager(session);
}
@Override
@ -62,26 +54,4 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac
public String getId() {
return "default";
}
/**
* When this method is called, assumption is that CLIENT_POLICIES feature is enabled
*/
protected List<ClientProfileRepresentation> getGlobalClientProfiles(KeycloakSession session) {
if (globalClientProfiles == null) {
synchronized (this) {
if (globalClientProfiles == null) {
logger.trace("LOAD GLOBAL CLIENT PROFILES ON KEYCLOAK");
// load builtin profiles from keycloak-services
try {
this.globalClientProfiles = ClientPoliciesUtil.getValidatedGlobalClientProfilesRepresentation(session, getClass().getResourceAsStream("/keycloak-default-client-profiles.json"));
} catch (ClientPolicyException cpe) {
logger.warnv("LOAD GLOBAL PROFILES ON KEYCLOAK FAILED :: error = {0}, error detail = {1}", cpe.getError(), cpe.getErrorDetail());
throw new IllegalStateException(cpe);
}
}
}
}
return globalClientProfiles;
}
}

View file

@ -18,11 +18,11 @@
package org.keycloak.services.resources.admin;
import org.eclipse.microprofile.openapi.annotations.Operation;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@ -66,11 +66,11 @@ public class ClientPoliciesResource {
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN)
@Operation()
public ClientPoliciesRepresentation getPolicies() {
public ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") boolean includeGlobalPolicies) {
auth.realm().requireViewRealm();
try {
return session.clientPolicy().getClientPolicies(realm);
return session.clientPolicy().getClientPolicies(realm, includeGlobalPolicies);
} catch (ClientPolicyException e) {
throw ErrorResponse.error(e.getError(), Response.Status.BAD_REQUEST);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 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.securityprofile;
import java.util.List;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.SecurityProfileConfiguration;
import org.keycloak.securityprofile.SecurityProfileProvider;
/**
*
* @author rmartinc
*/
public class DefaultSecurityProfileProvider implements SecurityProfileProvider {
private SecurityProfileConfiguration config;
public DefaultSecurityProfileProvider(SecurityProfileConfiguration config) {
this.config = config;
}
@Override
public String getName() {
return config.getName();
}
@Override
public List<ClientProfileRepresentation> getDefaultClientProfiles() {
return config.getDefaultClientProfiles();
}
@Override
public List<ClientPolicyRepresentation> getDefaultClientPolicies() {
return config.getDefaultClientPolicies();
}
@Override
public void close() {
config = null;
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2024 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.securityprofile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.idm.SecurityProfileConfiguration;
import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.securityprofile.SecurityProfileProviderFactory;
import org.keycloak.services.clientpolicy.ClientPoliciesUtil;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.util.JsonSerialization;
/**
* The default implementation for the security profile. It reads the configuration
* from the file configured.
* @author rmartinc
*/
public class DefaultSecurityProfileProviderFactory implements SecurityProfileProviderFactory {
private static final Logger logger = Logger.getLogger(DefaultSecurityProfileProviderFactory.class);
private String name;
private volatile SecurityProfileConfiguration configuration;
@Override
public SecurityProfileProvider create(KeycloakSession session) {
return new DefaultSecurityProfileProvider(readConfiguration(session));
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name("name")
.type("string")
.helpText("Name for the security configuration file to use. File `name`.json is searched in classapth and `conf` installation folder.")
.add()
.build();
}
@Override
public void init(Config.Scope config) {
this.name = config.get("name", "none-security-profile");
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return "default";
}
protected SecurityProfileConfiguration readConfiguration(KeycloakSession session) {
if (configuration == null) {
synchronized (this) {
SecurityProfileConfiguration conf;
final String file = name + ".json";
try {
// first try to read the json configuration file from classpath
InputStream tmp = getClass().getResourceAsStream("/" + file);
if (tmp == null) {
Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(file);
if (!Files.isReadable(path)) {
throw new IOException(String.format("File %s does not exists in the conf folder", file));
}
tmp = Files.newInputStream(path);
}
try (InputStream is = tmp) {
conf = JsonSerialization.readValue(is, SecurityProfileConfiguration.class);
}
// read the list of client profiles and policies validated
conf.setDefaultClientProfiles(ClientPoliciesUtil.readGlobalClientProfilesRepresentation(session, conf.getClientProfiles()));
conf.setDefaultClientPolicies(ClientPoliciesUtil.readGlobalClientPoliciesRepresentation(session, conf.getClientPolicies(),
conf.getDefaultClientProfiles()));
} catch (ClientPolicyException|IOException e) {
throw new IllegalStateException("Error loading the security profile from file " + file, e);
}
this.configuration = conf;
}
}
return this.configuration;
}
}

View file

@ -0,0 +1 @@
org.keycloak.services.securityprofile.DefaultSecurityProfileProviderFactory

View file

@ -0,0 +1,68 @@
{
"policies": [
{
"name": "Openid-connect OAuth 2.1 confidential client",
"description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "openid-connect"
}
},
{
"condition": "client-access-type",
"configuration": {
"type": [
"confidential"
]
}
}
],
"profiles": [
"oauth-2-1-for-confidential-client"
]
},
{
"name": "Openid-connect OAuth 2.1 public client",
"description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "openid-connect"
}
},
{
"condition": "client-access-type",
"configuration": {
"type": [
"public"
]
}
}
],
"profiles": [
"oauth-2-1-for-public-client"
]
},
{
"name": "Saml secure client (signatures, post, https)",
"description": "Saml policy to ensure signatures, POST binding and https URLs.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "saml"
}
}
],
"profiles": [
"saml-security-profile"
]
}
]
}

View file

@ -0,0 +1,5 @@
{
"name":"lax",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":null
}

View file

@ -0,0 +1,5 @@
{
"name":"none",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":null
}

View file

@ -0,0 +1,5 @@
{
"name":"strict",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":"keycloak-strict-client-policies"
}

View file

@ -0,0 +1,81 @@
/*
* Copyright 2024 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.securityprofile;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.keycloak.common.Profile;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.crypto.CryptoProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.securityprofile.SecurityProfileProviderFactory;
import org.keycloak.services.resteasy.ResteasyKeycloakSession;
import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory;
import org.keycloak.utils.ScopeUtil;
/**
*
* @author rmartinc
*/
@RunWith(Parameterized.class)
public class DefaultSecurityProfileProverFactoryTest {
private static KeycloakSession session;
private final String name;
@Parameters
public static Collection<Object[]> data() {
// return of json profile files packed with keycloak
return Arrays.asList(new Object[][]{
{"none-security-profile"},
{"lax-security-profile"},
{"strict-security-profile"},
});
}
public DefaultSecurityProfileProverFactoryTest(String name) {
this.name = name;
}
@BeforeClass
public static void beforeClass() {
Profile.defaults();
CryptoIntegration.init(CryptoProvider.class.getClassLoader());
ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory();
sessionFactory.init();
session = new ResteasyKeycloakSession(sessionFactory);
}
@Test
public void testConfigurationFile() {
SecurityProfileProviderFactory fact = new DefaultSecurityProfileProviderFactory();
fact.init(ScopeUtil.createScope(Collections.singletonMap("name", name)));
SecurityProfileProvider prov = fact.create(session);
Assert.assertNotNull(prov.getName());
Assert.assertNotNull(prov.getDefaultClientProfiles());
Assert.assertNotNull(prov.getDefaultClientPolicies());
}
}

View file

@ -35,4 +35,10 @@ public @interface SetDefaultProvider {
boolean beforeEnableFeature() default true;
String defaultProvider() default "";
/**
* Configuration for the provider in the form option1, value1, option2, value2
* @return The config options and values
*/
String[] config() default {};
}

View file

@ -9,9 +9,12 @@ import org.keycloak.testsuite.arquillian.containers.AbstractQuarkusDeployableCon
import org.keycloak.utils.StringUtil;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class SpiProvidersSwitchingUtils {
@ -28,24 +31,58 @@ public class SpiProvidersSwitchingUtils {
}
@Override
public void setDefaultProvider(Container container, String spiName, String providerId) {
public void setDefaultProvider(Container container, String spiName, String providerId, String... config) {
System.setProperty(getProviderPropertyName(spiName), providerId);
if (config != null) {
String optionName = null;
for (String c : config) {
if (optionName == null) {
optionName = c;
} else {
System.setProperty(getProviderPropertyNameConfig(spiName, providerId, optionName), c);
optionName = null;
}
}
}
}
@Override
public void removeProviderConfig(Container container, String spiName) {
System.clearProperty(getProviderPropertyName(spiName));
List<String> toRemove = System.getProperties().stringPropertyNames().stream()
.filter(p -> p.startsWith(getProviderPropertyNamePrefix(spiName)))
.collect(Collectors.toList());
toRemove.forEach(p -> System.clearProperty(p));
}
private String getProviderPropertyNamePrefix(String spiName) {
return "keycloak." + spiName + ".";
}
private String getProviderPropertyName(String spiName) {
return "keycloak." + spiName + ".provider";
return getProviderPropertyNamePrefix(spiName) + "provider";
}
private String getProviderPropertyNameConfig(String spiName, String providerId, String configName) {
return getProviderPropertyNamePrefix(spiName) + providerId + "." + configName;
}
},
QUARKUS {
@Override
public void setDefaultProvider(Container container, String spiName, String providerId) {
getQuarkusContainer(container).setAdditionalBuildArgs(Collections
.singletonList(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId));
public void setDefaultProvider(Container container, String spiName, String providerId, String... config) {
List<String> args = new LinkedList<>();
args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId);
if (config != null) {
String optionName = null;
for (String c : config) {
if (optionName == null) {
optionName = c;
} else {
args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-" + providerId + "-" + optionName + "=" + c);
optionName = null;
}
}
}
getQuarkusContainer(container).setAdditionalBuildArgs(args);
}
@Override
@ -92,10 +129,10 @@ public class SpiProvidersSwitchingUtils {
return Optional.empty();
}
public abstract void setDefaultProvider(Container container, String spiName, String providerId);
public abstract void setDefaultProvider(Container container, String spiName, String providerId, String... config);
public void updateDefaultProvider(Container container, String spiName, String providerId) {
setDefaultProvider(container, spiName, providerId);
public void updateDefaultProvider(Container container, String spiName, String providerId, String... config) {
setDefaultProvider(container, spiName, providerId, config);
}
public void unsetDefaultProvider(Container container, String spiName) {
@ -125,9 +162,9 @@ public class SpiProvidersSwitchingUtils {
if (annotation.onlyUpdateDefault()) {
spiSwitcher.getCurrentDefaultProvider(container, spi, annotation).ifPresent(v -> originalSettingsBackup.put(spi, v));
spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId());
spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId(), annotation.config());
} else {
spiSwitcher.setDefaultProvider(container, spi, annotation.providerId());
spiSwitcher.setDefaultProvider(container, spi, annotation.providerId(), annotation.config());
}
}

View file

@ -304,7 +304,6 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
createClientScopesConditionConfig(ClientScopesConditionFactory.OPTIONAL, Arrays.asList(SAMPLE_CLIENT_ROLE)))
.addProfile("ordinal-test-profile")
.addProfile("lack-of-builtin-field-test-profile")
.addProfile("ordinal-test-profile")
.toRepresentation();

View file

@ -0,0 +1,200 @@
/*
* Copyright 2024 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.testsuite.securityprofile;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.stream.Collectors;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider;
import org.keycloak.testsuite.auth.page.login.OIDCLogin;
import org.keycloak.testsuite.util.ClientBuilder;
@SetDefaultProvider(spi = "security-profile", providerId = "default", config = {"name", "strict-security-profile"})
public class StrictSecurityProfileTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
// no-op
}
@Test
public void testGlobalClientPolicies() {
RealmResource realm = testRealm();
// test there are policies defined in the endpoint
ClientPoliciesRepresentation policies = realm.clientPoliciesPoliciesResource().getPolicies(true);
Assert.assertNotNull(policies.getGlobalPolicies());
MatcherAssert.assertThat(policies.getGlobalPolicies().stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toList()),
Matchers.containsInAnyOrder("Openid-connect OAuth 2.1 confidential client",
"Openid-connect OAuth 2.1 public client",
"Saml secure client (signatures, post, https)"));
// try creating a global policy fails
ClientPoliciesRepresentation policiesRep = new ClientPoliciesRepresentation();
policiesRep.setPolicies(Collections.singletonList(policies.getGlobalPolicies().iterator().next()));
BadRequestException e = Assert.assertThrows(BadRequestException.class,
() -> realm.clientPoliciesPoliciesResource().updatePolicies(policiesRep));
ErrorRepresentation error = e.getResponse().readEntity(ErrorRepresentation.class);
MatcherAssert.assertThat(error.getErrorMessage(), Matchers.containsString("duplicated as a global policy"));
}
@Test
public void testCreatePublicOpenIdConnectClientSecure() {
RealmResource realm = testRealm();
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.publicClient()
.protocol(OIDCLogin.OIDC)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.build();
clientRep.setImplicitFlowEnabled(true);
OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org"));
wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN);
// set the redirect uri to unsecure http
clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org"));
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError());
Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription());
clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org"));
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
String id = ApiUtil.getCreatedId(resp);
getCleanup().addClientUuid(id);
// check everything is auto-configure for security as the policy has auto-configure
clientRep = realm.clients().get(id).toRepresentation();
wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod());
Assert.assertFalse(clientRep.isImplicitFlowEnabled());
Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled());
}
@Test
public void testCreateConfidentialOpenIdConnectClientSecure() {
RealmResource realm = testRealm();
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.protocol(OIDCLogin.OIDC)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.directAccessGrants()
.build();
clientRep.setImplicitFlowEnabled(true);
OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org"));
wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN);
wrapper.setUseMtlsHoKToken(false);
// set the redirect uri to unsecure http
clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org"));
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError());
Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription());
clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org"));
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
String id = ApiUtil.getCreatedId(resp);
getCleanup().addClientUuid(id);
// check everything is auto-configure for security as the policy has auto-configure
clientRep = realm.clients().get(id).toRepresentation();
wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod());
Assert.assertTrue(wrapper.isUseMtlsHokToken());
Assert.assertFalse(clientRep.isImplicitFlowEnabled());
Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled());
}
@Test
public void testCreateSamlClientSecure() {
RealmResource realm = testRealm();
// setup a perfect client for SAML secure
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.protocol(OIDCLogin.SAML)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.adminUrl("https://www.keycloak.org")
.attribute(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true")
.attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true")
.attribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true")
.build();
// change base url to unsecure http
clientRep.setBaseUrl("http://www.keycloak.org");
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
MatcherAssert.assertThat(error.getErrorDescription(), Matchers.startsWith("Non secure scheme for"));
clientRep.setBaseUrl("https://www.keycloak.org");
// change force post to false
clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "false");
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
Assert.assertEquals("Force POST binding is not enabled", error.getErrorDescription());
clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true");
// remove client signature
clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false");
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
Assert.assertEquals("Signatures not ensured for the client. Ensure Client signature required and Sign documents or Sign assertions are ON",
error.getErrorDescription());
clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true");
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
getCleanup().addClientUuid(ApiUtil.getCreatedId(resp));
}
}

View file

@ -265,5 +265,12 @@
"saml-artifact-resolver": {
"provider": "${keycloak.saml-artifact-resolver.provider:default}"
},
"security-profile": {
"provider": "${keycloak.security-profile.provider:default}",
"default": {
"name":"${keycloak.security-profile.default.name:none-security-profile}"
}
}
}

View file

@ -35,6 +35,7 @@ providers,4
runonserver,6
saml,6
script,6
securityprofile,4
session,6
sessionlimits,6
ssl,6

View file

@ -184,6 +184,12 @@
"sslCertChainPrefix": "x-ssl-client-cert-chain",
"certificateChainLength": 1
}
}
},
"security-profile": {
"provider": "${keycloak.security-profile.provider:default}",
"default": {
"name":"${keycloak.security-profile.default.name:none-security-profile}"
}
}
}