diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index d75ebf1d0..a8136225f 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -1,6 +1,9 @@ name: Sonar analysis -on: +on: + push: + branches: + - develop pull_request: types: [opened, edited, reopened, synchronize] @@ -28,7 +31,7 @@ jobs: - name: Sonar analysis env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_VIANELLO }} run: mvn -B -U install sonar:sonar -Dsonar.projectKey=indigo-iam_iam -Dsonar.organization=indigo-iam diff --git a/README.md b/README.md index 75eff5b1c..4e4d31ee3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # INDIGO Identity and Access Management (IAM) service [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3496834.svg)](https://doi.org/10.5281/zenodo.3496834) -[![travis-build-tatus](https://travis-ci.org/indigo-iam/iam.svg?branch=develop)](https://travis-ci.org/indigo-iam/iam) +[![build & packaging](https://github.com/indigo-iam/iam/actions/workflows/maven.yml/badge.svg?branch=master&event=push)](https://github.com/indigo-iam/iam/actions/workflows/maven.yml) [![sonarqube-qg](https://sonarcloud.io/api/project_badges/measure?project=indigo-iam_iam&metric=alert_status)](https://sonarcloud.io/dashboard?id=indigo-iam_iam) [![sonarqube-coverage](https://sonarcloud.io/api/project_badges/measure?project=indigo-iam_iam&metric=coverage)](https://sonarcloud.io/dashboard?id=indigo-iam_iam) [![sonarqube-maintainability](https://sonarcloud.io/api/project_badges/measure?project=indigo-iam_iam&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=indigo-iam_iam) diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java index 9063a5d6a..54b4692ab 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java @@ -38,6 +38,7 @@ import it.infn.mw.iam.api.common.client.RegisteredClientDTO; import it.infn.mw.iam.api.common.client.TokenEndpointAuthenticationMethod; import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.client_registration.ClientRegistrationProperties; @Component public class ClientConverter { @@ -46,11 +47,14 @@ public class ClientConverter { private final String clientRegistrationBaseUrl; + private final ClientRegistrationProperties clientProperties; + @Autowired - public ClientConverter(IamProperties properties) { + public ClientConverter(IamProperties properties, ClientRegistrationProperties clientProperties) { this.iamProperties = properties; clientRegistrationBaseUrl = String.format("%s%s", iamProperties.getBaseUrl(), ClientRegistrationApiController.ENDPOINT); + this.clientProperties = clientProperties; } private Set cloneSet(Set stringSet) { @@ -67,18 +71,17 @@ public ClientDetailsEntity entityFromClientManagementRequest(RegisteredClientDTO ClientDetailsEntity client = entityFromRegistrationRequest(dto); if (dto.getAccessTokenValiditySeconds() != null) { - if (dto.getAccessTokenValiditySeconds() <= 0) { - client.setAccessTokenValiditySeconds(null); - } else { - client.setAccessTokenValiditySeconds(dto.getAccessTokenValiditySeconds()); - } + client.setAccessTokenValiditySeconds(dto.getAccessTokenValiditySeconds()); + } else { + client.setAccessTokenValiditySeconds( + clientProperties.getClientDefaults().getDefaultAccessTokenValiditySeconds()); } + if (dto.getRefreshTokenValiditySeconds() != null) { - if (dto.getRefreshTokenValiditySeconds() <= 0) { - client.setRefreshTokenValiditySeconds(null); - } else { - client.setRefreshTokenValiditySeconds(dto.getRefreshTokenValiditySeconds()); - } + client.setRefreshTokenValiditySeconds(dto.getRefreshTokenValiditySeconds()); + } else { + client.setRefreshTokenValiditySeconds( + clientProperties.getClientDefaults().getDefaultRefreshTokenValiditySeconds()); } if (dto.getIdTokenValiditySeconds() != null) { @@ -193,19 +196,16 @@ public ClientDetailsEntity entityFromRegistrationRequest(RegisteredClientDTO dto client.setLogoUri(dto.getLogoUri()); client.setPolicyUri(dto.getPolicyUri()); - + client.setRedirectUris(cloneSet(dto.getRedirectUris())); client.setScope(cloneSet(dto.getScope())); - - client.setGrantTypes(new HashSet<>()); + + client.setGrantTypes(new HashSet<>()); if (!isNull(dto.getGrantTypes())) { client.setGrantTypes( - dto.getGrantTypes() - .stream() - .map(AuthorizationGrantType::getGrantType) - .collect(toSet())); + dto.getGrantTypes().stream().map(AuthorizationGrantType::getGrantType).collect(toSet())); } if (dto.getScope().contains("offline_access")) { @@ -231,6 +231,11 @@ public ClientDetailsEntity entityFromRegistrationRequest(RegisteredClientDTO dto client.setCodeChallengeMethod(pkceAlgo); } + client.setAccessTokenValiditySeconds( + clientProperties.getClientDefaults().getDefaultAccessTokenValiditySeconds()); + client.setRefreshTokenValiditySeconds( + clientProperties.getClientDefaults().getDefaultRefreshTokenValiditySeconds()); + return client; } 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 617333d71..4a97f1b21 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 @@ -58,39 +58,23 @@ public ClientDetailsEntity setupClientDefaults(ClientDetailsEntity client) { client.setClientId(UUID.randomUUID().toString()); } - client.setAccessTokenValiditySeconds( - properties.getClientDefaults().getDefaultAccessTokenValiditySeconds()); - client .setIdTokenValiditySeconds(properties.getClientDefaults().getDefaultIdTokenValiditySeconds()); client.setDeviceCodeValiditySeconds( properties.getClientDefaults().getDefaultDeviceCodeValiditySeconds()); - final int rtSecs = properties.getClientDefaults().getDefaultRefreshTokenValiditySeconds(); - - if (rtSecs < 0) { - client.setRefreshTokenValiditySeconds(null); - } else { - client.setRefreshTokenValiditySeconds(rtSecs); - } - client.setAllowIntrospection(true); if (isNull(client.getContacts())) { client.setContacts(new HashSet<>()); } - if (isNull(client.getClientId())) { - client.setClientId(UUID.randomUUID().toString()); - } - if (AUTH_METHODS_REQUIRING_SECRET.contains(client.getTokenEndpointAuthMethod())) { client.setClientSecret(generateClientSecret()); } client.setAuthorities(Sets.newHashSet(Authorities.ROLE_CLIENT)); - return client; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/DefaultMetadataLookupService.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/DefaultMetadataLookupService.java index 8d0272bd0..3ab833ea6 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/DefaultMetadataLookupService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/DefaultMetadataLookupService.java @@ -26,11 +26,13 @@ import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.opensaml.common.xml.SAMLConstants; import org.opensaml.saml2.metadata.EntityDescriptor; import org.opensaml.saml2.metadata.IDPSSODescriptor; +import org.opensaml.saml2.metadata.LocalizedString; import org.opensaml.saml2.metadata.provider.MetadataProvider; import org.opensaml.saml2.metadata.provider.MetadataProviderException; import org.opensaml.saml2.metadata.provider.ObservableMetadataProvider; @@ -49,7 +51,8 @@ @Component @Profile("saml") -public class DefaultMetadataLookupService implements MetadataLookupService, ObservableMetadataProvider.Observer { +public class DefaultMetadataLookupService + implements MetadataLookupService, ObservableMetadataProvider.Observer { private static final int MAX_RESULTS = 20; private static final Logger LOG = LoggerFactory.getLogger(DefaultMetadataLookupService.class); @@ -70,7 +73,7 @@ private void initializeMetadataSet() throws MetadataProviderException { final Instant startTime = Instant.now(); LOG.debug("Initializing IdP descriptor list from metadata"); - + Set newDescriptions = new HashSet<>(); for (String idpName : metadataManager.getIDPEntityNames()) { @@ -107,6 +110,8 @@ private IdpDescription descriptionFromMetadata(EntityDescriptor descriptor) { if (!uiInfo.getDisplayNames().isEmpty()) { result.setOrganizationName(uiInfo.getDisplayNames().get(0).getName().getLocalString()); + result + .setDisplayNames(uiInfo.getDisplayNames().stream().map(dn -> dn.getName()).toList()); } } } @@ -140,6 +145,17 @@ private Optional> lookupByEntityId(String text) { public List lookupIdp(String text) { List result = new ArrayList<>(); + String textToFind = text.toLowerCase(); + + Predicate filterForDescriptions = description -> { + if (description.getDisplayNames() != null) { + return description.getDisplayNames() + .stream() + .anyMatch(name -> name.getLocalString().toLowerCase().contains(textToFind)); + } else { + return description.getEntityId().toLowerCase().contains(textToFind); + } + }; lookupByEntityId(text).ifPresent(result::addAll); @@ -149,9 +165,24 @@ public List lookupIdp(String text) { try { lock.readLock().lock(); + return descriptions.stream() - .filter(p -> p.getOrganizationName().toLowerCase().contains(text.toLowerCase())) + .filter(filterForDescriptions) .limit(MAX_RESULTS) + .map(description -> { + List displayNames = description.getDisplayNames(); + if (displayNames != null) { + + for (LocalizedString displayName : displayNames) { + String localString = displayName.getLocalString(); + if (localString.toLowerCase().contains(textToFind)) { + description.setOrganizationName(localString); + break; + } + } + } + return description; + }) .collect(Collectors.toList()); } finally { lock.readLock().unlock(); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/model/IdpDescription.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/model/IdpDescription.java index 039b3572f..7b92df54d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/model/IdpDescription.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/saml/model/IdpDescription.java @@ -15,15 +15,20 @@ */ package it.infn.mw.iam.authn.saml.model; +import java.util.List; + +import org.opensaml.saml2.metadata.LocalizedString; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @JsonInclude(Include.NON_EMPTY) -public class IdpDescription { +public class IdpDescription { private String entityId; private String organizationName; private String imageUrl; + private List displayNames; public String getEntityId() { return entityId; @@ -49,9 +54,18 @@ public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public List getDisplayNames() { + return displayNames; + } + + public void setDisplayNames(List displayNames) { + this.displayNames = displayNames; + } + @Override public String toString() { return "IdpDescription [entityId=" + entityId + ", organizationName=" + organizationName - + ", imageUrl=" + imageUrl + "]"; + + ", imageUrl=" + imageUrl + ", displayNames=" + displayNames + "]"; } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java index e45d7100d..f3d3e72ae 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java @@ -17,6 +17,7 @@ import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.AARC_PROFILE_ID; import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.IAM_PROFILE_ID; +import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.KC_PROFILE_ID; import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.WLCG_PROFILE_ID; import java.time.Clock; @@ -24,6 +25,7 @@ import java.util.Map; import org.h2.server.web.WebServlet; +import org.mitre.oauth2.repository.SystemScopeRepository; import org.mitre.oauth2.service.IntrospectionResultAssembler; import org.mitre.oauth2.service.impl.DefaultIntrospectionResultAssembler; import org.mitre.oauth2.service.impl.DefaultOAuth2AuthorizationCodeService; @@ -69,6 +71,12 @@ import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileIdTokenCustomizer; import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileTokenIntrospectionHelper; import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileUserinfoHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakGroupHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakIdTokenCustomizer; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakIntrospectionHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakJWTProfile; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakProfileAccessTokenBuilder; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakUserinfoHelper; import it.infn.mw.iam.core.oauth.profile.wlcg.WLCGGroupHelper; import it.infn.mw.iam.core.oauth.profile.wlcg.WLCGJWTProfile; import it.infn.mw.iam.core.oauth.scope.matchers.DefaultScopeMatcherRegistry; @@ -152,6 +160,27 @@ JWTProfile aarcJwtProfile(IamProperties props, IamAccountRepository accountRepo, return new AarcJWTProfile(atBuilder, idHelper, uiHelper, intrHelper); } + @Bean(name = "kcJwtProfile") + JWTProfile kcJwtProfile(IamProperties props, IamAccountRepository accountRepo, + ScopeClaimTranslationService converter, UserInfoService userInfoService, ScopeMatcherRegistry registry, ClaimValueHelper claimHelper) { + + KeycloakGroupHelper groupHelper = new KeycloakGroupHelper(); + + KeycloakProfileAccessTokenBuilder atBuilder = + new KeycloakProfileAccessTokenBuilder(props, groupHelper); + + KeycloakUserinfoHelper uiHelper = + new KeycloakUserinfoHelper(props, userInfoService); + + KeycloakIdTokenCustomizer idHelper = + new KeycloakIdTokenCustomizer(accountRepo, converter, claimHelper, groupHelper, props); + + BaseIntrospectionHelper intrHelper = new KeycloakIntrospectionHelper(props, + new DefaultIntrospectionResultAssembler(), registry, groupHelper); + + return new KeycloakJWTProfile(atBuilder, idHelper, uiHelper, intrHelper); + } + @Bean(name = "iamJwtProfile") JWTProfile iamJwtProfile(IamProperties props, IamAccountRepository accountRepo, ScopeClaimTranslationService converter, ClaimValueHelper claimHelper, @@ -188,7 +217,9 @@ attributeMapHelper, new DefaultIntrospectionResultAssembler(), registry, @Bean JWTProfileResolver jwtProfileResolver(@Qualifier("iamJwtProfile") JWTProfile iamProfile, @Qualifier("wlcgJwtProfile") JWTProfile wlcgProfile, - @Qualifier("aarcJwtProfile") JWTProfile aarcProfile, IamProperties properties, + @Qualifier("aarcJwtProfile") JWTProfile aarcProfile, + @Qualifier("kcJwtProfile") JWTProfile kcProfile, + IamProperties properties, ClientDetailsService clientDetailsService) { JWTProfile defaultProfile = iamProfile; @@ -203,10 +234,16 @@ JWTProfileResolver jwtProfileResolver(@Qualifier("iamJwtProfile") JWTProfile iam defaultProfile = aarcProfile; } + if (it.infn.mw.iam.config.IamProperties.JWTProfile.Profile.KC + .equals(properties.getJwtProfile().getDefaultProfile())) { + defaultProfile = kcProfile; + } + Map profileMap = Maps.newHashMap(); profileMap.put(IAM_PROFILE_ID, iamProfile); profileMap.put(WLCG_PROFILE_ID, wlcgProfile); profileMap.put(AARC_PROFILE_ID, aarcProfile); + profileMap.put(KC_PROFILE_ID, kcProfile); LOG.info("Default JWT profile: {}", defaultProfile.name()); return new ScopeAwareProfileResolver(defaultProfile, profileMap, clientDetailsService); @@ -252,9 +289,9 @@ FilterRegistrationBean aupSignatureCheckFilter(AUPSignatureChe @Bean - ScopeMatcherRegistry customScopeMatchersRegistry(ScopeMatchersProperties properties) { + ScopeMatcherRegistry customScopeMatchersRegistry(ScopeMatchersProperties properties, SystemScopeRepository scopeRepo) { ScopeMatchersPropertiesParser parser = new ScopeMatchersPropertiesParser(); - return new DefaultScopeMatcherRegistry(parser.parseScopeMatchersProperties(properties)); + return new DefaultScopeMatcherRegistry(parser.parseScopeMatchersProperties(properties), scopeRepo); } @Bean 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 02c4f8951..a6793d664 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 @@ -354,7 +354,8 @@ public static class JWTProfile { public enum Profile { IAM, WLCG, - AARC + AARC, + KC } Profile defaultProfile = Profile.IAM; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/client_registration/ClientRegistrationProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/client_registration/ClientRegistrationProperties.java index d12d4d63c..0d5fb2aff 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/client_registration/ClientRegistrationProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/client_registration/ClientRegistrationProperties.java @@ -19,6 +19,8 @@ import java.util.concurrent.TimeUnit; +import javax.validation.constraints.NotNull; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @@ -28,10 +30,14 @@ public class ClientRegistrationProperties { public static class ClientDefaultsProperties { - private int defaultAccessTokenValiditySeconds = (int) TimeUnit.HOURS.toSeconds(1); + @NotNull(message = "Provide a default access token lifetime") + private int defaultAccessTokenValiditySeconds; + + @NotNull(message = "Provide a default refresh token lifetime") + private int defaultRefreshTokenValiditySeconds; + private int defaultIdTokenValiditySeconds = (int) TimeUnit.MINUTES.toSeconds(10); private int defaultDeviceCodeValiditySeconds = (int) TimeUnit.MINUTES.toSeconds(10); - private int defaultRefreshTokenValiditySeconds = (int) TimeUnit.DAYS.toSeconds(30); private int defaultRegistrationAccessTokenValiditySeconds = -1; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java index ac03dc430..9d2bdd86d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java @@ -34,9 +34,10 @@ public class ScopeAwareProfileResolver implements JWTProfileResolver { public static final String AARC_PROFILE_ID = "aarc"; public static final String IAM_PROFILE_ID = "iam"; public static final String WLCG_PROFILE_ID = "wlcg"; + public static final String KC_PROFILE_ID = "kc"; private static final Set SUPPORTED_PROFILES = - newHashSet(AARC_PROFILE_ID, IAM_PROFILE_ID, WLCG_PROFILE_ID); + newHashSet(AARC_PROFILE_ID, IAM_PROFILE_ID, WLCG_PROFILE_ID, KC_PROFILE_ID); private final Map profileMap; private final JWTProfile defaultProfile; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java new file mode 100644 index 000000000..66572635e --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java @@ -0,0 +1,32 @@ +/** + * 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.oauth.profile.keycloak; + +import java.util.Set; +import java.util.stream.Collectors; + +import it.infn.mw.iam.persistence.model.IamUserInfo; + +public class KeycloakGroupHelper { + + public static final String KEYCLOAK_ROLES_CLAIM = "roles"; + + public Set resolveGroupNames(IamUserInfo userInfo) { + + return userInfo.getGroups().stream().map(g -> g.getName()).collect(Collectors.toSet()); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java new file mode 100644 index 000000000..6c1be7320 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java @@ -0,0 +1,66 @@ +/** + * 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.oauth.profile.keycloak; + +import java.util.Set; + +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.service.ScopeClaimTranslationService; +import org.springframework.security.oauth2.provider.OAuth2Request; + +import com.nimbusds.jwt.JWTClaimsSet.Builder; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.iam.ClaimValueHelper; +import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileIdTokenCustomizer; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamUserInfo; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; + +@SuppressWarnings("deprecation") +public class KeycloakIdTokenCustomizer extends IamJWTProfileIdTokenCustomizer { + + private final KeycloakGroupHelper groupHelper; + + public KeycloakIdTokenCustomizer(IamAccountRepository accountRepo, + ScopeClaimTranslationService scopeClaimConverter, ClaimValueHelper claimValueHelper, + KeycloakGroupHelper groupHelper, IamProperties properties) { + super(accountRepo, scopeClaimConverter, claimValueHelper, properties); + this.groupHelper = groupHelper; + } + + @Override + public void customizeIdTokenClaims(Builder idClaims, ClientDetailsEntity client, + OAuth2Request request, String sub, OAuth2AccessTokenEntity accessToken, IamAccount account) { + + super.customizeIdTokenClaims(idClaims, client, request, sub, accessToken, account); + + IamUserInfo info = account.getUserInfo(); + Set groupNames = groupHelper.resolveGroupNames(info); + + if (!groupNames.isEmpty()) { + idClaims.claim(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groupNames); + } + + // Drop group claims as set by IAM JWT profile + idClaims.claim("groups", null); + + includeLabelsInIdToken(idClaims, account); + + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java new file mode 100644 index 000000000..56448a033 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java @@ -0,0 +1,61 @@ +/** + * 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.oauth.profile.keycloak; + +import java.util.Map; +import java.util.Set; + +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.oauth2.service.IntrospectionResultAssembler; +import org.mitre.openid.connect.model.UserInfo; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.common.BaseIntrospectionHelper; +import it.infn.mw.iam.core.oauth.scope.matchers.ScopeMatcherRegistry; +import it.infn.mw.iam.persistence.repository.UserInfoAdapter; + + +public class KeycloakIntrospectionHelper extends BaseIntrospectionHelper { + + private final KeycloakGroupHelper groupHelper; + + public KeycloakIntrospectionHelper(IamProperties props, IntrospectionResultAssembler assembler, + ScopeMatcherRegistry registry, KeycloakGroupHelper helper) { + super(props, assembler, registry); + this.groupHelper = helper; + } + + @Override + public Map assembleIntrospectionResult(OAuth2AccessTokenEntity accessToken, + UserInfo userInfo, Set authScopes) { + + Map result = getAssembler().assembleFrom(accessToken, userInfo, authScopes); + + addIssuerClaim(result); + addAudience(result, accessToken); + addScopeClaim(result, filterScopes(accessToken, authScopes)); + + Set groups = + groupHelper.resolveGroupNames(((UserInfoAdapter) userInfo).getUserinfo()); + + if (!groups.isEmpty()) { + result.put(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groups); + } + + return result; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java new file mode 100644 index 000000000..998828e11 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java @@ -0,0 +1,41 @@ +/** + * 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.oauth.profile.keycloak; + +import it.infn.mw.iam.core.oauth.profile.IDTokenCustomizer; +import it.infn.mw.iam.core.oauth.profile.IntrospectionResultHelper; +import it.infn.mw.iam.core.oauth.profile.JWTAccessTokenBuilder; +import it.infn.mw.iam.core.oauth.profile.UserInfoHelper; +import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfile; + +public class KeycloakJWTProfile extends IamJWTProfile { + + public static final String PROFILE_VERSION = "1.0"; + public static final String PROFILE_NAME = "Keycloak JWT profile " + PROFILE_VERSION; + + public KeycloakJWTProfile(JWTAccessTokenBuilder accessTokenBuilder, + IDTokenCustomizer idTokenBuilder, UserInfoHelper userInfoHelper, + IntrospectionResultHelper introspectionHelper) { + + super(accessTokenBuilder, idTokenBuilder, userInfoHelper, introspectionHelper); + } + + @Override + public String name() { + return PROFILE_NAME; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java new file mode 100644 index 000000000..672538d61 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java @@ -0,0 +1,73 @@ +/** + * 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.oauth.profile.keycloak; + +import static java.util.Objects.isNull; +import static java.util.stream.Collectors.joining; + +import java.time.Instant; +import java.util.Date; +import java.util.Set; + +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.model.UserInfo; +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTClaimsSet.Builder; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.common.BaseAccessTokenBuilder; +import it.infn.mw.iam.persistence.repository.UserInfoAdapter; + +@SuppressWarnings("deprecation") +public class KeycloakProfileAccessTokenBuilder extends BaseAccessTokenBuilder { + + public static final String PROFILE_VERSION = "1.0"; + + final KeycloakGroupHelper groupHelper; + + public KeycloakProfileAccessTokenBuilder(IamProperties properties, KeycloakGroupHelper groupHelper) { + super(properties); + this.groupHelper = groupHelper; + } + + + @Override + public JWTClaimsSet buildAccessToken(OAuth2AccessTokenEntity token, + OAuth2Authentication authentication, UserInfo userInfo, Instant issueTime) { + + Builder builder = baseJWTSetup(token, authentication, userInfo, issueTime); + + builder.notBeforeTime(Date.from(issueTime)); + + if (!token.getScope().isEmpty()) { + builder.claim(SCOPE_CLAIM_NAME, token.getScope().stream().collect(joining(SPACE))); + } + + if (!isNull(userInfo)) { + Set groupNames = + groupHelper.resolveGroupNames(((UserInfoAdapter) userInfo).getUserinfo()); + + if (!groupNames.isEmpty()) { + builder.claim(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groupNames); + } + } + + return builder.build(); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java new file mode 100644 index 000000000..c6e7c1119 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java @@ -0,0 +1,65 @@ +/** + * 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.oauth.profile.keycloak; + + + +import static java.util.Objects.isNull; + +import org.mitre.openid.connect.model.UserInfo; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import it.infn.mw.iam.core.userinfo.DelegateUserInfoAdapter; + +public class KeycloakUserInfoAdapter extends DelegateUserInfoAdapter { + + private static final long serialVersionUID = 1L; + + private final String[] resolvedGroups; + + private KeycloakUserInfoAdapter(UserInfo delegate, String[] resolvedGroups) { + super(delegate); + this.resolvedGroups = resolvedGroups; + } + + @Override + public JsonObject toJson() { + JsonObject json = super.toJson(); + + json.remove("groups"); + + if (!isNull(resolvedGroups)) { + JsonArray groups = new JsonArray(); + for (String g : resolvedGroups) { + groups.add(new JsonPrimitive(g)); + } + json.add(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groups); + } + + return json; + } + + public static KeycloakUserInfoAdapter forUserInfo(UserInfo delegate, String[] resolvedGroups) { + return new KeycloakUserInfoAdapter(delegate, resolvedGroups); + } + + public static KeycloakUserInfoAdapter forUserInfo(UserInfo delegate) { + return new KeycloakUserInfoAdapter(delegate, null); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java new file mode 100644 index 000000000..8c0d9c00e --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java @@ -0,0 +1,61 @@ +/** + * 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.oauth.profile.keycloak; + +import static it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakUserInfoAdapter.forUserInfo; +import static java.util.Objects.isNull; + +import java.util.Optional; + +import org.mitre.openid.connect.model.UserInfo; +import org.mitre.openid.connect.service.UserInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.wlcg.WLCGUserinfoHelper; + +@SuppressWarnings("deprecation") +public class KeycloakUserinfoHelper extends WLCGUserinfoHelper { + + public static final Logger LOG = LoggerFactory.getLogger(KeycloakUserinfoHelper.class); + + public KeycloakUserinfoHelper(IamProperties props, UserInfoService userInfoService) { + super(props, userInfoService); + } + + @Override + public UserInfo resolveUserInfo(OAuth2Authentication authentication) { + + UserInfo ui = lookupUserinfo(authentication); + + if (isNull(ui)) { + return null; + } + + Optional resolvedGroups = + resolveGroupsFromToken(authentication, KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM); + + if (resolvedGroups.isPresent()) { + return forUserInfo(ui, resolvedGroups.get()); + } else { + return forUserInfo(ui); + } + + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java index 339de2b7d..62baa1e6c 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java @@ -44,7 +44,7 @@ public WLCGUserinfoHelper(IamProperties props, UserInfoService userInfoService) } - private Optional resolveGroupsFromToken(OAuth2Authentication authentication) { + protected Optional resolveGroupsFromToken(OAuth2Authentication authentication, String groupClaim) { OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails(); if (isNull(details) || isNull(details.getTokenValue())) { @@ -53,7 +53,7 @@ private Optional resolveGroupsFromToken(OAuth2Authentication authentic try { JWT accessToken = JWTParser.parse(details.getTokenValue()); - String[] resolvedGroups = accessToken.getJWTClaimsSet().getStringArrayClaim("wlcg.groups"); + String[] resolvedGroups = accessToken.getJWTClaimsSet().getStringArrayClaim(groupClaim); return Optional.ofNullable(resolvedGroups); @@ -72,7 +72,7 @@ public UserInfo resolveUserInfo(OAuth2Authentication authentication) { return null; } - Optional resolvedGroups = resolveGroupsFromToken(authentication); + Optional resolvedGroups = resolveGroupsFromToken(authentication, WLCGGroupHelper.WLCG_GROUPS_SCOPE); if (resolvedGroups.isPresent()) { return forUserInfo(ui, resolvedGroups.get()); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/IamSystemScopeService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/IamSystemScopeService.java index 7f83355cf..4f655b70d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/IamSystemScopeService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/IamSystemScopeService.java @@ -37,7 +37,7 @@ public IamSystemScopeService(ScopeMatcherRegistry matcherRegistry) { public boolean scopesMatch(Set allowedScopes, Set requestedScopes) { Set allowedScopeMatchers = - requestedScopes.stream().map(scopeMatcherRegistry::findMatcherForScope).collect(toSet()); + allowedScopes.stream().map(scopeMatcherRegistry::findMatcherForScope).collect(toSet()); for (String rs : requestedScopes) { if (allowedScopeMatchers.stream().noneMatch(m -> m.matches(rs))) { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/matchers/DefaultScopeMatcherRegistry.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/matchers/DefaultScopeMatcherRegistry.java index 58ac2be88..21bcd02b4 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/matchers/DefaultScopeMatcherRegistry.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/matchers/DefaultScopeMatcherRegistry.java @@ -17,6 +17,8 @@ import java.util.Set; +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.repository.SystemScopeRepository; import org.springframework.cache.annotation.Cacheable; import org.springframework.security.oauth2.provider.ClientDetails; @@ -26,11 +28,14 @@ public class DefaultScopeMatcherRegistry implements ScopeMatcherRegistry { public static final String SCOPE_CACHE_KEY = "scope-matcher"; - + private final Set customMatchers; - public DefaultScopeMatcherRegistry(Set customMatchers) { + private final SystemScopeRepository scopeRepo; + + public DefaultScopeMatcherRegistry(Set customMatchers, SystemScopeRepository scopeRepo) { this.customMatchers = customMatchers; + this.scopeRepo = scopeRepo; } @Override @@ -49,7 +54,10 @@ public Set findMatchersForClient(ClientDetails client) { @Override public ScopeMatcher findMatcherForScope(String scope) { + Set systemScopes = scopeRepo.getAll(); + return customMatchers.stream() + .filter(s -> systemScopes.toString().contains(scope)) .filter(m -> m.matches(scope)) .findFirst() .orElse(StringEqualsScopeMatcher.stringEqualsMatcher(scope)); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java index 61169241d..314dd15a4 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java @@ -45,6 +45,7 @@ import static it.infn.mw.iam.core.userinfo.UserInfoClaim.WEBSITE; import static it.infn.mw.iam.core.userinfo.UserInfoClaim.WLCG_GROUPS; import static it.infn.mw.iam.core.userinfo.UserInfoClaim.ZONEINFO; +import static it.infn.mw.iam.core.userinfo.UserInfoClaim.ROLES; import java.util.EnumSet; import java.util.HashSet; @@ -79,7 +80,7 @@ public class IamScopeClaimTranslationService implements ScopeClaimTranslationSer protected static final Set PROFILE_CLAIMS = EnumSet.of(NAME, PREFERRED_USERNAME, GIVEN_NAME, FAMILY_NAME, MIDDLE_NAME, NICKNAME, PROFILE, PICTURE, WEBSITE, GENDER, ZONEINFO, - LOCALE, UPDATED_AT, BIRTHDATE, ORGANISATION_NAME, GROUPS, EXTERNAL_AUTHN); + LOCALE, UPDATED_AT, BIRTHDATE, ORGANISATION_NAME, GROUPS, EXTERNAL_AUTHN, ROLES); protected static final Set EMAIL_CLAIMS = EnumSet.of(EMAIL, EMAIL_VERIFIED); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java index 720b9a015..cbac68093 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java @@ -45,7 +45,8 @@ public enum UserInfoClaim { EDUPERSON_ENTITLEMENT("eduperson_entitlement"), ENTITLEMENTS("entitlements"), EDUPERSON_ASSURANCE("eduperson_assurance"), - SSH_KEYS("ssh_keys"); + SSH_KEYS("ssh_keys"), + ROLES("roles"); private UserInfoClaim(String claimName) { this.claimName = claimName; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java index bec096df0..c481a8078 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java @@ -24,6 +24,7 @@ import org.springframework.web.servlet.HandlerInterceptor; import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.client_registration.ClientRegistrationProperties; import it.infn.mw.iam.config.saml.IamSamlProperties; import it.infn.mw.iam.core.web.loginpage.LoginPageConfiguration; import it.infn.mw.iam.rcauth.RCAuthProperties; @@ -39,8 +40,8 @@ public class IamViewInfoInterceptor implements HandlerInterceptor { public static final String GIT_COMMIT_ID_KEY = "gitCommitId"; public static final String SIMULATE_NETWORK_LATENCY_KEY = "simulateNetworkLatency"; public static final String RCAUTH_ENABLED_KEY = "iamRcauthEnabled"; - public static final String RESOURCES_PATH_KEY = "resourcesPrefix"; + public static final String CLIENT_DEFAULTS_PROPERTIES_KEY = "clientDefaultsProperties"; @Value("${iam.version}") String iamVersion; @@ -62,7 +63,10 @@ public class IamViewInfoInterceptor implements HandlerInterceptor { @Autowired IamProperties iamProperties; - + + @Autowired + ClientRegistrationProperties clientRegistrationProperties; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { @@ -78,6 +82,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons request.setAttribute(RCAUTH_ENABLED_KEY, rcAuthProperties.isEnabled()); + request.setAttribute(CLIENT_DEFAULTS_PROPERTIES_KEY, clientRegistrationProperties.getClientDefaults()); if (iamProperties.getVersionedStaticResources().isEnableVersioning()) { request.setAttribute(RESOURCES_PATH_KEY, String.format("/resources/%s", gitCommitId)); diff --git a/iam-login-service/src/main/resources/application.yml b/iam-login-service/src/main/resources/application.yml index 0e0823cbf..bf051419b 100644 --- a/iam-login-service/src/main/resources/application.yml +++ b/iam-login-service/src/main/resources/application.yml @@ -213,6 +213,10 @@ task: client-registration: allow-for: ${IAM_CLIENT_REGISTRATION_ALLOW_FOR:ANYONE} enable: ${IAM_CLIENT_REGISTRATION_ENABLE:true} + client-defaults: + default-access-token-validity-seconds: ${DEFAULT_ACCESS_TOKEN_VALIDITY_SECONDS:3600} + default-refresh-token-validity-seconds: ${DEFAULT_REFRESH_TOKEN_VALIDITY_SECONDS:108000} + management: health: diff --git a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag index 58109b39c..3abad14d6 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag +++ b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag @@ -88,6 +88,14 @@ function getOrganisationName() { return '${iamOrganisationName}'; } +function getAccessTokenValiditySeconds() { + return ${clientDefaultsProperties.defaultAccessTokenValiditySeconds}; +} + +function getRefreshTokenValiditySeconds() { + return ${clientDefaultsProperties.defaultRefreshTokenValiditySeconds}; +} + function getOidcEnabled() { return ${loginPageConfiguration.oidcEnabled}; } diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.html index 1fb9c33b4..3820c1cd0 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.html +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.html @@ -20,16 +20,7 @@
-
- -
- + ng-model="$ctrl.client.access_token_validity_seconds">
@@ -66,16 +57,20 @@ + ng-required ng-min="30"> +

