8000 UI should prevent admins from defining a dynamic client scope with type Default by mabartos · Pull Request #48973 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1688,7 +1688,8 @@ effectiveRoleScopeMappings=Effective role scope mappings
clientAuthenticator=Client Authenticator
importAdded_other={{count}} records added.
oAuthDeviceCodeLifespanHelp=Max time before the device code and user code are expired. This value needs to be a long enough lifetime to be usable (allowing the user to retrieve their secondary device, navigate to the verification URI, login, etc.), but should be sufficiently short to limit the usability of a code obtained for phishing.
dynamicScopeHelp=If on, this scope will be considered a Dynamic Scope, which will be comprised of a static and a variable portion.
dynamicScopeHelp=If on, this scope will be considered a Dynamic Scope, which will be comprised of a static and a variable portion. Currently, dynamic scopes cannot be assigned as Default type.
dynamicScopeDisabledInfo=This scope was previously configured as dynamic, but the Dynamic Scopes feature is currently disabled. Saving will convert it to a regular scope, or consider removing it.
attributePermissionDescription=This section contains permissions for who can edit and who can view the attribute.
providerDetails=Provider details
groupDeleteError=Error deleting group {{error}}
Expand Down Expand Up @@ -2425,7 +2426,7 @@ downloadAdaptorTitle=Download adaptor configs
client-roles.label=Client Roles
keysFilter.PASSIVE=Passive keys
revocation=Revocation
scopeTypeHelp=Client scopes, which will be added as default scopes to each created client
scopeTypeHelp=Determines how the client scope is assigned to new clients. Default scopes are automatically included in token requests. Optional scopes are only included when explicitly requested. None means the scope must be manually added to each client.
search=Search
validateEditMode=You must select an edit mode
copyFlowSuccess=Flow successfully duplicated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const ChangeTypeDropdown = ({
{clientScopeTypesSelectOptions(
t,
!clientId ? allClientScopeTypes : undefined,
selectedRows,
)}
</SelectList>
</Select>
Expand Down
19 changes: 12 additions & 7 deletions js/apps/admin-ui/src/client-scopes/ClientScopesSection.tsx
8000
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
ClientScope,
ClientScopeDefaultOptionalType,
changeScope,
DynamicScopeLabel,
isDynamicScope,
removeScope,
} from "../components/client-scope/ClientScopeTypes";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
Expand Down Expand Up @@ -75,15 +77,18 @@ const TypeSelector = (scope: TypeSelectorProps) => {
);
};

const ClientScopeDetailLink = ({
id,
name,
}: ClientScopeDefaultOptionalType) => {
const ClientScopeDetailLink = (scope: ClientScopeDefaultOptionalType) => {
const { realm } = useRealm();
return (
<Link key={id} to={toClientScope({ realm, id: id!, tab: "settings" })}>
{name}
</Link>
<>
<Link
key={scope.id}
to={toClientScope({ realm, id: scope.id!, tab: "settings" })}
>
{scope.name}
</Link>{" "}
{isDynamicScope(scope) && <DynamicScopeLabel />}
</>
);
};

Expand Down
40 changes: 38 additions & 2 deletions js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/
import type { KeyMetadataRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/keyMetadataRepresentation";
import {
ActionGroup,
Alert,
Button,
FormHelperText,
HelperText,
Expand All @@ -23,6 +24,7 @@ import { useAdminClient } from "../../admin-client";
import { getProtocolName } from "../../clients/utils";
import { DefaultSwitchControl } from "../../components/SwitchControl";
import {
ClientScope,
allClientScopeTypes,
ClientScopeDefaultOptionalType,
} from "../../components/client-scope/ClientScopeTypes";
Expand Down Expand Up @@ -153,6 +155,14 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
defaultValue: "false",
});

const isDynamic = isDynamicScopesEnabled && dynamicScope === "true";
const isDynamicScopeWithFeatureDisabled =
!isDynamicScopesEnabled &&
clientScope?.attributes?.["is.dynamic.scope"] === "true";
const scopeTypeOptions = isDynamic
? allClientScopeTypes.filter((key) => key !== "default")
: allClientScopeTypes;

const selectedProtocol = useWatch({
control,
name: "protocol",
Expand Down Expand Up @@ -220,6 +230,18 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
convertToFormValues(clientScope ?? {}, setValue);
}, [clientScope, setValue]);

useEffect(() => {
if (isDynamicScopeWithFeatureDisabled) {
setValue(
convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.is.dynamic.scope",
),
"false",
{ shouldDirty: true, shouldValidate: true },
);
}
}, [setValue, isDynamicScopeWithFeatureDisabled]);

