diff --git a/src/main/java/org/folio/consortia/client/KeycloakClient.java b/src/main/java/org/folio/consortia/client/KeycloakClient.java index 5e239d22..b1e2c832 100644 --- a/src/main/java/org/folio/consortia/client/KeycloakClient.java +++ b/src/main/java/org/folio/consortia/client/KeycloakClient.java @@ -4,6 +4,9 @@ import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + import java.util.List; import java.util.Map; @@ -11,11 +14,13 @@ import org.folio.consortia.domain.dto.KeycloakClientCredentials; import org.folio.consortia.domain.dto.KeycloakIdentityProvider; import org.folio.consortia.domain.dto.KeycloakTokenResponse; +import org.folio.consortia.domain.dto.RealmExecutions; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; @@ -60,6 +65,18 @@ KeycloakIdentityProvider getIdentityProvider(@PathVariable("realm") String realm @PathVariable("alias") String alias, @RequestHeader(AUTHORIZATION) String token); + /** + * Create identity provider in a realm + * + * @param realm realm to create identity provider in + * @param identityProvider identity provider to create + * @param token authorization token + */ + @PostMapping("/admin/realms/{realm}/identity-provider/instances") + void createIdentityProvider(@PathVariable("realm") String realm, + @RequestBody KeycloakIdentityProvider identityProvider, + @RequestHeader(AUTHORIZATION) String token); + /** * Delete identity provider by alias in a realm * @@ -73,15 +90,88 @@ void deleteIdentityProvider(@PathVariable("realm") String realm, @RequestHeader(AUTHORIZATION) String token); /** - * Create identity provider in a realm + * Retrieves the realm configuration for the specified tenant. * - * @param realm realm to create identity provider in - * @param identityProvider identity provider to create - * @param token authorization token + * @param tenant the tenant identifier + * @param token the authorization token + * @return the realm configuration as an ObjectNode */ - @PostMapping("/admin/realms/{realm}/identity-provider/instances") - void createIdentityProvider(@PathVariable("realm") String realm, - @RequestBody KeycloakIdentityProvider identityProvider, - @RequestHeader(AUTHORIZATION) String token); + @GetMapping(value = "admin/realms/{tenant}") + ObjectNode getRealm(@PathVariable("tenant") String tenant, + @RequestHeader(AUTHORIZATION) String token); + /** + * Updates the realm configuration for the specified tenant. + * + * @param tenant the tenant identifier + * @param realm the updated realm configuration as a JsonNode + * @param token the authorization token + */ + @PutMapping(value = "/admin/realms/{tenant}") + void updateRealm(@PathVariable("tenant") String tenant, + @RequestBody JsonNode realm, + @RequestHeader(AUTHORIZATION) String token); + + /** + * Duplicates the built-in browser authentication flow for the specified tenant. + * + * @param tenant the tenant identifier + * @param copyRequest the request containing the new flow name + * @param token the authorization token + */ + @PostMapping(value = "/admin/realms/{tenant}/authentication/flows/browser/copy") + void copyBrowserFlow(@PathVariable("tenant") String tenant, + @RequestBody Map copyRequest, + @RequestHeader(AUTHORIZATION) String token); + + /** + * Retrieves the executions for the specified authentication flow in the specified tenant. + * + * @param tenant the tenant identifier + * @param flowName the name of the authentication flow + * @param token the authorization token + * @return the list of executions for the specified flow + */ + @GetMapping(value = "/admin/realms/{tenant}/authentication/flows/{flowName}/executions") + List getExecutions(@PathVariable("tenant") String tenant, + @PathVariable("flowName") String flowName, + @RequestHeader(AUTHORIZATION) String token); + + /** + * Adds an execution to the specified authentication flow in the specified tenant. + * + * @param tenant the tenant identifier + * @param flowName the name of the authentication flow + * @param executionRequest the request containing the execution details + * @param token the authorization token + */ + @PostMapping(value = "/admin/realms/{tenant}/authentication/flows/{flowName}/executions/execution") + void executeBrowserFlow(@PathVariable("tenant") String tenant, + @PathVariable("flowName") String flowName, + @RequestBody Map executionRequest, + @RequestHeader(AUTHORIZATION) String token); + + /** + * Raises the priority of the specified execution in the specified tenant. + * + * @param tenant the tenant identifier + * @param executionId the identifier of the execution to raise priority + * @param token the authorization token + */ + @PostMapping(value = "/admin/realms/{tenant}/authentication/executions/{executionId}/raise-priority") + void raisePriority(@PathVariable("tenant") String tenant, + @PathVariable("executionId") String executionId, + @RequestHeader(AUTHORIZATION) String token); + + /** + * Deletes the specified execution from the specified tenant. + * + * @param tenant the tenant identifier + * @param executionId the identifier of the execution to delete + * @param token the authorization token + */ + @DeleteMapping(value = "/admin/realms/{tenant}/authentication/executions/{executionId}") + void deleteExecution(@PathVariable("tenant") String tenant, + @PathVariable("executionId") String executionId, + @RequestHeader(AUTHORIZATION) String token); } diff --git a/src/main/java/org/folio/consortia/domain/dto/RealmExecutions.java b/src/main/java/org/folio/consortia/domain/dto/RealmExecutions.java new file mode 100644 index 00000000..6e11b338 --- /dev/null +++ b/src/main/java/org/folio/consortia/domain/dto/RealmExecutions.java @@ -0,0 +1,23 @@ +package org.folio.consortia.domain.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.With; + +@Data +@With +@AllArgsConstructor +@NoArgsConstructor +public class RealmExecutions { + private String id; + private String requirement; + private String displayName; + private List requirementChoices; + private boolean configurable; + private String providerId; + private int level; + private int index; + private int priority; +} diff --git a/src/main/java/org/folio/consortia/service/KeycloakService.java b/src/main/java/org/folio/consortia/service/KeycloakService.java index 84399f04..8bd8ed2c 100644 --- a/src/main/java/org/folio/consortia/service/KeycloakService.java +++ b/src/main/java/org/folio/consortia/service/KeycloakService.java @@ -1,7 +1,26 @@ package org.folio.consortia.service; + +import org.folio.consortia.domain.dto.Tenant; + public interface KeycloakService { + /** + * Adds a custom authentication flow for a central tenant. + *

+ * This method performs the following steps: + * 1. Duplicates the built-in browser authentication flow. + * 2. Adds a custom ECS Folio authentication form provider to the duplicated flow. + * 3. Fetches executions from the current flow. + * 4. Deletes the default auth-username-password-form execution from the flow. + * 5. Raises the priority of the custom ECS Folio authentication form provider. + * 6. Binds the custom flow to the realm. + * + * @param tenant the tenant for which the custom authentication flow is to be added + * @throws IllegalStateException if the required executions are not found + */ + void addCustomAuthFlowForCentralTenant(Tenant tenant); + /** * Creates an identity provider in the central tenant realm for the member tenant with conditions:
* 1) In case the identity provider already exists, it will not be created again to @@ -21,5 +40,4 @@ public interface KeycloakService { * @param memberTenantId member tenant */ void deleteIdentityProvider(String centralTenantId, String memberTenantId); - } diff --git a/src/main/java/org/folio/consortia/service/impl/KeycloakServiceImpl.java b/src/main/java/org/folio/consortia/service/impl/KeycloakServiceImpl.java index e676a56d..902611e7 100644 --- a/src/main/java/org/folio/consortia/service/impl/KeycloakServiceImpl.java +++ b/src/main/java/org/folio/consortia/service/impl/KeycloakServiceImpl.java @@ -2,11 +2,16 @@ import static org.folio.consortia.utils.KeycloakUtils.buildIdpClientConfig; +import java.util.Map; + +import com.fasterxml.jackson.databind.node.ObjectNode; + import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.folio.consortia.client.KeycloakClient; import org.folio.consortia.config.keycloak.KeycloakIdentityProviderProperties; import org.folio.consortia.domain.dto.KeycloakIdentityProvider; +import org.folio.consortia.domain.dto.Tenant; import org.folio.consortia.service.KeycloakCredentialsService; import org.folio.consortia.service.KeycloakService; import org.springframework.stereotype.Service; @@ -21,15 +26,66 @@ @Log4j2 public class KeycloakServiceImpl implements KeycloakService { + private static final String CUSTOM_BROWSER_FLOW = "custom-browser"; + private static final String ECS_FOLIO_AUTH_USRNM_PWD_FORM = "ecs-folio-auth-usrnm-pwd-form"; + private static final String AUTH_USERNAME_PASSWORD_FORM = "auth-username-password-form"; + private static final String CUSTOM_BROWSER_FLOW_FORMS = CUSTOM_BROWSER_FLOW + "%20forms"; private static final String KEYCLOAK_PROVIDER_ID = "keycloak-oidc"; private final KeycloakClient keycloakClient; private final KeycloakIdentityProviderProperties keycloakIdpProperties; private final KeycloakCredentialsService keycloakCredentialsService; + @Override + public void addCustomAuthFlowForCentralTenant(Tenant tenant) { + log.debug("Trying to add custom authentication flow for tenant with id={}", tenant.getId()); + if (isUnifiedLoginDisabled()) { + log.info("addCustomAuthFlowForCentralTenant:: Identity provider creation is disabled. Skipping creation for tenant {}", tenant.getId()); + return; + } + if (Boolean.FALSE.equals(tenant.getIsCentral())) { + log.info("addCustomAuthFlowForCentralTenant:: Tenant with id={} is not central, skipping custom authentication flow addition", tenant.getId()); + return; + } + + var token = keycloakCredentialsService.getMasterAuthToken(); + var tenantId = tenant.getId(); + + // 1. Duplicate built-in browser authentication flow + var browserFlowCopyConfig = Map.of("newName", CUSTOM_BROWSER_FLOW); + keycloakClient.copyBrowserFlow(tenantId, browserFlowCopyConfig, token); + + // 2. Add custom ecs folio authentication form provider to the duplicated flow + var browserFlowProviderConfig = Map.of("provider", ECS_FOLIO_AUTH_USRNM_PWD_FORM); + keycloakClient.executeBrowserFlow(tenantId, CUSTOM_BROWSER_FLOW_FORMS, browserFlowProviderConfig, token); + + // 3. Fetch executions from current flow + var executions = keycloakClient.getExecutions(tenantId, CUSTOM_BROWSER_FLOW, token); + var authUsernamePasswordFormExecution = executions.stream() + .filter(execution -> StringUtils.equals(execution.getProviderId(), AUTH_USERNAME_PASSWORD_FORM)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("auth-username-password-form execution not found")); + var ecsFolioAuthUsernamePasswordFormExecution = executions.stream() + .filter(execution -> StringUtils.equals(execution.getProviderId(), ECS_FOLIO_AUTH_USRNM_PWD_FORM)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("ecs-folio-auth-usrnm-pwd-form execution not found")); + + // 4. Delete default auth-username-password-form execution from the flow + keycloakClient.deleteExecution(tenant.getId(), authUsernamePasswordFormExecution.getId(), token); + + // 5. Raise priority of the custom ecs folio authentication form provider + keycloakClient.raisePriority(tenantId, ecsFolioAuthUsernamePasswordFormExecution.getId(), token); + + // 6. Bind the custom flow to the realm + ObjectNode realm = keycloakClient.getRealm(tenantId, token); + realm.put("browserFlow", CUSTOM_BROWSER_FLOW); + keycloakClient.updateRealm(tenantId, realm, token); + log.info("addCustomAuthFlowForCentralTenant:: Custom authentication flow successfully added for tenant with id={}", tenantId); + } + @Override public void createIdentityProvider(String centralTenantId, String memberTenantId) { - if (isIdpCreationDisabled()) { + if (isUnifiedLoginDisabled()) { log.info("createIdentityProvider:: Identity provider creation is disabled. Skipping creation for tenant {}", memberTenantId); return; } @@ -58,7 +114,7 @@ public void createIdentityProvider(String centralTenantId, String memberTenantId @Override public void deleteIdentityProvider(String centralTenantId, String memberTenantId) { - if (isIdpCreationDisabled()) { + if (isUnifiedLoginDisabled()) { log.info("deleteIdentityProvider:: Identity provider creation is disabled. Skipping deletion for tenant {}", memberTenantId); return; } @@ -84,7 +140,7 @@ private boolean identityProviderExists(String realm, String providerAlias, Strin } } - private boolean isIdpCreationDisabled() { + private boolean isUnifiedLoginDisabled() { return BooleanUtils.isNotTrue(keycloakIdpProperties.getEnabled()); } diff --git a/src/main/java/org/folio/consortia/service/impl/TenantManagerImpl.java b/src/main/java/org/folio/consortia/service/impl/TenantManagerImpl.java index 2276a06d..e1c092f6 100644 --- a/src/main/java/org/folio/consortia/service/impl/TenantManagerImpl.java +++ b/src/main/java/org/folio/consortia/service/impl/TenantManagerImpl.java @@ -7,7 +7,8 @@ import java.util.Objects; import java.util.UUID; - +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.ObjectUtils; import org.folio.consortia.client.ConsortiaConfigurationClient; import org.folio.consortia.client.UserTenantsClient; @@ -40,9 +41,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; - @Log4j2 @Service @RequiredArgsConstructor @@ -68,6 +66,7 @@ public class TenantManagerImpl implements TenantManager { private final ExecutionContextBuilder contextBuilder; private final FolioExecutionContext folioExecutionContext; + @Override public TenantCollection get(UUID consortiumId, Integer offset, Integer limit) { return tenantService.get(consortiumId, offset, limit); @@ -82,10 +81,9 @@ public Tenant save(UUID consortiumId, UUID adminUserId, Tenant tenantDto) { tenantService.checkTenantUniqueNameAndCodeOrThrow(tenantDto); createCustomFieldIfNeeded(tenantDto.getId()); + keycloakService.addCustomAuthFlowForCentralTenant(tenantDto); var existingTenant = tenantService.getByTenantId(tenantDto.getId()); - - // checked whether tenant exists or not. return existingTenant != null ? reAddSoftDeletedTenant(consortiumId, existingTenant, tenantDto) : addNewTenant(consortiumId, tenantDto, adminUserId); diff --git a/src/test/java/org/folio/consortia/service/KeycloakServiceTest.java b/src/test/java/org/folio/consortia/service/KeycloakServiceTest.java index 233c3c93..1e89ff4f 100644 --- a/src/test/java/org/folio/consortia/service/KeycloakServiceTest.java +++ b/src/test/java/org/folio/consortia/service/KeycloakServiceTest.java @@ -3,31 +3,42 @@ import static org.folio.consortia.service.KeycloakCredentialsServiceTest.createClientCredentials; import static org.folio.consortia.support.EntityUtils.CENTRAL_TENANT_ID; import static org.folio.consortia.support.EntityUtils.TENANT_ID; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import feign.FeignException; +import feign.Request; import org.folio.consortia.client.KeycloakClient; import org.folio.consortia.config.keycloak.KeycloakIdentityProviderProperties; import org.folio.consortia.config.keycloak.KeycloakLoginClientProperties; import org.folio.consortia.domain.dto.KeycloakIdentityProvider; +import org.folio.consortia.domain.dto.RealmExecutions; +import org.folio.consortia.domain.dto.Tenant; import org.folio.consortia.service.impl.KeycloakServiceImpl; import org.folio.consortia.support.CopilotGenerated; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; -import feign.FeignException; -import feign.Request; - @SpringBootTest @CopilotGenerated(partiallyGenerated = true) class KeycloakServiceTest { @@ -50,6 +61,9 @@ class KeycloakServiceTest { @Value("${folio.environment}") private String folioEnvironment; + @Captor + private ArgumentCaptor> mapCaptor; + @BeforeEach void setUp() { when(keycloakIdpProperties.getEnabled()).thenReturn(true); @@ -123,6 +137,54 @@ void deleteIdentityProvider_skipsIfProviderDoesNotExist() { verify(keycloakClient, never()).getIdentityProvider(anyString(), anyString(), eq(AUTH_TOKEN)); } + @Test + void addCustomAuthFlowForCentralTenantSuccess() { + Tenant tenant = new Tenant(); + tenant.setId("tenant-id"); + tenant.setIsCentral(true); + var executions = List.of( + new RealmExecutions().withId("id1").withProviderId("auth-username-password-form"), + new RealmExecutions().withId("id2").withProviderId("ecs-folio-auth-usrnm-pwd-form")); + var realm = new ObjectNode(JsonNodeFactory.instance); + + when(keycloakCredentialsService.getMasterAuthToken()).thenReturn(AUTH_TOKEN); + when(keycloakClient.getExecutions(anyString(), anyString(), anyString())).thenReturn(executions); + when(keycloakClient.getRealm(anyString(), anyString())).thenReturn(realm); + + keycloakService.addCustomAuthFlowForCentralTenant(tenant); + + verify(keycloakClient).copyBrowserFlow(eq("tenant-id"), mapCaptor.capture(), eq(AUTH_TOKEN)); + verify(keycloakClient).executeBrowserFlow(eq("tenant-id"), eq("custom-browser%20forms"), mapCaptor.capture(), eq(AUTH_TOKEN)); + verify(keycloakClient).deleteExecution("tenant-id", "id1", AUTH_TOKEN); + verify(keycloakClient).raisePriority("tenant-id", "id2", AUTH_TOKEN); + verify(keycloakClient).updateRealm(eq("tenant-id"), any(), eq(AUTH_TOKEN)); + } + + @Test + void addCustomAuthFlowForCentralTenantNotCentral() { + Tenant tenant = new Tenant(); + tenant.setId("tenant-id"); + tenant.setIsCentral(false); + + keycloakService.addCustomAuthFlowForCentralTenant(tenant); + + verifyNoInteractions(keycloakClient); + verifyNoInteractions(keycloakCredentialsService); + } + + @Test + void addCustomAuthFlowForCentralTenantExecutionNotFound() { + Tenant tenant = new Tenant(); + tenant.setId("tenant-id"); + tenant.setIsCentral(true); + String token = "token"; + + when(keycloakCredentialsService.getMasterAuthToken()).thenReturn(token); + when(keycloakClient.getExecutions(anyString(), anyString(), anyString())).thenReturn(List.of()); + + assertThrows(IllegalStateException.class, () -> keycloakService.addCustomAuthFlowForCentralTenant(tenant)); + } + private static String getTenantClientAlias(String tenant) { return tenant + "-keycloak-oidc"; }