Skip to content
Open
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 @@ -40,6 +40,7 @@ public class AuthenticationExecutionInfoRepresentation implements Serializable {
protected int level;
protected int index;
protected int priority;
protected Boolean providerUnavailable;

public String getId() {
return id;
Expand Down Expand Up @@ -152,4 +153,12 @@ public int getPriority() {
public void setPriority(int priority) {
this.priority = priority;
}

public Boolean getProviderUnavailable() {
return providerUnavailable;
}

public void setProviderUnavailable(Boolean providerUnavailable) {
this.providerUnavailable = providerUnavailable;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2104,6 +2104,9 @@ includeInIntrospection.label=Add to token introspection
roleImportSuccess=Role import successful
tokenUrl=Token URL
executionConfig={{name}} config
providerUnavailable=Provider unavailable
providerUnavailableHelp=The authenticator provider referenced by this step is not registered on the server. You can remove this step or reorder it within its current flow. Moving it to another flow or editing its configuration is blocked until the provider is available again.
providerUnavailableMoveBlocked=This step cannot be moved to another flow while its authenticator provider is unavailable. Remove it and re-add it once the provider is registered.
grantedClientScopes=Granted client scopes
keyError=A key must be provided.
invitations=Invitations
Expand Down
27 changes: 23 additions & 4 deletions js/apps/admin-ui/src/authentication/FlowDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export const providerConditionFilter = (
value: AuthenticationProviderRepresentation,
) => value.displayName?.startsWith("Condition ");

const containsOrphan = (
ex: AuthenticationFlowRepresentation | ExpandableExecution,
): boolean => {
if ("providerUnavailable" in ex && ex.providerUnavailable) {
return true;
}
const list = (ex as ExpandableExecution).executionList;
return !!list?.some(containsOrphan);
};

export default function FlowDetails() {
const { adminClient } = useAdminClient();

Expand Down Expand Up @@ -104,6 +114,14 @@ export default function FlowDetails() {
try {
let id = ex.id!;
if ("parent" in change) {
// Cross-flow drag runs delete + re-create; re-create fails on an unavailable provider, which would silently drop the row.
if (containsOrphan(ex)) {
addError(
"updateFlowError",
new Error(t("providerUnavailableMoveBlocked")),
);
return;
}
let config: AuthenticatorConfigRepresentation = {};
if ("authenticationConfig" in ex) {
config = await adminClient.authenticationManagement.getConfig({
Expand All @@ -127,13 +145,14 @@ export default function FlowDetails() {
type: "basic-flow",
});
id = result.id!;
ex.executionList?.forEach((e, i) =>
executeChange(e, {
const children = ex.executionList ?? [];
for (let i = 0; i < children.length; i++) {
await executeChange(children[i], {
parent: { ...ex, id: result.id },
newIndex: i,
oldIndex: i,
}),
);
});
}
} else {
const result =
await adminClient.authenticationManagement.addExecutionToFlow({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const FlowRow = ({
: execution.alias) || ""
}
providerId={execution.providerId!}
providerUnavailable={execution.providerUnavailable}
title={execution.displayName!}
/>
</Td>
Expand All @@ -100,7 +101,9 @@ export const FlowRow = ({
</>
)}
<Td isActionCell>
<ExecutionConfigModal execution={execution} />
{!execution.providerUnavailable && (
<ExecutionConfigModal execution={execution} />
)}
</Td>

{execution.authenticationFlow && !builtIn && (
Expand Down
17 changes: 15 additions & 2 deletions js/apps/admin-ui/src/authentication/components/FlowTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HelpItem } from "@keycloak/keycloak-ui-shared";
import { Label } from "@patternfly/react-core";
import {
CodeBranchIcon,
ExclamationTriangleIcon,
MapMarkerIcon,
ProcessAutomationIcon,
TaskIcon,
Expand All @@ -16,6 +17,7 @@ type FlowTitleProps = {
title: string;
subtitle: string;
providerId?: string;
providerUnavailable?: boolean;
};

const FlowIcon = ({ type }: { type: FlowType }) => {
Expand Down Expand Up @@ -54,18 +56,29 @@ export const FlowTitle = ({
title,
subtitle,
providerId,
providerUnavailable,
}: FlowTitleProps) => {
const { t } = useTranslation();
const { providers } = useAuthenticationProvider();
const helpText =
providers?.find((p) => p.id === providerId)?.description || subtitle;
const helpText = providerUnavailable
? t("providerUnavailableHelp")
: providers?.find((p) => p.id === providerId)?.description || subtitle;
return (
<div data-testid={title}>
<span data-id={id} id={`title-id-${id}`}>
<Label icon={<FlowIcon type={type} />} color={mapTypeToColor(type)}>
{t(type)}
</Label>{" "}
{title}{" "}
{providerUnavailable && (
<Label
data-testid={`${title}-provider-unavailable`}
color="red"
icon={<ExclamationTriangleIcon />}
>
{t("providerUnavailable")}
</Label>
)}{" "}
{helpText && <HelpItem helpText={helpText} fieldLabelId={id!} />}
</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export default interface AuthenticationExecutionInfoRepresentation {
flowId?: string;
level?: number;
index?: number;
providerUnavailable?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -727,32 +727,35 @@ public void recurseExecutions(AuthenticationFlowModel flow, List<AuthenticationE
} else {
String providerId = execution.getAuthenticator();
ConfigurableAuthenticatorFactory factory = CredentialHelper.getConfigurableAuthenticatorFactory(session, providerId);
if (factory == null) {
logger.warnf("Cannot find authentication provider implementation with provider ID '%s'", providerId);
throw new NotFoundException("Could not find authenticator provider");
}
rep.setDisplayName(factory.getDisplayType());
rep.setConfigurable(factory.isConfigurable());
for (AuthenticationExecutionModel.Requirement choice : factory.getRequirementChoices()) {
rep.getRequirementChoices().add(choice.name());
}
rep.setId(execution.getId());
rep.setRequirement(execution.getRequirement().name());

if (factory.isConfigurable()) {
String authenticatorConfigId = execution.getAuthenticatorConfig();
if(authenticatorConfigId != null) {
AuthenticatorConfigModel authenticatorConfig = new DeployedConfigurationsManager(session).getAuthenticatorConfig(realm, authenticatorConfigId);
if (factory == null) {
// Return a placeholder so a flow with an orphan execution stays manageable. See issue #15535.
logger.debugf("Cannot find authentication provider implementation with provider ID '%s'", providerId);
rep.setDisplayName(providerId);
rep.setConfigurable(false);
rep.setProviderUnavailable(true);
rep.getRequirementChoices().add(execution.getRequirement().name());
} else {
rep.setDisplayName(factory.getDisplayType());
rep.setConfigurable(factory.isConfigurable());
for (AuthenticationExecutionModel.Requirement choice : factory.getRequirementChoices()) {
rep.getRequirementChoices().add(choice.name());
}

if (factory.isConfigurable()) {
String authenticatorConfigId = execution.getAuthenticatorConfig();
if(authenticatorConfigId != null) {
AuthenticatorConfigModel authenticatorConfig = new DeployedConfigurationsManager(session).getAuthenticatorConfig(realm, authenticatorConfigId);

if (authenticatorConfig != null) {
rep.setAlias(authenticatorConfig.getAlias());
if (authenticatorConfig != null) {
rep.setAlias(authenticatorConfig.getAlias());
}
}
}
}

rep.setRequirement(execution.getRequirement().name());

providerId = execution.getAuthenticator();

// encode the provider id in case the provider is a script deployed to the server to make sure it can be used as path parameters without break the URL syntax
if (providerId.startsWith("script-")) {
providerId = Base32.encode(providerId.getBytes());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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.admin.authentication;

import java.util.HashMap;
import java.util.List;

import jakarta.ws.rs.core.Response;

import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.AdminEventAssertion;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.tests.utils.admin.AdminEventPaths;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
* Verifies the admin API keeps working when an execution references a provider id
* that has no registered factory (e.g. a custom Authenticator SPI was uninstalled
* or renamed). Covers https://github.com/keycloak/keycloak/issues/15535.
*/
@KeycloakIntegrationTest
public class OrphanExecutionTest extends AbstractAuthenticationTest {

private static final String MISSING_PROVIDER_ID = "orphan-authenticator-missing";

@InjectRunOnServer
RunOnServerClient runOnServer;

@Test
public void orphanExecutionDoesNotBreakFlowListing() {
String flowAlias = "orphan-listing-flow";
copyBrowserFlow(flowAlias);

String executionId = findExecutionByProvider("auth-cookie", authMgmtResource.getExecutions(flowAlias)).getId();
makeExecutionOrphan(executionId);

List<AuthenticationExecutionInfoRepresentation> executions = authMgmtResource.getExecutions(flowAlias);
AuthenticationExecutionInfoRepresentation orphan = findById(executionId, executions);

Assertions.assertNotNull(orphan, "Orphan execution must still be returned");
Assertions.assertEquals(Boolean.TRUE, orphan.getProviderUnavailable(), "providerUnavailable must be true");
Assertions.assertEquals(Boolean.FALSE, orphan.getConfigurable(), "Orphan execution must be reported as non-configurable");
Assertions.assertEquals(MISSING_PROVIDER_ID, orphan.getProviderId(), "providerId must be preserved");
Assertions.assertEquals(MISSING_PROVIDER_ID, orphan.getDisplayName(), "displayName falls back to providerId when factory is missing");
Assertions.assertNotNull(orphan.getRequirementChoices(), "requirementChoices must not be null");
Assertions.assertEquals(1, orphan.getRequirementChoices().size(), "Only the current requirement is offered");
Assertions.assertEquals(orphan.getRequirement(), orphan.getRequirementChoices().get(0));
}

@Test
public void orphanExecutionCanBeRemoved() {
String flowAlias = "orphan-removal-flow";
copyBrowserFlow(flowAlias);

String executionId = findExecutionByProvider("auth-cookie", authMgmtResource.getExecutions(flowAlias)).getId();
makeExecutionOrphan(executionId);

authMgmtResource.removeExecution(executionId);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.DELETE,
AdminEventPaths.authExecutionPath(executionId), ResourceType.AUTH_EXECUTION);

List<AuthenticationExecutionInfoRepresentation> executions = authMgmtResource.getExecutions(flowAlias);
Assertions.assertNull(findById(executionId, executions), "Orphan execution must be removable");
}

private void copyBrowserFlow(String newAlias) {
HashMap<String, Object> params = new HashMap<>();
params.put("newName", newAlias);
try (Response response = authMgmtResource.copy("browser", params)) {
Assertions.assertEquals(201, response.getStatus(), "Copy flow");
}
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.CREATE,
AdminEventPaths.authCopyFlowPath("browser"), params, ResourceType.AUTH_FLOW);
}

private void makeExecutionOrphan(String executionId) {
String realmName = managedRealm.getName();
runOnServer.run(session -> {
RealmModel realm = session.realms().getRealmByName(realmName);
AuthenticationExecutionModel exec = realm.getAuthenticationExecutionById(executionId);
exec.setAuthenticator(MISSING_PROVIDER_ID);
realm.updateAuthenticatorExecution(exec);
});
}

private static AuthenticationExecutionInfoRepresentation findById(String id, List<AuthenticationExecutionInfoRepresentation> executions) {
return executions.stream().filter(e -> id.equals(e.getId())).findFirst().orElse(null);
}
}
Loading