useEffect(() => {
if (isSigningKeySelected) {
const selectedKeyInfo = realmKeys.find(
Expand Down Expand Up @@ -265,6 +287,14 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
},
}}
/>
{isDynamicScopeWithFeatureDisabled && (
<Alert
variant="warning"
isInline
isPlain
title={t("dynamicScopeDisabledInfo")}
/>
)}
{isDynamicScopesEnabled && (
<>
<DefaultSwitchControl
Expand All @@ -278,6 +308,9 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
value ? form.getValues("name") || "" : "",
value,
);
if (value && form.getValues("type") === ClientScope.default) {
setValue("type", ClientScope.optional, { shouldDirty: true });
}
}}
stringify
/>
Expand Down Expand Up @@ -310,7 +343,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
label={t("type")}
labelIcon={t("scopeTypeHelp")}
controller={{ defaultValue: allClientScopeTypes[0] }}
options={allClientScopeTypes.map((key) => ({
options={scopeTypeOptions.map((key) => ({
key,
value: t(`clientScopeType.${key}`),
}))}
Expand Down Expand Up @@ -660,7 +693,10 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
<FormSubmitButton
data-testid="save"
formState={formState}
disabled={!isDirty || !isValid}
allowNonDirty={isDynamicScopeWithFeatureDisabled}
isDisabled={
!(isDirty || isDynamicScopeWithFeatureDisabled) || !isValid
}
>
{t("save")}
</FormSubmitButton>
Expand Down
9 changes: 8 additions & 1 deletion js/apps/admin-ui/src/clients/scopes/AddScopeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
import {
ClientScopeType,
clientScopeTypesDropdown,
DynamicScopeLabel,
isDynamicScope,
} from "../../components/client-scope/ClientScopeTypes";
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
Expand Down Expand Up @@ -212,7 +214,7 @@ export const AddScopeDialog = ({
)}
>
<DropdownList>
{clientScopeTypesDropdown(t, action)}
{clientScopeTypesDropdown(t, action, rows)}
</DropdownList>
</Dropdown>,
<Button
Expand Down Expand Up @@ -317,6 +319,11 @@ export const AddScopeDialog = ({
columns={[
{
name: "name",
cellRenderer: (row) => (
<>
{row.name} {isDynamicScope(row) && <DynamicScopeLabel />}
</>
),
},
{
name: "protocol",
Expand Down
10 changes: 9 additions & 1 deletion js/apps/admin-ui/src/clients/scopes/ClientScopes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
ClientScope,
addClientScope,
changeClientScope,
DynamicScopeLabel,
isDynamicScope,
removeClientScope,
} from "../../components/client-scope/ClientScopeTypes";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
Expand Down Expand Up @@ -158,6 +160,7 @@ export const ClientScopes = ({
...c,
type: ClientScope.optional,
description: scope?.description,
attributes: scope?.attributes,
};
return row;
});
Expand All @@ -168,6 +171,7 @@ export const ClientScopes = ({
...c,
type: ClientScope.default,
description: scope?.description,
attributes: scope?.attributes,
};
return row;
});
Expand Down Expand Up @@ -372,7 +376,11 @@ export const ClientScopes = ({
</Link>
);
}
return row.name!;
return (
<>
{row.name} {isDynamicScope(row) && <DynamicScopeLabel />}
</>
);
},
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
10BC8 import { toUpperCase } from "../../util";
import {
DropdownItem,
Label,
MenuToggle,
Select,
SelectOption,
Expand All @@ -31,11 +32,29 @@ export const allClientScopeTypes = Object.keys({
...ClientScope,
}) as AllClientScopeType[];

export const isDynamicScope = (scope: ClientScopeRepresentation) =>
scope.attributes?.["is.dynamic.scope"] === "true";

export const DynamicScopeLabel = () => (
<Label color="gold" isCompact>
dynamic
</Label>
);

const filterDefaultForDynamic = (
types: string[],
scopes: ClientScopeRepresentation[],
) =>
scopes.some(isDynamicScope)
? types.filter((t) => t !== ClientScope.default)
: types;

export const clientScopeTypesSelectOptions = (
t: TFunction,
scopeTypes: string[] | undefined = clientScopeTypes,
scopes: ClientScopeRepresentation[] = [],
) =>
scopeTypes.map((type) => (
filterDefaultForDynamic(scopeTypes, scopes).map((type) => (
<SelectOption key={type} value={type}>
{t(`clientScopeType.${type}`)}
</SelectOption>
Expand All @@ -44,8 +63,9 @@ export const clientScopeTypesSelectOptions = (
export const clientScopeTypesDropdown = (
t: TFunction,
onClick: (scope: ClientScopeType) => void,
scopes: ClientScopeRepresentation[] = [],
) =>
clientScopeTypes.map((type) => (
filterDefaultForDynamic(clientScopeTypes, scopes).map((type) => (
<DropdownItem key={type} => onClick(type as ClientScopeType)}>
{t(`clientScopeType.${type}`)}
</DropdownItem>
Expand All @@ -70,6 +90,9 @@ export const CellDropdown = ({
const { t } = useTranslation();
const [open, setOpen] = useState(false);

const types = all ? allClientScopeTypes : clientScopeTypes;
const filteredTypes = filterDefaultForDynamic(types, [clientScope]);

return (
<Select
key={clientScope.id}
Expand All @@ -96,10 +119,7 @@ export const CellDropdown = ({
}}
{...props}
>
{clientScopeTypesSelectOptions(
t,
all ? allClientScopeTypes : clientScopeTypes,
)}
{clientScopeTypesSelectOptions(t, filteredTypes)}
</Select>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ private void addDefaultClientScope(String clientScopeId, boolean defaultScope) {
if (clientScope == null) {
throw new jakarta.ws.rs.NotFoundException("Client scope not found");
}
// Dynamic scopes currently require the caller to explicitly provide the scope parameter (e.g. "scope_name:value"),
// so they cannot be included automatically as default scopes. This restriction may be lifted in the future.
if (defaultScope && clientScope.isDynamicScope()) {
throw new ErrorResponseException("invalid_request", "Can't assign a Dynamic Scope to a Client as a Default Scope", Response.Status.BAD_REQUEST);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ public static void validateClientScope(KeycloakSession session, ClientScopeRepre
}

/**
* Makes sure that an update that makes a Client Scope Dynamic is rejected if the Client Scope is assigned to a client
* as a default scope.
* Makes sure that an update that makes a Client Scope Dynamic is rejected if the Client Scope is assigned
* as a default scope — either to a client or as a realm-level default.
* @param rep the {@link ClientScopeRepresentation} with the changes from the frontend.
*/
public void validateDynamicScopeUpdate(ClientScopeRepresentation rep) {
Expand All @@ -277,6 +277,16 @@ public void validateDynamicScopeUpdate(ClientScopeRepresentation rep) {
throw ErrorResponse.error("This Client Scope can't be made dynamic as it's assigned to a Client as a Default Scope",
Response.Status.BAD_REQUEST);
}

// Also check realm-level default scopes — a scope that is a realm default would be automatically
// assigned to new clients as a default scope, which is incompatible with dynamic scopes.
boolean isRealmDefault = realm.getDefaultClientScopesStream(true)
.map(ClientScopeModel::getId)
.anyMatch(scopeId -> scopeId.equalsIgnoreCase(this.clientScope.getId()));
if (isRealmDefault) {
throw ErrorResponse.error("This Client Scope can't be made dynamic as it's assigned as a Realm Default Scope",
Response.Status.BAD_REQUEST);
}
}
// after the previous validation, run the usual Dynamic Scope validations.
validateDynamicClientScope(rep);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,13 @@ private void addDefaultClientScope(String clientScopeId, boolean defaultScope) {
if (clientScope == null) {
throw new NotFoundException("Client scope not found");
}


// Dynamic scopes currently require the caller to explicitly provide the scope parameter (e.g. "scope_name:value"),
// so they cannot be included automatically as default scopes. This restriction may be lifted in the future.
if (defaultScope && clientScope.isDynamicScope()) {
throw ErrorResponse.error("Can't assign a Dynamic Scope as a Default Scope", Status.BAD_REQUEST);
}

ClientResource.validateClientScopeAssignment(session, clientScope, defaultScope, realm);

realm.addDefaultClientScope(clientScope, defaultScope);
Expand Down
Loading
Loading
0