diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/DashboardConfigValidator.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/DashboardConfigValidator.java new file mode 100644 index 0000000000..e20a35f7b1 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/DashboardConfigValidator.java @@ -0,0 +1,46 @@ + +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.client.management.validation; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import it.infn.mw.iam.config.IamProperties.DashboardProperties; + +@Component +@Scope("prototype") +public class DashboardConfigValidator implements ConstraintValidator { + + private static final String CLIENT_ID_REGEX = "^[a-zA-Z0-9\\-._~]{4,256}$"; + private static final String CLIENT_SECRET_REGEX = "^[a-zA-Z0-9\\-._~]{32,72}$"; + + @Override + public boolean isValid(DashboardProperties dashboardProperties, ConstraintValidatorContext context) { + if (dashboardProperties == null || !dashboardProperties.isEnabled()) { + return true; + } + boolean validClientId = dashboardProperties.getClientId() != null + && dashboardProperties.getClientId().matches(CLIENT_ID_REGEX); + boolean validClientSecret = dashboardProperties.getClientSecret() != null + && dashboardProperties.getClientSecret().matches(CLIENT_SECRET_REGEX); + + return validClientId && validClientSecret; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/ValidDashboard.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/ValidDashboard.java new file mode 100644 index 0000000000..02b010c07e --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/management/validation/ValidDashboard.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.client.management.validation; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import javax.validation.Constraint; +import javax.validation.Payload; + +@Retention(RUNTIME) +@Target({FIELD}) +@Constraint(validatedBy = DashboardConfigValidator.class) +public @interface ValidDashboard { + String message() default "Invalid dashboard client configuration"; + + Class [] groups() default {}; + + Class[] payload() default {}; +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/DefaultClientDefaultsService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/DefaultClientDefaultsService.java index a88c27ab3c..2ce7b9d363 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/DefaultClientDefaultsService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/DefaultClientDefaultsService.java @@ -40,6 +40,7 @@ public class DefaultClientDefaultsService implements ClientDefaultsService { EnumSet.of(AuthMethod.SECRET_BASIC, AuthMethod.SECRET_POST, AuthMethod.SECRET_JWT); private static final int SECRET_SIZE = 512; + private static final int BCRYPT_MAX_SIZE = 72; private static final SecureRandom RNG = new SecureRandom(); private final ClientRegistrationProperties properties; @@ -95,9 +96,7 @@ public ClientDetailsEntity setupClientDefaults(ClientDetailsEntity client) { @Override public String generateClientSecret() { - return - Base64.encodeBase64URLSafeString(new BigInteger(SECRET_SIZE, RNG).toByteArray()) - .replace("=", ""); + return Base64.encodeBase64URLSafeString(new BigInteger(SECRET_SIZE, RNG).toByteArray()).substring(0, BCRYPT_MAX_SIZE); } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java index 9e5066c019..5bd41371dd 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java @@ -21,17 +21,20 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; import com.fasterxml.jackson.annotation.JsonInclude; import com.google.common.collect.Lists; import com.nimbusds.jose.JWEAlgorithm; import com.nimbusds.jose.JWSAlgorithm; +import it.infn.mw.iam.api.client.management.validation.ValidDashboard; import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType; import it.infn.mw.iam.config.login.LoginButtonProperties; import it.infn.mw.iam.config.multi_factor_authentication.VerifyButtonProperties; @Component +@Validated @ConfigurationProperties(prefix = "iam") public class IamProperties { @@ -599,6 +602,38 @@ public void setTrackLastUsed(boolean trackLastUsed) { } } + @Validated + public static class DashboardProperties { + + private boolean enabled = false; + private String clientId; + private String clientSecret; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + } + public static class DefaultGroup { private String name; private String enrollment = "INSERT"; @@ -727,6 +762,9 @@ public void setUrnSubnamespaces(String urnSubnamespaces) { private AarcProfile aarcProfile = new AarcProfile(); + @ValidDashboard + private DashboardProperties dashboard = new DashboardProperties(); + public String getBaseUrl() { return baseUrl; } @@ -969,6 +1007,18 @@ public ClientProperties getClient() { return client; } + public DashboardProperties getDashboard() { + return dashboard; + } + + public void setDashboard(DashboardProperties dashboard) { + this.dashboard = dashboard; + } + + public Boolean isDashboardPropertiesEnable() { + return dashboard != null; + } + public AarcProfile getAarcProfile() { return aarcProfile; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/util/StartupRunner.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/util/StartupRunner.java new file mode 100644 index 0000000000..a318f180b1 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/util/StartupRunner.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import it.infn.mw.iam.dashboard.DashboardConfigService; + +@Component +public class StartupRunner implements ApplicationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(StartupRunner.class); + + private final DashboardConfigService dashboardConfigService; + + public StartupRunner(DashboardConfigService dashboardConfigService) { + this.dashboardConfigService = dashboardConfigService; + } + + @Override + public void run(ApplicationArguments args) { + if (!dashboardConfigService.isEnabled()) { + LOG.info( + "Dashboard client is disabled, skipping checks for the dashboard client properties and the presence of the record for the dashboard client"); + return; + } + + boolean isValid = dashboardConfigService.init(); + if (!isValid) { + throw new IllegalStateException( + "Dashboard client record does not exist or is not valid. Please check the dashboard client properties and ensure that a record with the specified client id, client secret and redirect uri exists in the database"); + } + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/dashboard/DashboardConfigService.java b/iam-login-service/src/main/java/it/infn/mw/iam/dashboard/DashboardConfigService.java new file mode 100644 index 0000000000..ff5419db5c --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/dashboard/DashboardConfigService.java @@ -0,0 +1,155 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.dashboard; + +import java.text.ParseException; +import java.util.Optional; +import java.util.Set; + +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.PKCEAlgorithm; +import org.mitre.oauth2.service.SystemScopeService; +import org.mitre.oauth2.model.ClientDetailsEntity.AuthMethod; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import it.infn.mw.iam.api.client.management.service.DefaultClientManagementService; +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.IamProperties.DashboardProperties; +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; +import it.infn.mw.iam.api.common.client.AuthorizationGrantType; +import it.infn.mw.iam.api.common.client.RegisteredClientDTO; +import it.infn.mw.iam.api.common.client.TokenEndpointAuthenticationMethod; + +@Service +public class DashboardConfigService { + + private static final Logger LOG = LoggerFactory.getLogger(DashboardConfigService.class); + + private static final String DASHBOARD_CALLBACK = "/ui/api/auth/oauth2/callback/indigo-iam"; + private static final Set DASHBOARD_SCOPES = Set.of(SystemScopeService.OPENID_SCOPE, + SystemScopeService.OFFLINE_ACCESS, "email", + "profile", "iam:admin.read", "iam:admin.write", "scim:read", "scim:write"); + + private final IamClientRepository clientRepository; + private final DefaultClientManagementService clientService; + private final IamProperties iamProperties; + + public DashboardConfigService( + IamClientRepository clientRepository, + DefaultClientManagementService clientService, + IamProperties iamProperties) { + this.clientService = clientService; + this.clientRepository = clientRepository; + this.iamProperties = iamProperties; + } + + public boolean isEnabled() { + return iamProperties.getDashboard().isEnabled(); + } + + public boolean init() { + DashboardProperties dashboardProperties = iamProperties.getDashboard(); + String iamUrl = iamProperties.getBaseUrl(); + String clientId = dashboardProperties.getClientId(); + String clientSecret = dashboardProperties.getClientSecret(); + String url = iamUrl + DASHBOARD_CALLBACK; + Optional dashboardRecord = clientRepository.findByClientId(clientId); + + if (!dashboardRecord.isPresent()) { + LOG.info("The client record for dashboard does not exist. Creating record with default configuration..."); + try { + createRecordDashboard(clientId, clientSecret, url); + return true; + } catch (Exception e) { + LOG.error("Error saving dashboard client: {}", e.getMessage()); + return false; + } + } + + ClientDetailsEntity client = dashboardRecord.get(); + boolean isValid = checkRecordConfiguration(client, clientSecret, url); + if (!isValid) { + LOG.warn("The record is not properly configured. Updating Dashboard client."); + updateRecordDashboard(client, clientSecret, url); + } + return true; + } + + public boolean checkRecordConfiguration(ClientDetailsEntity client, String clientSecret, String url) { + return hasAllRequiredScopes(client) + && hasValidClientSecret(client, clientSecret) + && hasValidRedirectUris(client, url) + && supportsAuthorizationCodeGrant(client) + && usesClientSecretBasicAuth(client) + && usesPKCES256(client); + } + + private void createRecordDashboard(String clientId, String secret, String url) throws ParseException { + RegisteredClientDTO client = new RegisteredClientDTO(); + client.setScope(DASHBOARD_SCOPES); + client.setClientId(clientId); + client.setClientName("dashboard"); + client.setClientSecret(secret); + client.setTokenEndpointAuthMethod(TokenEndpointAuthenticationMethod.client_secret_basic); + client.setAccessTokenValiditySeconds(3600); + client.setCodeChallengeMethod(PKCEAlgorithm.S256.toString()); + client.setActive(true); + client.setRedirectUris(Set.of(url)); + client.setGrantTypes(Set.of(AuthorizationGrantType.CODE, AuthorizationGrantType.REFRESH_TOKEN)); + + clientService.saveNewClient(client); + } + + private void updateRecordDashboard(ClientDetailsEntity client, String secret, String url) { + client.setScope(DASHBOARD_SCOPES); + client.setGrantTypes( + Set.of(AuthorizationGrantType.CODE.getGrantType(), AuthorizationGrantType.REFRESH_TOKEN.getGrantType())); + client.setCodeChallengeMethod(PKCEAlgorithm.S256); + client.setClientSecret(secret); + client.setRedirectUris(Set.of(url)); + client.setTokenEndpointAuthMethod(AuthMethod.SECRET_BASIC); + + clientRepository.save(client); + } + + private boolean hasAllRequiredScopes(ClientDetailsEntity client) { + return client.getScope().containsAll(DASHBOARD_SCOPES); + } + + private boolean hasValidClientSecret(ClientDetailsEntity client, String clientSecret) { + return client.getClientSecret().equals(clientSecret); + } + + private boolean hasValidRedirectUris(ClientDetailsEntity client, String url) { + return client.getRedirectUris().equals(Set.of(url)); + } + + private boolean supportsAuthorizationCodeGrant(ClientDetailsEntity client) { + return client.getGrantTypes().equals( + Set.of(AuthorizationGrantType.CODE.getGrantType(), AuthorizationGrantType.REFRESH_TOKEN.getGrantType())); + } + + private boolean usesClientSecretBasicAuth(ClientDetailsEntity client) { + return client.getTokenEndpointAuthMethod().equals(AuthMethod.SECRET_BASIC); + } + + private boolean usesPKCES256(ClientDetailsEntity client) { + return client.getCodeChallengeMethod().getName().equals(PKCEAlgorithm.S256.toString()); + } +} diff --git a/iam-login-service/src/main/resources/application.yml b/iam-login-service/src/main/resources/application.yml index e124de24b7..121768207b 100644 --- a/iam-login-service/src/main/resources/application.yml +++ b/iam-login-service/src/main/resources/application.yml @@ -189,6 +189,11 @@ iam: client: track-last-used: ${IAM_CLIENT_TRACK_LAST_USED:false} + dashboard: + enabled: ${IAM_DASHBOARD_ENABLED:false} + client-id: ${IAM_DASHBOARD_CLIENT_ID:} + client-secret: ${IAM_DASHBOARD_CLIENT_SECRET:} + cache: enabled: ${IAM_CACHE_ENABLED:true} oidc-discovery-cleanup-period-secs: ${IAM_OIDC_DISCOVERY_CLEANUP_PERIOD_SECS:86400} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/config/DashboardConfigValidatorTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/config/DashboardConfigValidatorTests.java new file mode 100644 index 0000000000..0111dfc768 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/config/DashboardConfigValidatorTests.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.config; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import javax.validation.ConstraintValidatorContext; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import it.infn.mw.iam.api.client.management.validation.DashboardConfigValidator; +import it.infn.mw.iam.config.IamProperties.DashboardProperties; + +class DashboardConfigValidatorTests { + + private final DashboardConfigValidator validator = new DashboardConfigValidator(); + private final ConstraintValidatorContext context = mock(ConstraintValidatorContext.class); + private static DashboardProperties dashboardProperties; + + @BeforeEach + void setup() { + dashboardProperties = new DashboardProperties(); + dashboardProperties.setEnabled(true); + dashboardProperties.setClientId("client-dashboard"); + dashboardProperties.setClientSecret("0tlkqGPJD2vWN1dgTqi3xn-PAJ7EMgNKFFUOydZPsTLkIouqQFmfioJcvfk0V2Xt"); + } + + @Test + void testSkipValidation() { + dashboardProperties = new DashboardProperties(); + dashboardProperties.setEnabled(false); + dashboardProperties.setClientId(null); + dashboardProperties.setClientSecret(null); + assertTrue(validator.isValid(dashboardProperties, context)); + } + + @Test + void testDashboardPropertiesValid() { + assertTrue(validator.isValid(dashboardProperties, context)); + } + + @Test + void testIsNotValidDashboardSecret() { + invalidSecret("too-short-client-secret"); + invalidSecret("too-long-client-secret-82gbV6OEwGBCPMmcFPXg5-4wRJXnKc-4wds5odwrFiY4wds5odwrF"); + invalidSecret("0tlkqGPJD2vWN1/dgTqi3xn-PAJ7EMgNKFFUOydZPsTLkIouqQFmfioJcvfk0V2Xt"); + invalidSecret(null); + invalidSecret(""); + } + + @Test + void testIsNotValidDashboardClientIdNull() { + invalidClientId(null); + invalidClientId("id"); + invalidClientId("client-with-special-chars/"); + invalidClientId("client with spaces"); + invalidClientId("too long client-id over 255 characters " + "a".repeat(256)); + invalidClientId(""); + } + + private void invalidSecret(String secret) { + dashboardProperties.setClientSecret(secret); + assertFalse(validator.isValid(dashboardProperties, context)); + } + + private void invalidClientId(String clientId) { + dashboardProperties.setClientId(clientId); + assertFalse(validator.isValid(dashboardProperties, context)); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/core/util/StartupRunnerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/core/util/StartupRunnerTests.java new file mode 100644 index 0000000000..bed913bd13 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/core/util/StartupRunnerTests.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.core.util; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.ApplicationArguments; +import it.infn.mw.iam.core.util.StartupRunner; +import it.infn.mw.iam.dashboard.DashboardConfigService; + +@ExtendWith(MockitoExtension.class) +class StartupRunnerTests { + + @Mock + private DashboardConfigService service; + + private StartupRunner runner; + + @BeforeEach + void initRunner() { + runner = new StartupRunner(service); + } + + @Test + void testRunnerInitializesDashboard() { + when(service.isEnabled()).thenReturn(true); + when(service.init()).thenReturn(true); + runner.run(mock(ApplicationArguments.class)); + verify(service, times(1)).init(); + } + + @Test + void testRunnerDoesNotInitializeDashboard() { + when(service.isEnabled()).thenReturn(false); + runner.run(mock(ApplicationArguments.class)); + verify(service, times(0)).init(); + } + + @Test + void testRunnerThrowsExceptionOnInitializationFailure() { + when(service.isEnabled()).thenReturn(true); + when(service.init()).thenThrow(new IllegalStateException()); + assertThrows(IllegalStateException.class, () -> runner.run(mock(ApplicationArguments.class))); + verify(service, times(1)).init(); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/dashboard/DashboardConfigServiceTest.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/dashboard/DashboardConfigServiceTest.java new file mode 100644 index 0000000000..20d1fdbde5 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/dashboard/DashboardConfigServiceTest.java @@ -0,0 +1,160 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.dashboard; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.ClientDetailsEntity.AuthMethod; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mitre.oauth2.model.PKCEAlgorithm; + +import java.text.ParseException; +import java.util.Optional; +import java.util.Set; + +import com.google.common.collect.Sets; + +import it.infn.mw.iam.dashboard.DashboardConfigService; +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; +import it.infn.mw.iam.api.client.management.service.DefaultClientManagementService; +import it.infn.mw.iam.api.common.client.AuthorizationGrantType; +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.IamProperties.DashboardProperties; + +@ExtendWith(MockitoExtension.class) +class DashboardConfigServiceTest { + + private static final String CLIENT_ID = "dashboard-client"; + private static final String CLIENT_SECRET = "dashboard-client-secret-01234567890"; + private static final String BASE_URL = "http://localhost:8080"; + private static final Set SCOPES = Sets.newHashSet("openid", "profile", "email", "iam:admin.read", + "iam:admin.write", "scim:read", "scim:write", "offline_access"); + private static final Set AUTH_GRAND_TYPE = Set.of(AuthorizationGrantType.CODE.getGrantType(), + AuthorizationGrantType.REFRESH_TOKEN.getGrantType()); + + @Mock + IamClientRepository clientRepository; + + @Mock + DefaultClientManagementService clientService; + + @Mock + IamProperties iamProperties; + + private DashboardConfigService getService() { + return new DashboardConfigService(clientRepository, clientService, iamProperties); + } + + @Test + void testCheckRecordConfiguration() { + ClientDetailsEntity client = createClientDashboard(CLIENT_ID, CLIENT_SECRET, BASE_URL, AUTH_GRAND_TYPE, SCOPES); + + assertEquals(true, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + } + + @Test + void testFailCheckRecordWithWrongConfigurations() { + Set scopesWithoutRequired = Sets.newHashSet("FAKE_SCOPE"); + ClientDetailsEntity client = createClientDashboard(CLIENT_ID, CLIENT_SECRET, BASE_URL, AUTH_GRAND_TYPE, + scopesWithoutRequired); + assertEquals(false, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + + scopesWithoutRequired = Sets.newHashSet("openid"); + client = createClientDashboard(CLIENT_ID, CLIENT_SECRET, BASE_URL, AUTH_GRAND_TYPE, scopesWithoutRequired); + assertEquals(false, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + + client = createClientDashboard(CLIENT_ID, "test_secret", BASE_URL, AUTH_GRAND_TYPE, SCOPES); + assertEquals(false, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + + client = createClientDashboard(CLIENT_ID, CLIENT_SECRET, "https://fake.url", AUTH_GRAND_TYPE, SCOPES); + assertEquals(false, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + + client = createClientDashboard(CLIENT_ID, CLIENT_SECRET, BASE_URL, + Set.of(AuthorizationGrantType.CODE.getGrantType()), + SCOPES); + assertEquals(false, getService().checkRecordConfiguration(client, CLIENT_SECRET, BASE_URL)); + } + + @Test + void testInit() { + ClientDetailsEntity dashboard = setClientDashboard(); + when(clientRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(dashboard)); + + mockDashboardProperties(true, CLIENT_ID, CLIENT_SECRET); + assertEquals(true, getService().init()); + } + + @Test + void testInitInsertNewDashboardClient() throws ParseException { + String newClientId = "new-" + CLIENT_ID; + mockDashboardProperties(true, newClientId, CLIENT_SECRET); + + assertThat(clientRepository.findByClientId(newClientId).isPresent(), is(false)); + assertEquals(true, getService().init()); + verify(clientService, times(1)).saveNewClient(any()); + verify(clientRepository, times(0)).save(any()); + } + + @Test + void testInitUpdateDashboard() throws ParseException { + String newClientId = "new-" + CLIENT_ID; + mockDashboardProperties(true, CLIENT_ID, CLIENT_SECRET); + ClientDetailsEntity dashboard = setClientDashboard(); + when(clientRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(dashboard)); + + assertThat(clientRepository.findByClientId(newClientId).isPresent(), is(false)); + assertEquals(true, getService().init()); + verify(clientService, times(0)).saveNewClient(any()); + verify(clientRepository, times(1)).save(any()); + } + + private ClientDetailsEntity createClientDashboard(String clientId, String clientSecret, + String redirectUris, Set grantTypes, Set scopes) { + ClientDetailsEntity client = new ClientDetailsEntity(); + client.setClientId(clientId); + client.setClientSecret(clientSecret); + client.setScope(scopes); + client.setGrantTypes(grantTypes); + client.setRedirectUris(Set.of(redirectUris)); + client.setCodeChallengeMethod(PKCEAlgorithm.S256); + client.setTokenEndpointAuthMethod(AuthMethod.SECRET_BASIC); + return client; + } + + private ClientDetailsEntity setClientDashboard() { + return createClientDashboard(CLIENT_ID, CLIENT_SECRET, BASE_URL, AUTH_GRAND_TYPE, SCOPES); + } + + private void mockDashboardProperties(boolean isENabled, String clientId, String secret) { + DashboardProperties properties = Mockito.mock(DashboardProperties.class); + lenient().when(properties.isEnabled()).thenReturn(isENabled); + lenient().when(properties.getClientId()).thenReturn(clientId); + lenient().when(properties.getClientSecret()).thenReturn(secret); + when(iamProperties.getDashboard()).thenReturn(properties); + } +}