+ This will setup after how many seconds the refresh token + expires. Type 0 to set an infinite lifetime. +

+ +
-
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.js index 9e2b67da4..62a005fa3 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/tokensettings/tokensettings.component.js @@ -26,25 +26,27 @@ self.toggleOfflineAccess = toggleOfflineAccess; self.canIssueRefreshTokens = false; self.hasDeviceCodeGrantType = false; - + self.accessTokenValiditySeconds = getAccessTokenValiditySeconds(); + self.refreshTokenValiditySeconds = getRefreshTokenValiditySeconds(); self.$onInit = function () { console.debug('TokenSettingsController.self', self); - if (!self.client.access_token_validity_seconds) { - self.client.access_token_validity_seconds = 0; + if (self.client.access_token_validity_seconds == null) { + self.client.access_token_validity_seconds = self.accessTokenValiditySeconds; } - if (!self.client.refresh_token_validity_seconds) { - self.client.refresh_token_validity_seconds = 0; + + if (self.client.refresh_token_validity_seconds == null) { + self.client.refresh_token_validity_seconds = self.refreshTokenValiditySeconds; } $scope.$watch('$ctrl.client.access_token_validity_seconds', function handleChange(newVal, oldVal) { - if (!self.client.access_token_validity_seconds) { + if (newVal <= 0) { self.client.access_token_validity_seconds = 0; } }); $scope.$watch('$ctrl.client.refresh_token_validity_seconds', function handleChange(newVal, oldVal) { - if (!self.client.refresh_token_validity_seconds) { + if (newVal <= 0) { self.client.refresh_token_validity_seconds = 0; } }); diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientManagementAPIIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientManagementAPIIntegrationTests.java index c3d8002ec..6e7693552 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientManagementAPIIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientManagementAPIIntegrationTests.java @@ -20,6 +20,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertTrue; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -144,4 +145,85 @@ public void ratRotationWorks() throws Exception { client = mapper.readValue(responseJson, RegisteredClientDTO.class); assertThat(client.getRegistrationAccessToken(), notNullValue()); } + + @Test + public void setTokenLifetimesWorks() throws Exception { + + String clientJson = ClientJsonStringBuilder.builder() + .scopes("openid") + .accessTokenValiditySeconds(null) + .refreshTokenValiditySeconds(null) + .build(); + + String responseJson = mvc + .perform(post(ClientManagementAPIController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO client = mapper.readValue(responseJson, RegisteredClientDTO.class); + assertTrue(client.getAccessTokenValiditySeconds().equals(3600)); + assertTrue(client.getRefreshTokenValiditySeconds().equals(108000)); + + clientJson = ClientJsonStringBuilder.builder() + .scopes("openid") + .accessTokenValiditySeconds(0) + .refreshTokenValiditySeconds(0) + .build(); + + responseJson = mvc + .perform(post(ClientManagementAPIController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andReturn() + .getResponse() + .getContentAsString(); + + client = mapper.readValue(responseJson, RegisteredClientDTO.class); + assertTrue(client.getAccessTokenValiditySeconds().equals(0)); + assertTrue(client.getRefreshTokenValiditySeconds().equals(0)); + + clientJson = ClientJsonStringBuilder.builder() + .scopes("openid") + .accessTokenValiditySeconds(10) + .refreshTokenValiditySeconds(10) + .build(); + + responseJson = mvc + .perform(post(ClientManagementAPIController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andReturn() + .getResponse() + .getContentAsString(); + + client = mapper.readValue(responseJson, RegisteredClientDTO.class); + assertTrue(client.getAccessTokenValiditySeconds().equals(10)); + assertTrue(client.getRefreshTokenValiditySeconds().equals(10)); + + } + + @Test + public void negativeTokenLifetimesNotAllowed() throws Exception { + + String clientJson = + ClientJsonStringBuilder.builder().scopes("openid").accessTokenValiditySeconds(-1).build(); + + mvc + .perform(post(ClientManagementAPIController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.error", containsString("must be greater than or equal to 0"))); + + clientJson = + ClientJsonStringBuilder.builder().scopes("openid").refreshTokenValiditySeconds(-1).build(); + + mvc + .perform(post(ClientManagementAPIController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.error", containsString("must be greater than or equal to 0"))); + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientRegistrationAPIIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientRegistrationAPIIntegrationTests.java new file mode 100644 index 000000000..e1dddb7c2 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/client/ClientRegistrationAPIIntegrationTests.java @@ -0,0 +1,140 @@ +/** + * 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.api.client; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.api.client.registration.ClientRegistrationApiController; +import it.infn.mw.iam.api.common.client.RegisteredClientDTO; +import it.infn.mw.iam.test.api.TestSupport; +import it.infn.mw.iam.test.oauth.client_registration.ClientRegistrationTestSupport.ClientJsonStringBuilder; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@IamMockMvcIntegrationTest +@WithMockUser(username = "test", roles = "USER") +@SpringBootTest(classes = {IamLoginService.class}) +public class ClientRegistrationAPIIntegrationTests extends TestSupport { + + @Autowired + private MockMvc mvc; + + @Autowired + private ObjectMapper mapper; + + @Test + @WithAnonymousUser + public void dynamicRegistrationWorksForAnonymousUser() throws Exception { + + String clientJson = ClientJsonStringBuilder.builder().scopes("openid").build(); + + mvc + .perform(post(ClientRegistrationApiController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andExpect(jsonPath("$.client_id").exists()) + .andExpect(jsonPath("$.client_secret").exists()) + .andExpect(jsonPath("$.client_name").exists()) + .andExpect(jsonPath("$.grant_types").exists()) + .andExpect(jsonPath("$.scope").exists()) + .andExpect(jsonPath("$.dynamically_registered").value(true)) + .andExpect(jsonPath("$.registration_access_token").exists()); + + } + + @Test + public void clientDetailsVisibleWithAuthentication() throws Exception { + + String clientJson = ClientJsonStringBuilder.builder().scopes("openid").build(); + + String responseJson = mvc + .perform(post(ClientRegistrationApiController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO client = mapper.readValue(responseJson, RegisteredClientDTO.class); + + final String url = + String.format("%s/%s", ClientRegistrationApiController.ENDPOINT, client.getClientId()); + + mvc.perform(get(url)) + .andExpect(OK) + .andExpect(jsonPath("$.client_id").value(client.getClientId())) + .andExpect(jsonPath("$.client_name").value(client.getClientName())); + + } + + @Test + public void clientRemovalWorksWithAuthentication() throws Exception { + + String clientJson = ClientJsonStringBuilder.builder().scopes("openid").build(); + + String responseJson = mvc + .perform(post(ClientRegistrationApiController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO client = mapper.readValue(responseJson, RegisteredClientDTO.class); + + final String url = + String.format("%s/%s", ClientRegistrationApiController.ENDPOINT, client.getClientId()); + + mvc.perform(delete(url)).andExpect(NO_CONTENT); + + mvc.perform(get(url)) + .andExpect(NOT_FOUND) + .andExpect(jsonPath("$.error", containsString("Client not found"))); + } + + @Test + public void tokenLifetimesAreNotEditable() throws Exception { + + String clientJson = ClientJsonStringBuilder.builder() + .scopes("openid") + .accessTokenValiditySeconds(10) + .refreshTokenValiditySeconds(10) + .build(); + + mvc + .perform(post(ClientRegistrationApiController.ENDPOINT).contentType(APPLICATION_JSON) + .content(clientJson)) + .andExpect(CREATED) + .andExpect(jsonPath("$.access_token_validity_seconds").doesNotExist()) + .andExpect(jsonPath("$.refresh_token_validity_seconds").doesNotExist()); + + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/saml/MetadataLookupServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/saml/MetadataLookupServiceTests.java index 7c21a92e7..5fdde229f 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/saml/MetadataLookupServiceTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/saml/MetadataLookupServiceTests.java @@ -55,10 +55,11 @@ public class MetadataLookupServiceTests { public static final String IDP2_ENTITY_ID = "urn:test:idp2"; public static final String IDP3_ENTITY_ID = "urn:test:idp3"; public static final String IDP4_ENTITY_ID = "urn:test:idp4"; - + public static final String IDP1_ORGANIZATION_NAME = "IDP1 organization"; public static final String IDP2_ORGANIZATION_NAME = "IDP2 organization"; - + public static final String IDP4_ORGANIZATION_NAME = "IDP4 organization"; + @Mock MetadataManager manager; @@ -75,23 +76,26 @@ public class MetadataLookupServiceTests { UIInfo idp1UIInfo, idp2UIInfo, idp4UIInfo; @Mock - DisplayName idp1DisplayName, idp2DisplayName, idp4DisplayName; + DisplayName idp1DisplayName, idp1ItDisplayName, idp2DisplayName, idp4DisplayName; @Mock - LocalizedString idp1LocalizedString, idp2LocalizedString, idp4LocalizedString; + LocalizedString idp1LocalizedString, idp1ItLocalizedString, idp2LocalizedString, + idp4LocalizedString; @Before public void setup() throws MetadataProviderException { when(idp1LocalizedString.getLocalString()).thenReturn(IDP1_ORGANIZATION_NAME); + when(idp1ItLocalizedString.getLocalString()).thenReturn("IDP1 organizzazione"); when(idp1DisplayName.getName()).thenReturn(idp1LocalizedString); - when(idp1UIInfo.getDisplayNames()).thenReturn(asList(idp1DisplayName)); + when(idp1ItDisplayName.getName()).thenReturn(idp1ItLocalizedString); + when(idp1UIInfo.getDisplayNames()).thenReturn(asList(idp1DisplayName, idp1ItDisplayName)); when(idp2LocalizedString.getLocalString()).thenReturn(IDP2_ORGANIZATION_NAME); when(idp2DisplayName.getName()).thenReturn(idp2LocalizedString); when(idp2UIInfo.getDisplayNames()).thenReturn(asList(idp2DisplayName)); - when(idp4LocalizedString.getLocalString()).thenReturn(""); + when(idp4LocalizedString.getLocalString()).thenReturn(IDP4_ORGANIZATION_NAME); when(idp4DisplayName.getName()).thenReturn(idp4LocalizedString); when(idp4UIInfo.getDisplayNames()).thenReturn(asList(idp4DisplayName)); @@ -102,7 +106,7 @@ public void setup() throws MetadataProviderException { .thenReturn(asList(idp2UIInfo)); when(idp4SsoExtensions.getUnknownXMLObjects(UIInfo.DEFAULT_ELEMENT_NAME)) - .thenReturn(asList(idp4UIInfo)); + .thenReturn(asList(idp4UIInfo)); when(idp1SsoDesc.getExtensions()).thenReturn(idp1SsoExtensions); @@ -115,7 +119,7 @@ public void setup() throws MetadataProviderException { when(idp2Desc.getEntityID()).thenReturn(IDP2_ENTITY_ID); when(idp2Desc.getIDPSSODescriptor(SAMLConstants.SAML20P_NS)).thenReturn(idp2SsoDesc); - + when(idp3Desc.getEntityID()).thenReturn(IDP3_ENTITY_ID); when(idp4Desc.getEntityID()).thenReturn(IDP4_ENTITY_ID); @@ -126,8 +130,8 @@ public void setup() throws MetadataProviderException { when(manager.getEntityDescriptor(IDP3_ENTITY_ID)).thenReturn(idp3Desc); when(manager.getEntityDescriptor(IDP4_ENTITY_ID)).thenReturn(idp4Desc); - when(manager.getIDPEntityNames()).thenReturn(Sets.newHashSet(IDP1_ENTITY_ID, IDP2_ENTITY_ID, - IDP3_ENTITY_ID, IDP4_ENTITY_ID)); + when(manager.getIDPEntityNames()) + .thenReturn(Sets.newHashSet(IDP1_ENTITY_ID, IDP2_ENTITY_ID, IDP3_ENTITY_ID, IDP4_ENTITY_ID)); } @@ -138,72 +142,86 @@ public void testServiceInitialization() throws MetadataProviderException { assertNotNull(service.listIdps()); List idps = service.listIdps(); - + assertThat(idps, hasSize(4)); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), hasProperty("organizationName", is(IDP1_ORGANIZATION_NAME))))); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP2_ENTITY_ID)), hasProperty("organizationName", is(IDP2_ORGANIZATION_NAME))))); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP3_ENTITY_ID)), hasProperty("organizationName", is(IDP3_ENTITY_ID))))); assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP4_ENTITY_ID)), - hasProperty("organizationName", is(IDP4_ENTITY_ID))))); + hasProperty("organizationName", is(IDP4_ORGANIZATION_NAME))))); } - - + + @Test public void testEmptyMetadataInitialization() { when(manager.getIDPEntityNames()).thenReturn(emptySet()); DefaultMetadataLookupService service = new DefaultMetadataLookupService(manager); - + assertThat(service.listIdps(), hasSize(0)); } @Test - public void testLookupByOrganizationNameWorks() { + public void testEmptyTextToFind() { DefaultMetadataLookupService service = new DefaultMetadataLookupService(manager); - List idps = service.lookupIdp(IDP1_ORGANIZATION_NAME); - assertThat(idps, hasSize(1)); - - assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), + List idps = service.lookupIdp("noMatchOnTextToFind"); + assertThat(idps, hasSize(0)); + } + + @Test + public void testLookupByOrganizationNameWorks() { + DefaultMetadataLookupService service = new DefaultMetadataLookupService(manager); + + List idpsIt = service.lookupIdp("organizz"); + assertThat(idpsIt, hasSize(1)); + + assertThat(idpsIt, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), + hasProperty("organizationName", is("IDP1 organizzazione"))))); + + List idpsEn = service.lookupIdp(IDP1_ORGANIZATION_NAME); + assertThat(idpsEn, hasSize(1)); + + assertThat(idpsEn, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), hasProperty("organizationName", is(IDP1_ORGANIZATION_NAME))))); } - + @Test public void testPartialLookupWorks() { DefaultMetadataLookupService service = new DefaultMetadataLookupService(manager); - + List idps = service.lookupIdp("idp"); assertThat(idps, hasSize(4)); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), hasProperty("organizationName", is(IDP1_ORGANIZATION_NAME))))); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP2_ENTITY_ID)), hasProperty("organizationName", is(IDP2_ORGANIZATION_NAME))))); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP3_ENTITY_ID)), hasProperty("organizationName", is(IDP3_ENTITY_ID))))); assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP4_ENTITY_ID)), - hasProperty("organizationName", is(IDP4_ENTITY_ID))))); + hasProperty("organizationName", is(IDP4_ORGANIZATION_NAME))))); } - + @Test public void testEntityIdLookupWorks() { - + DefaultMetadataLookupService service = new DefaultMetadataLookupService(manager); List idps = service.lookupIdp(IDP1_ENTITY_ID); assertThat(idps, hasSize(1)); - + assertThat(idps, hasItem(allOf(hasProperty("entityId", is(IDP1_ENTITY_ID)), hasProperty("organizationName", is(IDP1_ORGANIZATION_NAME))))); - + idps = service.lookupIdp("unknown"); assertThat(idps, hasSize(0)); } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java index 51fedb4bf..eb0532684 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java @@ -37,6 +37,7 @@ import java.util.UUID; import org.junit.After; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -71,7 +72,7 @@ @SpringBootTest(classes = {IamLoginService.class, CoreControllerTestSupport.class, CernAccountLifecycleTests.TestConfig.class}) @TestPropertySource(properties = { - // @formatter:off +// @formatter:off "lifecycle.account.expiredAccountPolicy.suspensionGracePeriodDays=0", "lifecycle.account.expiredAccountPolicy.removalGracePeriodDays=30", "cern.task.pageSize=5" @@ -229,6 +230,87 @@ public void testActionNotSetForDisabledInValidAccounts() { assertThat(timestampLabel.get().getValue(), is(valueOf(clock.instant().toEpochMilli()))); } + @Ignore + @Test + public void testLifecycleStates() { + + when(hrDb.hasValidExperimentParticipation(anyString())).thenReturn(false); + + IamAccount testAccount = + repo.findByUuid(TEST_USER_UUID).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + + testAccount.setActive(true); + service.setLabel(testAccount, cernPersonIdLabel()); + + handler.run(); + + testAccount = + repo.findByUuid(TEST_USER_UUID).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + + assertThat(testAccount.isActive(), is(false)); + Optional statusLabel = + testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + Optional actionLabel = + testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_ACTION); + Optional timestampLabel = + testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP); + + assertThat(statusLabel.isPresent(), is(true)); + assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); + + assertThat(actionLabel.isPresent(), is(true)); + assertThat(actionLabel.get().getValue(), + is(CernHrLifecycleHandler.Action.DISABLE_ACCOUNT.name())); + + assertThat(timestampLabel.isPresent(), is(true)); + assertThat(timestampLabel.get().getValue(), is(valueOf(clock.instant().toEpochMilli()))); + + handler.run(); + + statusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + actionLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_ACTION); + + assertThat(statusLabel.isPresent(), is(true)); + assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); + + assertThat(actionLabel.isPresent(), is(true)); + assertThat(actionLabel.get().getValue(), + is(CernHrLifecycleHandler.Action.DISABLE_ACCOUNT.name())); + + when(hrDb.hasValidExperimentParticipation(anyString())).thenReturn(true); + when(hrDb.getHrDbPersonRecord(anyString())).thenReturn(voPerson("988211")); + + assertThat(testAccount.isActive(), is(false)); + + handler.run(); + + statusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + actionLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_ACTION); + + assertThat(testAccount.isActive(), is(true)); + + assertThat(statusLabel.isPresent(), is(true)); + assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); + + assertThat(actionLabel.isPresent(), is(true)); + assertThat(actionLabel.get().getValue(), + is(CernHrLifecycleHandler.Action.RESTORE_ACCOUNT.name())); + + handler.run(); + + assertThat(testAccount.isActive(), is(true)); + + statusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + actionLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_ACTION); + + assertThat(statusLabel.isPresent(), is(true)); + assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); + + assertThat(actionLabel.isPresent(), is(true)); + assertThat(actionLabel.get().getValue(), is(CernHrLifecycleHandler.Action.NO_ACTION.name())); + + } + @Test public void testRestoreLifecycleWorks() { @@ -270,7 +352,9 @@ public void testRestoreLifecycleWorks() { @Test public void testApiErrorIsHandled() { - when(hrDb.hasValidExperimentParticipation(anyString())).thenThrow(new CernHrDbApiError("API is unreachable")); + + when(hrDb.hasValidExperimentParticipation(anyString())) + .thenThrow(new CernHrDbApiError("API is unreachable")); IamAccount testAccount = repo.findByUuid(TEST_USER_UUID).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); @@ -335,8 +419,7 @@ public void testRestoreLifecycleDoesNotTouchSuspendedAccount() { assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); assertThat(actionLabel.isPresent(), is(true)); - assertThat(actionLabel.get().getValue(), - is(CernHrLifecycleHandler.Action.NO_ACTION.name())); + assertThat(actionLabel.get().getValue(), is(CernHrLifecycleHandler.Action.NO_ACTION.name())); assertThat(timestampLabel.isPresent(), is(true)); assertThat(timestampLabel.get().getValue(), is(valueOf(clock.instant().toEpochMilli()))); @@ -373,8 +456,7 @@ public void testIgnoreAccount() { assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.OK.name())); assertThat(actionLabel.isPresent(), is(true)); - assertThat(actionLabel.get().getValue(), - is(CernHrLifecycleHandler.Action.NO_ACTION.name())); + assertThat(actionLabel.get().getValue(), is(CernHrLifecycleHandler.Action.NO_ACTION.name())); assertThat(timestampLabel.isPresent(), is(true)); assertThat(timestampLabel.get().getValue(), is(valueOf(clock.instant().toEpochMilli()))); @@ -418,7 +500,6 @@ public void testPaginationWorks() { assertThat(timestampLabel.isPresent(), is(true)); assertThat(timestampLabel.get().getValue(), is(valueOf(clock.instant().toEpochMilli()))); - } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/client_registration/ClientRegistrationTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/client_registration/ClientRegistrationTestSupport.java index e1277cf85..78d3c3fcd 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/client_registration/ClientRegistrationTestSupport.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/client_registration/ClientRegistrationTestSupport.java @@ -42,47 +42,59 @@ public class ClientRegistrationTestSupport { public static final String LEGACY_REGISTER_ENDPOINT = "/register"; public static class ClientJsonStringBuilder { - + static final Joiner JOINER = Joiner.on(RegisteredClientFields.SCOPE_SEPARATOR); - + String name = "test_client"; Set redirectUris = Sets.newHashSet("http://localhost:9090"); Set grantTypes = Sets.newHashSet("client_credentials"); Set scopes = Sets.newHashSet(); Set responseTypes = Sets.newHashSet(); - - private ClientJsonStringBuilder() { - } - + Integer accessTokenValiditySeconds; + Integer refreshTokenValiditySeconds; + + private ClientJsonStringBuilder() {} + public static ClientJsonStringBuilder builder() { return new ClientJsonStringBuilder(); } - + public ClientJsonStringBuilder name(String name) { this.name = name; return this; } - - public ClientJsonStringBuilder redirectUris(String...uris) { + + public ClientJsonStringBuilder redirectUris(String... uris) { this.redirectUris = Sets.newHashSet(uris); return this; } - - public ClientJsonStringBuilder grantTypes(String...grantTypes) { + + public ClientJsonStringBuilder grantTypes(String... grantTypes) { this.grantTypes = Sets.newHashSet(grantTypes); return this; } - - public ClientJsonStringBuilder scopes(String...scopes) { + + public ClientJsonStringBuilder scopes(String... scopes) { this.scopes = Sets.newHashSet(scopes); return this; } - - public ClientJsonStringBuilder responseTypes(String...responseTypes) { + + public ClientJsonStringBuilder responseTypes(String... responseTypes) { this.responseTypes = Sets.newHashSet(responseTypes); return this; } - + + public ClientJsonStringBuilder accessTokenValiditySeconds(Integer accessTokenValiditySeconds) { + this.accessTokenValiditySeconds = accessTokenValiditySeconds; + return this; + } + + public ClientJsonStringBuilder refreshTokenValiditySeconds( + Integer refreshTokenValiditySeconds) { + this.refreshTokenValiditySeconds = refreshTokenValiditySeconds; + return this; + } + public String build() { JsonObject json = new JsonObject(); json.addProperty(CLIENT_NAME, name); @@ -93,12 +105,13 @@ public String build() { json.add(CLAIMS_REDIRECT_URIS, getAsArray(newHashSet(), true)); json.add(REQUEST_URIS, getAsArray(newHashSet(), true)); json.add(CONTACTS, getAsArray(newHashSet("test@iam.test"))); - + json.addProperty("access_token_validity_seconds", accessTokenValiditySeconds); + json.addProperty("refresh_token_validity_seconds", refreshTokenValiditySeconds); return json.toString(); } - - - + + + } protected String setToString(Set scopes) { diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java index 34ed6b915..6c6897d2f 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java @@ -34,6 +34,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import java.io.UnsupportedEncodingException; +import java.util.Optional; +import java.util.Set; import org.junit.Test; import org.junit.runner.RunWith; @@ -47,6 +49,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Sets; import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.JWTParser; @@ -412,6 +415,58 @@ public void testDeviceCodeApprovalFlowWorks() throws Exception { .andExpect(jsonPath("$.active", equalTo(true))); } + @Test + public void testDeviceCodeFlowDoesNotWorkIfScopeNotAllowed() throws Exception { + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) + .param("client_id", "device-code-client") + .param("scope", "openid profile offline_access custom-scope")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", equalTo("invalid_scope"))); + } + + @Test + public void deviceCodeDoesNotWorkForDynamicallyRegisteredClientIfScopeNotAllowed() + throws UnsupportedEncodingException, Exception { + + String jsonInString = ClientJsonStringBuilder.builder() + .grantTypes("urn:ietf:params:oauth:grant-type:device_code") + .scopes("openid", "profile", "offline_access") + .build(); + + String clientJson = + mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.registration_access_token").exists()) + .andExpect(jsonPath("$.registration_client_uri").exists()) + .andExpect(jsonPath("$.scope", containsString("offline_access"))) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO registrationResponse = + objectMapper.readValue(clientJson, RegisteredClientDTO.class); + + ClientDetailsEntity newClient = + clientRepo.findByClientId(registrationResponse.getClientId()).orElseThrow(); + + assertThat(newClient, notNullValue()); + + RequestPostProcessor clientBasicAuth = + httpBasic(newClient.getClientId(), newClient.getClientSecret()); + + mvc + .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) + .with(clientBasicAuth) + .param("client_id", newClient.getClientId()) + .param("scope", "openid profile offline_access custom-scope")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", equalTo("invalid_scope"))); + } + + @Test public void deviceCodeWorksForDynamicallyRegisteredClient() throws UnsupportedEncodingException, Exception { @@ -433,7 +488,7 @@ public void deviceCodeWorksForDynamicallyRegisteredClient() RegisteredClientDTO registrationResponse = objectMapper.readValue(clientJson, RegisteredClientDTO.class); - + ClientDetailsEntity newClient = clientRepo.findByClientId(registrationResponse.getClientId()).orElseThrow(); @@ -559,6 +614,13 @@ public void deviceCodeWorksForDynamicallyRegisteredClient() @Test public void publicClientDeviceCodeWorks() throws Exception { + Optional client = clientRepo.findByClientId(PUBLIC_DEVICE_CODE_CLIENT_ID); + Set scopes = Sets.newHashSet(); + scopes.add("openid"); + scopes.add("profile"); + if (client.isPresent()) { + client.get().setScope(scopes); + } String deviceResponse = mvc .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) .param("client_id", PUBLIC_DEVICE_CODE_CLIENT_ID) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java index 7c1c477d4..bca7e412c 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java @@ -44,7 +44,7 @@ public class IntrospectionEndpointTests extends EndpointsTestUtils { @Value("${iam.organisation.name}") String organisationName; - + @Value("${iam.issuer}") String issuer; @@ -53,7 +53,7 @@ public class IntrospectionEndpointTests extends EndpointsTestUtils { private static final String CLIENT_SECRET = "secret"; @Test - public void testIntrospectionEndpointRetursBasicUserInformation() throws Exception { + public void testIntrospectionEndpointReturnsBasicUserInformation() throws Exception { String accessToken = getPasswordAccessToken(); // @formatter:off diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java new file mode 100644 index 000000000..278bb85ef --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java @@ -0,0 +1,122 @@ +/** + * 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.oauth.profile; + +import static it.infn.mw.iam.core.oauth.granters.TokenExchangeTokenGranter.TOKEN_EXCHANGE_GRANT_TYPE; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.model.UserInfo; +import org.mitre.openid.connect.service.ScopeClaimTranslationService; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.oauth2.common.exceptions.InvalidRequestException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +import com.google.common.collect.Maps; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.iam.ClaimValueHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakGroupHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakProfileAccessTokenBuilder; +import it.infn.mw.iam.test.util.oauth.MockOAuth2Request; + +@SuppressWarnings("deprecation") +@RunWith(MockitoJUnitRunner.class) +public class KeycloakAccessTokenBuilderTests { + + IamProperties properties = new IamProperties(); + + @Mock + ScopeClaimTranslationService scService; + + @Mock + ClaimValueHelper claimValueHelper; + + @Mock + OAuth2AccessTokenEntity tokenEntity; + + @Mock + ClientDetailsEntity client; + + @Mock + OAuth2Authentication authentication; + + @Spy + MockOAuth2Request oauth2Request = + new MockOAuth2Request("clientId", new String[] {"openid", "profile"}); + + @Mock + UserInfo userInfo; + + final Instant now = Clock.systemDefaultZone().instant(); + + final KeycloakGroupHelper groupHelper = new KeycloakGroupHelper(); + + KeycloakProfileAccessTokenBuilder tokenBuilder; + + @Before + public void setup() { + + tokenBuilder = + new KeycloakProfileAccessTokenBuilder(properties, groupHelper); + when(tokenEntity.getExpiration()).thenReturn(null); + when(tokenEntity.getClient()).thenReturn(client); + when(client.getClientId()).thenReturn("client"); + // when(authentication.getName()).thenReturn("auth-name"); + when(authentication.getOAuth2Request()).thenReturn(oauth2Request); + // when(authentication.isClientOnly()).thenReturn(false); + when(userInfo.getSub()).thenReturn("userinfo-sub"); + when(oauth2Request.getGrantType()).thenReturn(TOKEN_EXCHANGE_GRANT_TYPE); + } + + + @Test(expected = InvalidRequestException.class) + public void testMissingSubjectTokenTokenExchangeErrors() { + try { + tokenBuilder.buildAccessToken(tokenEntity, authentication, userInfo, now); + } catch (InvalidRequestException e) { + assertThat(e.getMessage(), containsString("subject_token not found")); + throw e; + } + } + + @Test(expected = InvalidRequestException.class) + public void testSubjectTokenNotParsable() { + Map paramsMap = Maps.newHashMap(); + paramsMap.put("subject_token", "3427thjdfhgejt73ja"); + + oauth2Request.setRequestParameters(paramsMap); + try { + tokenBuilder.buildAccessToken(tokenEntity, authentication, userInfo, now); + } catch (InvalidRequestException e) { + assertThat(e.getMessage(), containsString("Error parsing subject token")); + throw e; + } + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java new file mode 100644 index 000000000..58f552fd8 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java @@ -0,0 +1,178 @@ +/** + * 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.oauth.profile; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.assertj.core.util.Lists; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; + +import it.infn.mw.iam.test.oauth.EndpointsTestUtils; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +@TestPropertySource(properties = {"iam.jwt-profile.default-profile=kc",}) +public class KeycloakProfileIntegrationTests extends EndpointsTestUtils { + + private static final String CLIENT_ID = "password-grant"; + private static final String CLIENT_SECRET = "secret"; + private static final String USERNAME = "test"; + private static final String PASSWORD = "password"; + protected static final String KC_GROUP_CLAIM = "roles"; + + private String getAccessTokenForUser(String scopes) throws Exception { + + return new AccessTokenGetter().grantType("password") + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .username(USERNAME) + .password(PASSWORD) + .scope(scopes) + .getAccessTokenValue(); + } + + private String getAccessTokenWithAudience(String scopes, String audience) throws Exception { + + return new AccessTokenGetter().grantType("password") + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .username(USERNAME) + .password(PASSWORD) + .scope(scopes) + .audience(audience) + .getAccessTokenValue(); + } + + @Test + public void testKeycloakProfileAccessToken() throws Exception { + JWT token = JWTParser.parse(getAccessTokenForUser("openid profile")); + + assertThat(token.getJWTClaimsSet().getClaim("scope"), is("openid profile")); + assertThat(token.getJWTClaimsSet().getClaim("nbf"), notNullValue()); + assertThat(token.getJWTClaimsSet().getClaim("groups"), nullValue()); + assertThat(token.getJWTClaimsSet().getClaim("roles"), notNullValue()); + List roles = + Lists.newArrayList(token.getJWTClaimsSet().getStringArrayClaim(KC_GROUP_CLAIM)); + assertThat(roles, hasSize(2)); + assertThat(roles, hasItem("Analysis")); + assertThat(roles, hasItem("Production")); + } + + @Test + public void testKeycloakProfileAccessTokenForUserNotInGroups() throws Exception { + String accessTokenString = (String) new AccessTokenGetter().grantType("password") + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .username("admin") + .password("password") + .scope("openid profile") + .getAccessTokenValue(); + + assertThat(!accessTokenString.contains("roles"), is(true)); + + } + + @Test + public void testKeycloakProfileAccessTokenWithClientCredentials() throws Exception { + String accessTokenString = (String) new AccessTokenGetter().grantType("client_credentials") + .clientId("client-cred") + .clientSecret("secret") + .scope("openid profile") + .getAccessTokenValue(); + + assertThat(!accessTokenString.contains("roles"), is(true)); + + } + + @Test + public void testKeycloackProfileIntrospect() throws Exception { + + JWT token = JWTParser.parse(getAccessTokenForUser("openid profile")); + + // @formatter:off + mvc.perform(post("/introspect") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .param("token", token.getParsedString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active", equalTo(true))) + .andExpect(jsonPath("$." + KC_GROUP_CLAIM, containsInAnyOrder("Analysis", "Production"))) + .andExpect(jsonPath("$." + KC_GROUP_CLAIM, hasSize(equalTo(2)))) + .andExpect(jsonPath("$.iss", equalTo("http://localhost:8080/"))) + .andExpect(jsonPath("$.scope", containsString("openid"))) + .andExpect(jsonPath("$.scope", containsString("profile"))); + // @formatter:on + + } + + @Test + public void testKeycloackProfileIntrospectWithAudience() throws Exception { + + JWT token = JWTParser.parse(getAccessTokenWithAudience("openid profile", "myAudience")); + + // @formatter:off + mvc.perform(post("/introspect") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .param("token", token.getParsedString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active", equalTo(true))) + .andExpect(jsonPath("$.aud", equalTo("myAudience"))); + // @formatter:on + + } + + @Test + public void testKeycloackProfileForUserNotInGroups() throws Exception { + + String accessTokenString = (String) new AccessTokenGetter().grantType("password") + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .username("admin") + .password("password") + .scope("openid profile") + .getAccessTokenValue(); + + // @formatter:off + mvc.perform(post("/introspect") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .param("token", accessTokenString)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.active", equalTo(true))) + .andExpect(jsonPath("$.roles").doesNotExist()); + // @formatter:on + + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileUserInfoTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileUserInfoTests.java new file mode 100644 index 000000000..047cd3533 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileUserInfoTests.java @@ -0,0 +1,129 @@ +/** + * 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.oauth.profile; + +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import io.restassured.RestAssured; +import it.infn.mw.iam.test.TestUtils; +import it.infn.mw.iam.test.util.annotation.IamRandomPortIntegrationTest; + +@RunWith(SpringRunner.class) +@IamRandomPortIntegrationTest +@TestPropertySource(properties = { +// @formatter:off + "iam.jwt-profile.default-profile=kc", + // @formatter:on +}) +public class KeycloakProfileUserInfoTests { + + @Value("${local.server.port}") + private Integer iamPort; + + private static final String USERNAME = "test"; + private static final String PASSWORD = "password"; + + private String userinfoUrl; + private static final String USERINFO_URL_TEMPLATE = "http://localhost:%d/userinfo"; + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + } + + @Before + public void setup() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + RestAssured.port = iamPort; + userinfoUrl = String.format(USERINFO_URL_TEMPLATE, iamPort); + } + + @Test + public void testUserinfoResponseWithGroups() { + String accessToken = TestUtils.passwordTokenGetter() + .port(iamPort) + .username(USERNAME) + .password(PASSWORD) + .scope("openid profile") + .getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.OK.value()) + .body("\"roles\"", hasItems("Analysis", "Production")); + } + + @Test + public void testUserinfoResponseWithoutGroups() { + String accessToken = TestUtils.passwordTokenGetter() + .port(iamPort) + .username(USERNAME) + .password(PASSWORD) + .scope("openid") + .getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.OK.value()) + .body("\"roles\"", nullValue()); + } + + @Test + public void testUserinfoResponseWithoutGroupsTwo() { + String accessToken = TestUtils.passwordTokenGetter() + .port(iamPort) + .username("admin") + .password(PASSWORD) + .scope("openid profile") + .getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.OK.value()) + .body("\"roles\"", nullValue()); + } + + @Test + public void testUserinfoResponseWithoutUser() { + String accessToken = TestUtils.clientCredentialsTokenGetter().port(iamPort).getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.FORBIDDEN.value()); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java index d3470cc5c..5703fea6b 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java @@ -58,6 +58,9 @@ public class ProfileSelectorTests { @Mock JWTProfile wlcgProfile; + @Mock + JWTProfile kcProfile; + ScopeAwareProfileResolver profileResolver; @Before @@ -67,6 +70,7 @@ public void setup() { profileMap.put(ScopeAwareProfileResolver.AARC_PROFILE_ID, aarcProfile); profileMap.put(ScopeAwareProfileResolver.IAM_PROFILE_ID, iamProfile); profileMap.put(ScopeAwareProfileResolver.WLCG_PROFILE_ID, wlcgProfile); + profileMap.put(ScopeAwareProfileResolver.KC_PROFILE_ID, kcProfile); profileResolver = new ScopeAwareProfileResolver(iamProfile, profileMap, clientsService); } @@ -133,5 +137,24 @@ public void multipleProfilesLeadToDefaultProfile() throws Exception { profile = profileResolver.resolveProfile(CLIENT_ID); assertThat(profile, is(iamProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "kc"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(kcProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "kc", "iam"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(iamProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "kc", "wlcg"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(iamProfile)); + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java index d01bd692b..8339de7f8 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java @@ -42,6 +42,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.service.SystemScopeService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -130,9 +132,18 @@ public class WLCGProfileIntegrationTests extends EndpointsTestUtils { @Autowired private MockOAuth2Filter oauth2Filter; + @Autowired + private SystemScopeService scopeService; + @Before public void setup() { oauth2Filter.cleanupSecurityContext(); + SystemScope wlcgGroupsScope = new SystemScope("wlcg.groups"); + SystemScope storageReadScope = new SystemScope("storage.read:/"); + SystemScope storageWriteScope = new SystemScope("storage.write:/"); + scopeService.save(wlcgGroupsScope); + scopeService.save(storageReadScope); + scopeService.save(storageWriteScope); } @After diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopeRegistryTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopeRegistryTests.java index 8e7f6fefb..fd9c334f8 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopeRegistryTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopeRegistryTests.java @@ -18,6 +18,7 @@ import static com.google.common.collect.Sets.newHashSet; import static it.infn.mw.iam.core.oauth.scope.matchers.RegexpScopeMatcher.regexpMatcher; import static it.infn.mw.iam.core.oauth.scope.matchers.StringEqualsScopeMatcher.stringEqualsMatcher; +import static it.infn.mw.iam.core.oauth.scope.matchers.StructuredPathScopeMatcher.structuredPathMatcher; import static java.util.Collections.emptySet; import static org.hamcrest.CoreMatchers.hasItem; import static org.hamcrest.CoreMatchers.not; @@ -28,8 +29,11 @@ import java.util.Set; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mitre.oauth2.model.SystemScope; +import org.mitre.oauth2.repository.SystemScopeRepository; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.security.oauth2.provider.ClientDetails; @@ -43,52 +47,66 @@ @RunWith(MockitoJUnitRunner.class) public class ScopeRegistryTests { - + @Mock ClientDetails client; - + + @Mock + SystemScopeRepository scopeRepo; + + @Before + public void setup() { + SystemScope testScope = new SystemScope("test:/whatever"); + when(scopeRepo.getAll()).thenReturn(Sets.newHashSet(testScope)); + } + @Test public void testEmptyScopes() { - - DefaultScopeMatcherRegistry matcherRegistry = new DefaultScopeMatcherRegistry(emptySet()); - - when(client.getScope()).thenReturn(Sets.newHashSet("openid","profile")); + + DefaultScopeMatcherRegistry matcherRegistry = + new DefaultScopeMatcherRegistry(emptySet(), scopeRepo); + + when(client.getScope()).thenReturn(Sets.newHashSet("openid", "profile")); Set matchers = matcherRegistry.findMatchersForClient(client); - + assertThat(matchers, not(nullValue())); assertThat(matchers, hasSize(2)); assertThat(matchers, hasItem(stringEqualsMatcher("openid"))); assertThat(matchers, hasItem(stringEqualsMatcher("profile"))); } - + @Test public void testNonMatchingScope() { - - DefaultScopeMatcherRegistry matcherRegistry = new DefaultScopeMatcherRegistry(newHashSet(regexpMatcher("^test:/.*$"))); - - when(client.getScope()).thenReturn(Sets.newHashSet("openid","profile")); + + DefaultScopeMatcherRegistry matcherRegistry = + new DefaultScopeMatcherRegistry(newHashSet(regexpMatcher("^test:/.*$")), scopeRepo); + + when(client.getScope()).thenReturn(Sets.newHashSet("openid", "profile")); Set matchers = matcherRegistry.findMatchersForClient(client); - + assertThat(matchers, not(nullValue())); assertThat(matchers, hasSize(2)); assertThat(matchers, hasItem(stringEqualsMatcher("openid"))); assertThat(matchers, hasItem(stringEqualsMatcher("profile"))); } - + @Test public void testMatchingScope() { - - DefaultScopeMatcherRegistry matcherRegistry = new DefaultScopeMatcherRegistry(newHashSet(regexpMatcher("^test:/.*$"))); - - when(client.getScope()).thenReturn(Sets.newHashSet("openid","profile", "test", "test:/whatever")); + + DefaultScopeMatcherRegistry matcherRegistry = + new DefaultScopeMatcherRegistry(newHashSet(regexpMatcher("^test:/.*$"), structuredPathMatcher("storage.create", "/")), scopeRepo); + + when(client.getScope()) + .thenReturn(Sets.newHashSet("openid", "profile", "test", "test:/whatever", "storage.create:/whatever")); Set matchers = matcherRegistry.findMatchersForClient(client); - + assertThat(matchers, not(nullValue())); - assertThat(matchers, hasSize(4)); + assertThat(matchers, hasSize(5)); assertThat(matchers, hasItem(stringEqualsMatcher("openid"))); assertThat(matchers, hasItem(stringEqualsMatcher("profile"))); assertThat(matchers, hasItem(stringEqualsMatcher("test"))); assertThat(matchers, hasItem(regexpMatcher("^test:/.*$"))); + assertThat(matchers, hasItem(stringEqualsMatcher("storage.create:/whatever"))); } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java index 37948896e..0a1fcb993 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/StructuredScopeRequestIntegrationTests.java @@ -29,9 +29,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import java.util.Optional; +import java.util.Set; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mitre.oauth2.model.ClientDetailsEntity; import org.mitre.oauth2.model.SystemScope; import org.mitre.oauth2.service.SystemScopeService; import org.springframework.beans.factory.annotation.Autowired; @@ -44,8 +48,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Sets; import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; import it.infn.mw.iam.test.oauth.EndpointsTestUtils; import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; @@ -67,6 +73,9 @@ public class StructuredScopeRequestIntegrationTests extends EndpointsTestUtils @Autowired private ObjectMapper mapper; + @Autowired + private IamClientRepository clientRepo; + @Before public void setup() throws Exception { SystemScope storageReadScope = new SystemScope("storage.read:/"); @@ -124,6 +133,17 @@ public void testIntrospectionResponse() throws Exception { @Test public void testDeviceCodeStructuredScopeRequest() throws Exception { + + Optional client = clientRepo.findByClientId(DEVICE_CODE_CLIENT_ID); + Set scopes = Sets.newHashSet(); + scopes.add("storage.read:/"); + scopes.add("openid"); + scopes.add("profile"); + scopes.add("offline_access"); + if (client.isPresent()) { + client.get().setScope(scopes); + } + String response = mvc .perform(post(DEVICE_CODE_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED) .with(httpBasic(DEVICE_CODE_CLIENT_ID, DEVICE_CODE_CLIENT_SECRET)) @@ -243,11 +263,11 @@ public void testDeviceCodeStructuredScopeRequest() throws Exception { public void testRefreshTokenStructuredScopeRequest() throws Exception { DefaultOAuth2AccessToken tokenResponse = new AccessTokenGetter().grantType(PASSWORD_GRANT_TYPE) - .clientId(PASSWORD_CLIENT_ID) - .clientSecret(PASSWORD_CLIENT_SECRET) - .username(TEST_USERNAME) - .password(TEST_PASSWORD) - .scope("openid storage.read:/ offline_access") + .clientId(PASSWORD_CLIENT_ID) + .clientSecret(PASSWORD_CLIENT_SECRET) + .username(TEST_USERNAME) + .password(TEST_PASSWORD) + .scope("openid storage.read:/ offline_access") .getTokenResponseObject(); assertThat(tokenResponse.getScope(), hasItem("storage.read:/")); @@ -260,9 +280,8 @@ public void testRefreshTokenStructuredScopeRequest() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").exists()) .andExpect(jsonPath("$.refresh_token").exists()) - .andExpect(jsonPath("$.scope", - allOf(containsString("storage.read:/test "), containsString("offline_access"), - containsString("openid")))); + .andExpect(jsonPath("$.scope", allOf(containsString("storage.read:/test "), + containsString("offline_access"), containsString("openid")))); } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyPdpTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyPdpTests.java index 8e1acdb7d..cc70bb036 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyPdpTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyPdpTests.java @@ -17,6 +17,8 @@ import static it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy.PATH; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItems; @@ -35,6 +37,7 @@ import org.mitre.oauth2.model.SystemScope; import org.mitre.oauth2.service.SystemScopeService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; @@ -52,6 +55,7 @@ @RunWith(SpringRunner.class) +@ActiveProfiles({"h2-test", "h2", "saml", "registration", "wlcg-scopes"}) @IamMockMvcIntegrationTest public class ScopePolicyPdpTests extends ScopePolicyTestUtils { @@ -151,7 +155,7 @@ public void testGroupPolicyIsEnforced() { assertThat(filteredScopes, hasItems("openid", "profile")); } - + @Test public void testChainedOverrideAtGroupIsEnforced() { IamAccount testAccount = findTestAccount(); @@ -163,18 +167,18 @@ public void testChainedOverrideAtGroupIsEnforced() { IamScopePolicy gp = initPermitScopePolicy(); gp.linkGroup(firstGroup); gp.setScopes(Sets.newHashSet(OPENID, PROFILE)); - - + + policyScopeRepo.save(gp); - - Set filteredScopes = pdp - .filterScopes(Sets.newHashSet("openid", "profile"), testAccount); - + + Set filteredScopes = + pdp.filterScopes(Sets.newHashSet("openid", "profile"), testAccount); + assertThat(filteredScopes, hasSize(2)); assertThat(filteredScopes, hasItems("openid", "profile")); } - - + + @Test public void testChainedOverrideIsEnforced() { IamAccount testAccount = findTestAccount(); @@ -257,46 +261,72 @@ public void testConflictingGroupPolicyDenyOverrides2() { assertThat(filteredScopes, hasSize(2)); assertThat(filteredScopes, hasItems("openid", "profile")); } - - + + @Test public void testPathFiltering() { - + IamAccount testAccount = findTestAccount(); IamScopePolicy up = initDenyScopePolicy(); - + up.getScopes().add("read:/"); up.getScopes().add("write:/"); up.setMatchingPolicy(PATH); - + policyScopeRepo.save(up); - Set filteredScopes = - pdp.filterScopes(Sets.newHashSet("openid", "profile", "read:/", "write", "read:/sub/path"), testAccount); + Set filteredScopes = pdp.filterScopes( + Sets.newHashSet("openid", "profile", "read:/", "write", "read:/sub/path"), testAccount); assertThat(filteredScopes, hasSize(3)); assertThat(filteredScopes, hasItems("openid", "profile", "write")); } - + @Test public void testPathPermit() { - + IamAccount testAccount = findTestAccount(); IamScopePolicy up = initPermitScopePolicy(); - + up.getScopes().add("read:/"); up.getScopes().add("write:/"); up.setMatchingPolicy(PATH); - + policyScopeRepo.save(up); - Set filteredScopes = - pdp.filterScopes(Sets.newHashSet("openid", "profile", "read:/", "write", "read:/sub/path"), testAccount); + Set filteredScopes = pdp.filterScopes( + Sets.newHashSet("openid", "profile", "read:/", "write", "read:/sub/path"), testAccount); assertThat(filteredScopes, hasSize(5)); assertThat(filteredScopes, hasItems("openid", "profile", "write", "read:/", "read:/sub/path")); } + @Test + public void testPathForCustomScope() { + + IamAccount testAccount = findTestAccount(); + IamScopePolicy up = initDenyScopePolicy(); + + up.getScopes().add("storage.write:/"); + up.setMatchingPolicy(PATH); + + policyScopeRepo.save(up); + + up = initPermitScopePolicy(); + up.getScopes().add("storage.write:/path"); + up.linkAccount(testAccount); + up.setMatchingPolicy(PATH); + + policyScopeRepo.save(up); + + Set filteredScopes = pdp.filterScopes(Sets.newHashSet("openid", "profile", + "storage.write:/", "storage.write:/path", "storage.write:/path/sub"), testAccount); + + assertThat(filteredScopes, hasSize(4)); + assertThat(filteredScopes, + hasItems("openid", "profile", "storage.write:/path", "storage.write:/path/sub")); + } + @Test public void testMisspelledScopeInScopePolicy() throws Exception { @@ -309,16 +339,49 @@ public void testMisspelledScopeInScopePolicy() throws Exception { policyScopeRepo.save(up); mvc - .perform( - post("/token").with(httpBasic("password-grant", "secret")) - .param("grant_type", "password") - .param("username", "test") - .param("password", "password") - .param("scope", "openid storage.read:/")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error", equalTo("invalid_scope"))) - .andExpect(jsonPath("$.error_description", equalTo("Misspelled storage.read/ scope in the scope policy"))); + .perform(post("/token").with(httpBasic("password-grant", "secret")) + .param("grant_type", "password") + .param("username", "test") + .param("password", "password") + .param("scope", "openid storage.read:/")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", equalTo("invalid_scope"))) + .andExpect(jsonPath("$.error_description", + equalTo("Misspelled storage.read/ scope in the scope policy"))); + + } + + @Test + public void testFakeWLCGScopeAsCustomScopeNotIncluded() throws Exception { + + mvc + .perform(post("/token").with(httpBasic("password-grant", "secret")) + .param("grant_type", "password") + .param("username", "test") + .param("password", "password") + .param("scope", "openid storage.create:/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect( + jsonPath("$.scope", allOf(containsString("openid"), containsString("storage.create:/")))); + + IamScopePolicy up = initDenyScopePolicy(); + up.getScopes().add("storage.create:/"); + up.setMatchingPolicy(PATH); + up.linkAccount(findTestAccount()); + up = policyScopeRepo.save(up); + mvc + .perform(post("/token").with(httpBasic("password-grant", "secret")) + .param("grant_type", "password") + .param("username", "test") + .param("password", "password") + .param("scope", "openid storage.create:/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.scope", allOf(containsString("openid")))); + + policyScopeRepo.delete(up); } } diff --git a/pom.xml b/pom.xml index 7ea2dc01a..18e83817a 100644 --- a/pom.xml +++ b/pom.xml @@ -50,7 +50,7 @@ 1.16.2 - 1.3.6.cnaf-20230726 + 1.3.6.cnaf-20230914 2.5.2.RELEASE 3.3.2