From 2388687eb06673173904851af0cf8b0870bfa70c Mon Sep 17 00:00:00 2001 From: Damian Ujma <92095156+damianujma@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:19:34 +0100 Subject: [PATCH] ACS-9414 Enhance the Identity Provider configuration (#3263) --- .github/workflows/ci.yml | 2 +- .secrets.baseline | 4 +- ...dentityServiceAuthenticationComponent.java | 1 + .../IdentityServiceConfig.java | 81 +++++++++ .../IdentityServiceFacade.java | 11 +- .../IdentityServiceFacadeFactoryBean.java | 43 +++-- ...IdentityServiceJITProvisioningHandler.java | 160 ++++++++---------- .../IdentityServiceRemoteUserMapper.java | 3 +- .../SpringBasedIdentityServiceFacade.java | 113 ++++++------- ...ntityServiceAdminConsoleAuthenticator.java | 8 +- .../AccessTokenToDecodedTokenUserMapper.java | 66 ++++++++ .../user/DecodedTokenUser.java | 44 +++++ .../{ => user}/OIDCUserInfo.java | 2 +- .../user/TokenUserToOIDCUserMapper.java | 76 +++++++++ .../user/UserInfoAttrMapping.java | 41 +++++ ...dentity-service-authentication-context.xml | 23 ++- ...identity-service-authentication.properties | 9 +- .../java/org/alfresco/AllUnitTestsSuite.java | 4 + .../ClientRegistrationProviderUnitTest.java | 45 +++++ ...ityServiceAuthenticationComponentTest.java | 3 +- ...tityServiceJITProvisioningHandlerTest.java | 16 +- ...ServiceJITProvisioningHandlerUnitTest.java | 135 ++++++++------- .../IdentityServiceRemoteUserMapperTest.java | 5 +- ...ingBasedIdentityServiceFacadeUnitTest.java | 4 +- ...viceAdminConsoleAuthenticatorUnitTest.java | 3 + ...TokenToDecodedTokenUserMapperUnitTest.java | 109 ++++++++++++ .../TokenUserToOIDCUserMapperUnitTest.java | 95 +++++++++++ .../test/resources/alfresco-global.properties | 3 + 28 files changed, 870 insertions(+), 239 deletions(-) create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java rename repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/{ => user}/OIDCUserInfo.java (99%) create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b159fbe20..95710e18bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: - uses: Alfresco/alfresco-build-tools/.github/actions/get-build-info@v8.16.0 - uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@v8.16.0 - uses: Alfresco/alfresco-build-tools/.github/actions/setup-java-build@v8.16.0 - - uses: Alfresco/ya-pmd-scan@v4.1.0 + - uses: Alfresco/ya-pmd-scan@v4.3.0 with: classpath-build-command: "mvn test-compile -ntp -Pags -pl \"-:alfresco-community-repo-docker\"" diff --git a/.secrets.baseline b/.secrets.baseline index 7503d90af5..4a3a67cf6e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1607,7 +1607,7 @@ "filename": "repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 47, + "line_number": 48, "is_secret": false } ], @@ -1868,5 +1868,5 @@ } ] }, - "generated_at": "2025-03-17T14:00:53Z" + "generated_at": "2025-03-21T13:01:19Z" } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java index b99dce03a3..e48f9bf7f0 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java @@ -33,6 +33,7 @@ import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; /** * diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java index 9bc31a29c3..7593e982e9 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java @@ -69,6 +69,13 @@ public class IdentityServiceConfig private boolean clientIdValidationDisabled; private String adminConsoleRedirectPath; private String signatureAlgorithms; + private String adminConsoleScopes; + private String passwordGrantScopes; + private String issuerAttribute; + private String firstNameAttribute; + private String lastNameAttribute; + private String emailAttribute; + private long jwtClockSkewMs; /** * @@ -329,4 +336,78 @@ public class IdentityServiceConfig { this.signatureAlgorithms = signatureAlgorithms; } + + public String getIssuerAttribute() + { + return issuerAttribute; + } + + public void setIssuerAttribute(String issuerAttribute) + { + this.issuerAttribute = issuerAttribute; + } + + public Set getAdminConsoleScopes() + { + return Stream.of(adminConsoleScopes.split(",")) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setAdminConsoleScopes(String adminConsoleScopes) + { + this.adminConsoleScopes = adminConsoleScopes; + } + + public Set getPasswordGrantScopes() + { + return Stream.of(passwordGrantScopes.split(",")) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setPasswordGrantScopes(String passwordGrantScopes) + { + this.passwordGrantScopes = passwordGrantScopes; + } + + public void setFirstNameAttribute(String firstNameAttribute) + { + this.firstNameAttribute = firstNameAttribute; + } + + public void setLastNameAttribute(String lastNameAttribute) + { + this.lastNameAttribute = lastNameAttribute; + } + + public void setEmailAttribute(String emailAttribute) + { + this.emailAttribute = emailAttribute; + } + + public void setJwtClockSkewMs(long jwtClockSkewMs) + { + this.jwtClockSkewMs = jwtClockSkewMs; + } + + public String getFirstNameAttribute() + { + return firstNameAttribute; + } + + public String getLastNameAttribute() + { + return lastNameAttribute; + } + + public String getEmailAttribute() + { + return emailAttribute; + } + + public long getJwtClockSkewMs() + { + return jwtClockSkewMs; + } } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java index 05ca66046b..3054869dbe 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java @@ -34,6 +34,9 @@ import java.util.Optional; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + /** * Allows to interact with the Identity Service */ @@ -66,11 +69,11 @@ public interface IdentityServiceFacade * * @param token * {@link String} with encoded access token value. - * @param principalAttribute - * {@link String} the attribute name used to access the user's name from the user info response. - * @return {@link OIDCUserInfo} containing user claims. + * @param userInfoAttrMapping + * {@link UserInfoAttrMapping} containing the mapping of claims. + * @return {@link DecodedTokenUser} containing user claims or {@link Optional#empty()} if the token does not contain a username claim. */ - Optional getUserInfo(String token, String principalAttribute); + Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping); /** * Gets a client registration diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java index 3ce984652b..bcafe791d4 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java @@ -72,6 +72,7 @@ import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.oauth2.sdk.id.Identifier; import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; @@ -96,6 +97,7 @@ import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.security.converter.RsaKeyConverters; import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; @@ -124,6 +126,8 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; /** * Creates an instance of {@link IdentityServiceFacade}.
@@ -134,6 +138,7 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean getUserInfo(String token, String principalAttribute) + public Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping) { - return getTargetFacade().getUserInfo(token, principalAttribute); + return getTargetFacade().getUserInfo(token, userInfoAttrMapping); } @Override @@ -277,7 +282,7 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean SCOPES = Set.of("openid", "profile", "email"); - ClientRegistrationProvider(IdentityServiceConfig config) { this.config = requireNonNull(config); @@ -456,11 +459,12 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean (StringUtils.isNotBlank(config.getRealm()) && StringUtils.isBlank(config.getIssuerUrl())) ? config.getAuthServerUrl() : config.getIssuerUrl()); + final var usernameAttribute = StringUtils.isNotBlank(config.getPrincipalAttribute()) ? config.getPrincipalAttribute() : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; + return ClientRegistration .withRegistrationId("ids") .authorizationUri(authUri) @@ -468,6 +472,7 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean getSupportedScopes(Scope scopes) { - return scopes.stream().filter(scope -> SCOPES.contains(scope.getValue())) + return scopes.stream() + .filter(this::hasPasswordGrantScope) .map(Identifier::getValue) .collect(Collectors.toSet()); } + private boolean hasPasswordGrantScope(Scope.Value scope) + { + return config.getPasswordGrantScopes().contains(scope.getValue()); + } + private Optional extractMetadata(RestOperations rest, URI metadataUri) { final String response; @@ -552,6 +563,18 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean getMetadataIssuer(OIDCProviderMetadata metadata, IdentityServiceConfig config) + { + return DEFAULT_ISSUER_ATTR.equals(config.getIssuerAttribute()) ? Optional.of(metadata) + .map(OIDCProviderMetadata::getIssuer) + .map(Issuer::getValue) + : Optional.of(metadata) + .map(OIDCProviderMetadata::getCustomParameters) + .map(map -> map.get(config.getIssuerAttribute())) + .filter(String.class::isInstance) + .map(String.class::cast); + } + static class JwtDecoderProvider { private static final SignatureAlgorithm DEFAULT_SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256; @@ -651,7 +674,7 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean createJwtTokenValidator(ProviderDetails providerDetails) { List> validators = new ArrayList<>(); - validators.add(new JwtTimestampValidator(Duration.of(0, ChronoUnit.MILLIS))); + validators.add(new JwtTimestampValidator(Duration.of(config.getJwtClockSkewMs(), ChronoUnit.MILLIS))); validators.add(new JwtIssuerValidator(providerDetails.getIssuerUri())); if (!config.isClientIdValidationDisabled()) { diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java index debf729dfb..599bc06292 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandler.java @@ -30,52 +30,33 @@ import java.io.Serializable; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.function.BiFunction; import java.util.function.Predicate; -import com.nimbusds.openid.connect.sdk.claims.PersonClaims; -import com.nimbusds.openid.connect.sdk.claims.UserInfo; import org.apache.commons.lang3.StringUtils; import org.alfresco.model.ContentModel; import org.alfresco.repo.security.authentication.AuthenticationUtil; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.DecodedAccessToken; +import org.alfresco.repo.security.authentication.identityservice.user.AccessTokenToDecodedTokenUserMapper; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.TokenUserToOIDCUserMapper; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.namespace.QName; import org.alfresco.service.transaction.TransactionService; /** - * This class handles Just in Time user provisioning. It extracts {@link OIDCUserInfo} from {@link IdentityServiceFacade.DecodedAccessToken} or {@link UserInfo} and creates a new user if it does not exist in the repository. + * This class handles Just in Time user provisioning. It extracts {@link OIDCUserInfo} from the given bearer token and creates a new user if it does not exist in the repository. */ public class IdentityServiceJITProvisioningHandler { - private final IdentityServiceConfig identityServiceConfig; private final IdentityServiceFacade identityServiceFacade; private final PersonService personService; private final TransactionService transactionService; - - private final BiFunction> mapTokenToUserInfoResponse = (token, usernameMappingClaim) -> { - Optional firstName = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - Optional lastName = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - Optional email = Optional.ofNullable(token) - .map(jwtToken -> jwtToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)) - .filter(String.class::isInstance) - .map(String.class::cast); - - return Optional.ofNullable(token.getClaim(Optional.ofNullable(usernameMappingClaim) - .filter(StringUtils::isNotBlank) - .orElse(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME))) - .filter(String.class::isInstance) - .map(String.class::cast) - .map(this::normalizeUserId) - .map(username -> new OIDCUserInfo(username, firstName.orElse(""), lastName.orElse(""), email.orElse(""))); - }; + private final IdentityServiceConfig identityServiceConfig; + private UserInfoAttrMapping userInfoAttrMapping; + private TokenUserToOIDCUserMapper tokenUserToOIDCUserMapper; + private AccessTokenToDecodedTokenUserMapper tokenToDecodedTokenUserMapper; public IdentityServiceJITProvisioningHandler(IdentityServiceFacade identityServiceFacade, PersonService personService, @@ -88,92 +69,95 @@ public class IdentityServiceJITProvisioningHandler this.identityServiceConfig = identityServiceConfig; } + /** + * Extracts {@link OIDCUserInfo} from the given bearer token and creates a new user if it does not exist in the repository. Call to the UserInfo endpoint is made only if the token does not contain a username claim or if user needs to be created and some of the {@link OIDCUserInfo} fields are empty. + */ public Optional extractUserInfoAndCreateUserIfNeeded(String bearerToken) { - Optional userInfoResponse = Optional.ofNullable(bearerToken) - .filter(Predicate.not(String::isEmpty)) - .flatMap(token -> extractUserInfoResponseFromAccessToken(token) - .filter(userInfo -> StringUtils.isNotEmpty(userInfo.username())) - .or(() -> extractUserInfoResponseFromEndpoint(token))); - - if (transactionService.isReadOnly() || userInfoResponse.isEmpty()) + if (userInfoAttrMapping == null) { - return userInfoResponse; + initMappers(identityServiceConfig); } - return AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork>() { + + Optional oidcUserInfo = Optional.ofNullable(bearerToken) + .filter(Predicate.not(String::isEmpty)) + .flatMap(token -> extractUserInfoResponseFromAccessToken(token).filter(decodedTokenUser -> StringUtils.isNotEmpty(decodedTokenUser.username())) + .or(() -> extractUserInfoResponseFromEndpoint(token, userInfoAttrMapping))) + .map(tokenUserToOIDCUserMapper::toOIDCUser); + + if (transactionService.isReadOnly() || oidcUserInfo.isEmpty()) + { + return oidcUserInfo; + } + return AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork<>() { @Override public Optional doWork() throws Exception { - return userInfoResponse.map(userInfo -> { - if (userInfo.username() != null && personService.createMissingPeople() - && !personService.personExists(userInfo.username())) + return oidcUserInfo.map(oidcUser -> { + if (userDoesNotExistsAndCanBeCreated(oidcUser)) { - if (!userInfo.allFieldsNotEmpty()) + if (!oidcUser.allFieldsNotEmpty()) { - userInfo = extractUserInfoResponseFromEndpoint(bearerToken).orElse(userInfo); + oidcUser = extractUserInfoResponseFromEndpoint(bearerToken, userInfoAttrMapping) + .map(tokenUserToOIDCUserMapper::toOIDCUser) + .orElse(oidcUser); } - Map properties = new HashMap<>(); - properties.put(ContentModel.PROP_USERNAME, userInfo.username()); - properties.put(ContentModel.PROP_FIRSTNAME, userInfo.firstName()); - properties.put(ContentModel.PROP_LASTNAME, userInfo.lastName()); - properties.put(ContentModel.PROP_EMAIL, userInfo.email()); - properties.put(ContentModel.PROP_ORGID, ""); - properties.put(ContentModel.PROP_HOME_FOLDER_PROVIDER, null); - - properties.put(ContentModel.PROP_SIZE_CURRENT, 0L); - properties.put(ContentModel.PROP_SIZE_QUOTA, -1L); // no quota - - personService.createPerson(properties); + createPerson(oidcUser); } - return userInfo; + return oidcUser; }); } + }, AuthenticationUtil.getSystemUserName()); } - private Optional extractUserInfoResponseFromAccessToken(String bearerToken) + private void initMappers(IdentityServiceConfig identityServiceConfig) + { + this.userInfoAttrMapping = initUserInfoAttrMapping(identityServiceConfig); + this.tokenUserToOIDCUserMapper = new TokenUserToOIDCUserMapper(personService); + this.tokenToDecodedTokenUserMapper = new AccessTokenToDecodedTokenUserMapper(userInfoAttrMapping); + } + + private boolean userDoesNotExistsAndCanBeCreated(OIDCUserInfo userInfo) + { + return userInfo.username() != null && personService.createMissingPeople() + && !personService.personExists(userInfo.username()); + } + + private Optional extractUserInfoResponseFromAccessToken(String bearerToken) { return Optional.ofNullable(bearerToken) .map(identityServiceFacade::decodeToken) - .flatMap(decodedToken -> mapTokenToUserInfoResponse.apply(decodedToken, - identityServiceConfig.getPrincipalAttribute())); + .flatMap(tokenToDecodedTokenUserMapper::toDecodedTokenUser); } - private Optional extractUserInfoResponseFromEndpoint(String bearerToken) + private Optional extractUserInfoResponseFromEndpoint(String bearerToken, UserInfoAttrMapping userInfoAttrMapping) { - return identityServiceFacade.getUserInfo(bearerToken, - StringUtils.isNotBlank(identityServiceConfig.getPrincipalAttribute()) ? identityServiceConfig.getPrincipalAttribute() : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME) - .filter(userInfo -> userInfo.username() != null && !userInfo.username().isEmpty()) - .map(userInfo -> new OIDCUserInfo(normalizeUserId(userInfo.username()), - Optional.ofNullable(userInfo.firstName()).orElse(""), - Optional.ofNullable(userInfo.lastName()).orElse(""), - Optional.ofNullable(userInfo.email()).orElse(""))); + return identityServiceFacade.getUserInfo(bearerToken, userInfoAttrMapping) + .filter(userInfo -> userInfo.username() != null && !userInfo.username().isEmpty()); } - /** - * Normalizes a user id, taking into account existing user accounts and case sensitivity settings. - * - * @param userId - * the user id - * @return the string - */ - private String normalizeUserId(final String userId) + private void createPerson(OIDCUserInfo userInfo) { - if (userId == null) - { - return null; - } + Map properties = new HashMap<>(); + properties.put(ContentModel.PROP_USERNAME, userInfo.username()); + properties.put(ContentModel.PROP_FIRSTNAME, userInfo.firstName()); + properties.put(ContentModel.PROP_LASTNAME, userInfo.lastName()); + properties.put(ContentModel.PROP_EMAIL, userInfo.email()); + properties.put(ContentModel.PROP_ORGID, ""); + properties.put(ContentModel.PROP_HOME_FOLDER_PROVIDER, null); + properties.put(ContentModel.PROP_SIZE_CURRENT, 0L); + properties.put(ContentModel.PROP_SIZE_QUOTA, -1L); // no quota - String normalized = AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork() { - @Override - public String doWork() throws Exception - { - return personService.getUserIdentifier(userId); - } - }, AuthenticationUtil.getSystemUserName()); - - return normalized == null ? userId : normalized; + personService.createPerson(properties); } + private UserInfoAttrMapping initUserInfoAttrMapping(IdentityServiceConfig identityServiceConfig) + { + return new UserInfoAttrMapping(identityServiceFacade.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(), + identityServiceConfig.getFirstNameAttribute(), + identityServiceConfig.getLastNameAttribute(), + identityServiceConfig.getEmailAttribute()); + } } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java index 4aab2aa408..5302116dc7 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2025 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -38,6 +38,7 @@ import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.AuthenticationUtil; import org.alfresco.repo.security.authentication.external.RemoteUserMapper; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; /** * A {@link RemoteUserMapper} implementation that detects and validates JWTs issued by the Alfresco Identity Service. diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java index 8eb0eff806..de39c53b27 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacade.java @@ -30,21 +30,12 @@ import static java.util.Objects.requireNonNull; import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.AUDIENCE; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; import java.time.Instant; import java.util.Map; import java.util.Optional; -import java.util.function.Predicate; -import com.nimbusds.oauth2.sdk.ErrorObject; -import com.nimbusds.oauth2.sdk.ParseException; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; -import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse; -import com.nimbusds.openid.connect.sdk.UserInfoRequest; -import com.nimbusds.openid.connect.sdk.UserInfoResponse; -import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse; +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.core.convert.converter.Converter; @@ -59,27 +50,35 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRe import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.AbstractOAuth2Token; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2AuthorizationException; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestOperations; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; + class SpringBasedIdentityServiceFacade implements IdentityServiceFacade { private static final Log LOGGER = LogFactory.getLog(SpringBasedIdentityServiceFacade.class); private static final Instant SOME_INSIGNIFICANT_DATE_IN_THE_PAST = Instant.MIN.plusSeconds(12345); private final Map clients; + private final DefaultOAuth2UserService defaultOAuth2UserService; private final ClientRegistration clientRegistration; private final JwtDecoder jwtDecoder; @@ -93,6 +92,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade AuthorizationGrantType.AUTHORIZATION_CODE, createAuthorizationCodeClient(restOperations), AuthorizationGrantType.REFRESH_TOKEN, createRefreshTokenClient(restOperations), AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations, clientRegistration)); + this.defaultOAuth2UserService = createOAuth2UserService(restOperations); } @Override @@ -121,51 +121,18 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade } @Override - public Optional getUserInfo(String tokenParameter, String principalAttribute) + public Optional getUserInfo(String token, UserInfoAttrMapping userInfoAttrMapping) { - return Optional.ofNullable(tokenParameter) - .filter(Predicate.not(String::isEmpty)) - .flatMap(token -> Optional.ofNullable(clientRegistration) - .map(ClientRegistration::getProviderDetails) - .map(ClientRegistration.ProviderDetails::getUserInfoEndpoint) - .map(ClientRegistration.ProviderDetails.UserInfoEndpoint::getUri) - .flatMap(uri -> { - try - { - return Optional.of( - new UserInfoRequest(new URI(uri), new BearerAccessToken(token)).toHTTPRequest().send()); - } - catch (IOException | URISyntaxException e) - { - LOGGER.warn("Failed to get user information. Reason: " + e.getMessage()); - return Optional.empty(); - } - }) - .flatMap(httpResponse -> { - try - { - UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse); - - if (userInfoResponse instanceof UserInfoErrorResponse userInfoErrorResponse) - { - String errorMessage = Optional.ofNullable(userInfoErrorResponse.getErrorObject()) - .map(ErrorObject::getDescription) - .orElse("No error description found"); - LOGGER.warn("User Info Request failed: " + errorMessage); - throw new UserInfoException(errorMessage); - } - return Optional.of(userInfoResponse); - } - catch (ParseException e) - { - LOGGER.warn("Failed to parse user info response. Reason: " + e.getMessage()); - return Optional.empty(); - } - }) - .map(UserInfoResponse::toSuccessResponse) - .map(UserInfoSuccessResponse::getUserInfo)) - .map(userInfo -> new OIDCUserInfo(userInfo.getStringClaim(principalAttribute), userInfo.getGivenName(), - userInfo.getFamilyName(), userInfo.getEmailAddress())); + try + { + return Optional.ofNullable(defaultOAuth2UserService.loadUser(new OAuth2UserRequest(clientRegistration, getSpringAccessToken(token)))) + .flatMap(oAuth2User -> mapOAuth2UserToDecodedTokenUser(oAuth2User, userInfoAttrMapping)); + } + catch (OAuth2AuthenticationException exception) + { + LOGGER.warn("User Info Request failed: " + exception.getMessage()); + return Optional.empty(); + } } @Override @@ -202,11 +169,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade if (grant.isRefreshToken()) { - final OAuth2AccessToken expiredAccessToken = new OAuth2AccessToken( - TokenType.BEARER, - "JUST_FOR_FULFILLING_THE_SPRING_API", - SOME_INSIGNIFICANT_DATE_IN_THE_PAST, - SOME_INSIGNIFICANT_DATE_IN_THE_PAST.plusSeconds(1)); + final OAuth2AccessToken expiredAccessToken = getSpringAccessToken("JUST_FOR_FULFILLING_THE_SPRING_API"); final OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(grant.getRefreshToken(), null); return new OAuth2RefreshTokenGrantRequest(clientRegistration, expiredAccessToken, refreshToken, @@ -258,6 +221,26 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade return client; } + private static DefaultOAuth2UserService createOAuth2UserService(RestOperations rest) + { + final DefaultOAuth2UserService userService = new DefaultOAuth2UserService(); + userService.setRestOperations(rest); + return userService; + } + + private Optional mapOAuth2UserToDecodedTokenUser(OAuth2User oAuth2User, UserInfoAttrMapping userInfoAttrMapping) + { + var preferredUsername = Optional.ofNullable(oAuth2User.getAttribute(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)) + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(StringUtils::isNotEmpty); + var userName = Optional.ofNullable(oAuth2User.getName()).filter(username -> !username.isEmpty()).or(() -> preferredUsername); + return userName.map(name -> DecodedTokenUser.validateAndCreate(name, + oAuth2User.getAttribute(userInfoAttrMapping.firstNameClaim()), + oAuth2User.getAttribute(userInfoAttrMapping.lastNameClaim()), + oAuth2User.getAttribute(userInfoAttrMapping.emailClaim()))); + } + private static OAuth2AccessTokenResponseClient createPasswordClient(RestOperations rest, ClientRegistration clientRegistration) { @@ -288,6 +271,16 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade }; } + private static OAuth2AccessToken getSpringAccessToken(String token) + { + // Just for fulfilling the Spring API + return new OAuth2AccessToken( + TokenType.BEARER, + token, + SOME_INSIGNIFICANT_DATE_IN_THE_PAST, + SOME_INSIGNIFICANT_DATE_IN_THE_PAST.plusSeconds(1)); + } + private static class SpringAccessTokenAuthorization implements AccessTokenAuthorization { private final OAuth2AccessTokenResponse tokenResponse; diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java index a6d631e862..8907c2f808 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java @@ -70,7 +70,6 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private static final String ALFRESCO_ACCESS_TOKEN = "ALFRESCO_ACCESS_TOKEN"; private static final String ALFRESCO_REFRESH_TOKEN = "ALFRESCO_REFRESH_TOKEN"; private static final String ALFRESCO_TOKEN_EXPIRATION = "ALFRESCO_TOKEN_EXPIRATION"; - private static final Set SCOPES = Set.of("openid", "profile", "email", "offline_access"); private IdentityServiceConfig identityServiceConfig; private IdentityServiceFacade identityServiceFacade; @@ -225,11 +224,16 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private Set getSupportedScopes(Scope scopes) { return scopes.stream() - .filter(scope -> SCOPES.contains(scope.getValue())) + .filter(this::hasAdminConsoleScope) .map(Identifier::getValue) .collect(Collectors.toSet()); } + private boolean hasAdminConsoleScope(Scope.Value scope) + { + return identityServiceConfig.getAdminConsoleScopes().contains(scope.getValue()); + } + private String getRedirectUri(String requestURL) { try diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java new file mode 100644 index 0000000000..e94d6824e5 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapper.java @@ -0,0 +1,66 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import java.util.Optional; + +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; +import org.apache.commons.lang3.StringUtils; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; + +public class AccessTokenToDecodedTokenUserMapper +{ + private static final String DEFAULT_USERNAME_CLAIM = PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; + + private final UserInfoAttrMapping userInfoAttrMapping; + + public AccessTokenToDecodedTokenUserMapper(UserInfoAttrMapping userInfoAttrMapping) + { + this.userInfoAttrMapping = userInfoAttrMapping; + } + + /** + * Maps the given {@link IdentityServiceFacade.DecodedAccessToken} to a {@link DecodedTokenUser}. + * + * @param token + * the token to map + * @return the mapped {@link DecodedTokenUser} or {@link Optional#empty()} if the token does not contain a username claim + */ + public Optional toDecodedTokenUser(IdentityServiceFacade.DecodedAccessToken token) + { + Object firstName = token.getClaim(userInfoAttrMapping.firstNameClaim()); + Object lastName = token.getClaim(userInfoAttrMapping.lastNameClaim()); + Object email = token.getClaim(userInfoAttrMapping.emailClaim()); + + return Optional.ofNullable(token.getClaim(Optional.ofNullable(userInfoAttrMapping.usernameClaim()) + .filter(StringUtils::isNotBlank) + .orElse(DEFAULT_USERNAME_CLAIM))) + .filter(String.class::isInstance) + .map(String.class::cast) + .map(username -> DecodedTokenUser.validateAndCreate(username, firstName, lastName, email)); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java new file mode 100644 index 0000000000..305c108944 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/DecodedTokenUser.java @@ -0,0 +1,44 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import java.util.Optional; + +public record DecodedTokenUser(String username, String firstName, String lastName, String email) +{ + + private static final String EMPTY_STRING = ""; + + public static DecodedTokenUser validateAndCreate(String username, Object firstName, Object lastName, Object email) + { + return new DecodedTokenUser(username, getStringVal(firstName), getStringVal(lastName), getStringVal(email)); + } + + private static String getStringVal(Object firstName) + { + return Optional.ofNullable(firstName).filter(String.class::isInstance).map(String.class::cast).orElse(EMPTY_STRING); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java similarity index 99% rename from repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java rename to repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java index 5f8a3c25d6..8d8e98f995 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OIDCUserInfo.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/OIDCUserInfo.java @@ -23,7 +23,7 @@ * along with Alfresco. If not, see . * #L% */ -package org.alfresco.repo.security.authentication.identityservice; +package org.alfresco.repo.security.authentication.identityservice.user; import java.util.stream.Stream; diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java new file mode 100644 index 0000000000..5e3cde4be9 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapper.java @@ -0,0 +1,76 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.service.cmr.security.PersonService; + +public class TokenUserToOIDCUserMapper +{ + private final PersonService personService; + + public TokenUserToOIDCUserMapper(PersonService personService) + { + this.personService = personService; + } + + /** + * Maps a decoded token user to an OIDC user where the user id (username) is normalized. + * + * @param decodedTokenUser + * the decoded token user + * @return the OIDC user + */ + public OIDCUserInfo toOIDCUser(DecodedTokenUser decodedTokenUser) + { + return new OIDCUserInfo(usernameToUserId(decodedTokenUser.username()), decodedTokenUser.firstName(), decodedTokenUser.lastName(), decodedTokenUser.email()); + } + + /** + * Normalizes a username, taking into account existing user accounts and case sensitivity settings. + * + * @param caseSensitiveUserName + * the case-sensitive username + * @return the string + */ + private String usernameToUserId(final String caseSensitiveUserName) + { + if (caseSensitiveUserName == null) + { + return null; + } + + String normalized = AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork() { + @Override + public String doWork() throws Exception + { + return personService.getUserIdentifier(caseSensitiveUserName); + } + }, AuthenticationUtil.getSystemUserName()); + + return normalized == null ? caseSensitiveUserName : normalized; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java new file mode 100644 index 0000000000..d748dfe2fd --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/user/UserInfoAttrMapping.java @@ -0,0 +1,41 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +/** + * The UserInfoAttrMapping record represents the mapping of claims fetched from the UserInfo endpoint to create an Alfresco user. + * + * @param usernameClaim + * the claim that represents the username + * @param firstNameClaim + * the claim that represents the first name + * @param lastNameClaim + * the claim that represents the last name + * @param emailClaim + * the claim that represents the email + */ +public record UserInfoAttrMapping(String usernameClaim, String firstNameClaim, String lastNameClaim, String emailClaim) +{} diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml index 748baf8cec..8bc16b3c06 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml @@ -149,6 +149,15 @@ ${identity-service.principal-attribute:preferred_username} + + ${identity-service.first-name-attribute:given_name} + + + ${identity-service.last-name-attribute:family_name} + + + ${identity-service.email-attribute:email} + ${identity-service.client-id.validation.disabled:true} @@ -158,6 +167,18 @@ ${identity-service.signature-algorithms:RS256,PS256} + + ${identity-service.admin-console.scopes:openid,profile,email,offline_access} + + + ${identity-service.password-grant.scopes:openid,profile,email} + + + ${identity-service.issuer-attribute:issuer} + + + ${identity-service.jwt-clock-skew-ms:0} + @@ -219,4 +240,4 @@ - \ No newline at end of file + diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties index 7357b01644..e6d517c1ad 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties @@ -12,4 +12,11 @@ identity-service.resource=alfresco identity-service.credentials.secret= identity-service.public-client=true identity-service.admin-console.redirect-path=/alfresco/s/admin/admin-communitysummary -identity-service.signature-algorithms=RS256,PS256 \ No newline at end of file +identity-service.signature-algorithms=RS256,PS256 +identity-service.first-name-attribute=given_name +identity-service.last-name-attribute=family_name +identity-service.email-attribute=email +identity-service.admin-console.scopes=openid,profile,email,offline_access +identity-service.password-grant.scopes=openid,profile,email +identity-service.issuer-attribute=issuer +identity-service.jwt-clock-skew-ms=0 diff --git a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java index aff459d446..29689e1cad 100644 --- a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java +++ b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java @@ -37,6 +37,8 @@ import org.alfresco.repo.security.authentication.identityservice.SpringBasedIden import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleAuthenticationCookiesServiceUnitTest; import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleHttpServletRequestWrapperUnitTest; import org.alfresco.repo.security.authentication.identityservice.admin.IdentityServiceAdminConsoleAuthenticatorUnitTest; +import org.alfresco.repo.security.authentication.identityservice.user.AccessTokenToDecodedTokenUserMapperUnitTest; +import org.alfresco.repo.security.authentication.identityservice.user.TokenUserToOIDCUserMapperUnitTest; import org.alfresco.util.testing.category.DBTests; import org.alfresco.util.testing.category.NonBuildTests; @@ -149,6 +151,8 @@ import org.alfresco.util.testing.category.NonBuildTests; LazyInstantiatingIdentityServiceFacadeUnitTest.class, SpringBasedIdentityServiceFacadeUnitTest.class, IdentityServiceJITProvisioningHandlerUnitTest.class, + AccessTokenToDecodedTokenUserMapperUnitTest.class, + TokenUserToOIDCUserMapperUnitTest.class, AdminConsoleAuthenticationCookiesServiceUnitTest.class, AdminConsoleHttpServletRequestWrapperUnitTest.class, IdentityServiceAdminConsoleAuthenticatorUnitTest.class, diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java index bc620bd8fb..4efd45cfb0 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java @@ -38,6 +38,7 @@ import java.util.Set; import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import net.minidev.json.JSONObject; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -57,6 +58,9 @@ public class ClientRegistrationProviderUnitTest private static final String OPENID_CONFIGURATION = "{\"token_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/token\",\"token_endpoint_auth_methods_supported\":[\"client_secret_post\",\"private_key_jwt\",\"client_secret_basic\"],\"jwks_uri\":\"https://login.serviceonline.alfresco/common/discovery/v2.0/keys\",\"response_modes_supported\":[\"query\",\"fragment\",\"form_post\"],\"subject_types_supported\":[\"pairwise\"],\"id_token_signing_alg_values_supported\":[\"RS256\"],\"response_types_supported\":[\"code\",\"id_token\",\"code id_token\",\"id_token token\"],\"scopes_supported\":[\"openid\",\"profile\",\"email\",\"offline_access\"],\"issuer\":\"https://login.serviceonline.alfresco/alfresco/v2.0\",\"request_uri_parameter_supported\":false,\"userinfo_endpoint\":\"https://graph.service.alfresco/oidc/userinfo\",\"authorization_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/authorize\",\"device_authorization_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/devicecode\",\"http_logout_supported\":true,\"frontchannel_logout_supported\":true,\"end_session_endpoint\":\"https://login.serviceonline.alfresco/common/oauth2/v2.0/logout\",\"claims_supported\":[\"sub\",\"iss\",\"cloud_instance_name\",\"cloud_instance_host_name\",\"cloud_graph_host_name\",\"msgraph_host\",\"aud\",\"exp\",\"iat\",\"auth_time\",\"acr\",\"nonce\",\"preferred_username\",\"name\",\"tid\",\"ver\",\"at_hash\",\"c_hash\",\"email\"],\"kerberos_endpoint\":\"https://login.serviceonline.alfresco/common/kerberos\",\"tenant_region_scope\":null,\"cloud_instance_name\":\"serviceonline.alfresco\",\"cloud_graph_host_name\":\"graph.oidc.net\",\"msgraph_host\":\"graph.service.alfresco\",\"rbac_url\":\"https://pas.oidc.alfresco\"}"; private static final String DISCOVERY_PATH_SEGMENTS = "/.well-known/openid-configuration"; private static final String AUTH_SERVER = "https://login.serviceonline.alfresco"; + private static final String ADMIN_CONSOLE_SCOPES = "openid,email,profile,offline_access"; + private static final String PSSWD_GRANT_SCOPES = "openid,email,profile"; + private static final String ISSUER_ATRR = "issuer"; private IdentityServiceConfig config; private RestTemplate restTemplate; @@ -70,6 +74,9 @@ public class ClientRegistrationProviderUnitTest config = new IdentityServiceConfig(); config.setAuthServerUrl(AUTH_SERVER); config.setResource(CLIENT_ID); + config.setAdminConsoleScopes(ADMIN_CONSOLE_SCOPES); + config.setPasswordGrantScopes(PSSWD_GRANT_SCOPES); + config.setIssuerAttribute(ISSUER_ATRR); restTemplate = mock(RestTemplate.class); ResponseEntity responseEntity = mock(ResponseEntity.class); @@ -263,4 +270,42 @@ public class ClientRegistrationProviderUnitTest "https://login.serviceonline.alfresco/alfresco/v2.0" + DISCOVERY_PATH_SEGMENTS); } } + + @Test + public void shouldUseDefaultIssuerAttribute() + { + config.setIssuerUrl(null); + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( + restTemplate); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isEqualTo("https://login.serviceonline.alfresco/alfresco/v2.0"); + + } + } + + @Test + public void shouldUseCustomIssuerAttribute() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + config.setIssuerAttribute("access_token_issuer"); + when(oidcResponse.getCustomParameters()).thenReturn(createJSONObject("access_token_issuer", "https://login.serviceonline.alfresco/alfresco/v2.0/at_trust")); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( + restTemplate); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isEqualTo("https://login.serviceonline.alfresco/alfresco/v2.0/at_trust"); + + } + } + + private static JSONObject createJSONObject(String fieldName, String fieldValue) + { + JSONObject jsonObject = new JSONObject(); + jsonObject.appendField(fieldName, fieldValue); + return jsonObject; + } } diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java index 374eae188c..2833b364c5 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2025 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -43,6 +43,7 @@ import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; import org.alfresco.repo.security.sync.UserRegistrySynchronizer; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.security.PersonService; diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java index 8f53ddf82c..52e4714d27 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerTest.java @@ -25,10 +25,7 @@ */ package org.alfresco.repo.security.authentication.identityservice; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import java.lang.reflect.Field; import java.util.Optional; @@ -37,11 +34,14 @@ import com.nimbusds.openid.connect.sdk.claims.PersonClaims; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.alfresco.model.ContentModel; import org.alfresco.repo.management.subsystems.ChildApplicationContextFactory; import org.alfresco.repo.management.subsystems.DefaultChildApplicationContextManager; import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; @@ -126,11 +126,15 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest String principalAttribute = isAuth0Enabled ? PersonClaims.NICKNAME_CLAIM_NAME : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, userPassword)); + UserInfoAttrMapping userInfoAttrMapping = new UserInfoAttrMapping(principalAttribute, "given_name", "family_name", "email"); String accessToken = accessTokenAuthorization.getAccessToken().getTokenValue(); + ClientRegistration clientRegistration = mock(ClientRegistration.class, RETURNS_DEEP_STUBS); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(principalAttribute); IdentityServiceFacade idsServiceFacadeMock = mock(IdentityServiceFacade.class); when(idsServiceFacadeMock.decodeToken(accessToken)).thenReturn(null); - when(idsServiceFacadeMock.getUserInfo(accessToken, principalAttribute)).thenReturn(identityServiceFacade.getUserInfo(accessToken, principalAttribute)); + when(idsServiceFacadeMock.getUserInfo(accessToken, userInfoAttrMapping)).thenReturn(identityServiceFacade.getUserInfo(accessToken, userInfoAttrMapping)); + when(idsServiceFacadeMock.getClientRegistration()).thenReturn(clientRegistration); // Replace the original facade with a mocked one to prevent user information from being extracted from the access token. Field declaredField = jitProvisioningHandler.getClass() @@ -151,7 +155,7 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertEquals("johndoe123@alfresco.com", userInfoOptional.get().email()); assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); verify(idsServiceFacadeMock).decodeToken(accessToken); - verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, principalAttribute); + verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, userInfoAttrMapping); if (!isAuth0Enabled) { assertEquals("John", userInfoOptional.get().firstName()); diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java index 89d7d05629..2b5223f68f 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceJITProvisioningHandlerUnitTest.java @@ -40,8 +40,13 @@ import java.util.Optional; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; import org.junit.Before; import org.junit.Test; +import org.mockito.Answers; import org.mockito.Mock; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.alfresco.repo.security.authentication.identityservice.user.DecodedTokenUser; +import org.alfresco.repo.security.authentication.identityservice.user.OIDCUserInfo; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.transaction.TransactionService; @@ -51,6 +56,9 @@ public class IdentityServiceJITProvisioningHandlerUnitTest @Mock private IdentityServiceFacade identityServiceFacade; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ClientRegistration clientRegistration; + @Mock private PersonService personService; @@ -64,11 +72,22 @@ public class IdentityServiceJITProvisioningHandlerUnitTest private IdentityServiceConfig identityServiceConfig; @Mock - private OIDCUserInfo userInfo; + private DecodedTokenUser decodedTokenUser; private IdentityServiceJITProvisioningHandler jitProvisioningHandler; + private UserInfoAttrMapping expectedMapping; + private static final String JWT_TOKEN = "myToken"; + private static final String USERNAME = "johny123"; + private static final String FIRST_NAME = "John"; + private static final String LAST_NAME = "Doe"; + private static final String EMAIL = "johny123@email.com"; + + public static final String USERNAME_CLAIM = "nickname"; + public static final String EMAIL_CLAIM = "email"; + public static final String FIRST_NAME_CLAIM = "given_name"; + public static final String LAST_NAME_CLAIM = "family_name"; @Before public void setup() @@ -78,149 +97,147 @@ public class IdentityServiceJITProvisioningHandlerUnitTest when(transactionService.isReadOnly()).thenReturn(false); when(identityServiceFacade.decodeToken(JWT_TOKEN)).thenReturn(decodedAccessToken); when(personService.createMissingPeople()).thenReturn(true); - jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, - personService, transactionService, identityServiceConfig); + when(identityServiceFacade.getClientRegistration()).thenReturn(clientRegistration); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(USERNAME_CLAIM); + when(identityServiceConfig.getEmailAttribute()).thenReturn(EMAIL_CLAIM); + when(identityServiceConfig.getFirstNameAttribute()).thenReturn(FIRST_NAME_CLAIM); + when(identityServiceConfig.getLastNameAttribute()).thenReturn(LAST_NAME_CLAIM); + expectedMapping = new UserInfoAttrMapping(USERNAME_CLAIM, FIRST_NAME_CLAIM, LAST_NAME_CLAIM, EMAIL_CLAIM); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); } @Test public void shouldExtractUserInfoForExistingUser() { - when(personService.personExists("johny123")).thenReturn(true); - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertFalse(result.get().allFieldsNotEmpty()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoForExistingUserWithProviderPrincipalAttribute() { - when(identityServiceConfig.getPrincipalAttribute()).thenReturn("nickname"); - when(personService.personExists("johny123")).thenReturn(true); - when(decodedAccessToken.getClaim("nickname")).thenReturn("johny123"); + when(identityServiceConfig.getPrincipalAttribute()).thenReturn(USERNAME_CLAIM); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(USERNAME); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertFalse(result.get().allFieldsNotEmpty()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, "nickname"); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoFromAccessTokenAndCreateUser() { - when(personService.personExists("johny123")).thenReturn(false); - - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); - when(decodedAccessToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)).thenReturn("John"); - when(decodedAccessToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)).thenReturn("Doe"); - when(decodedAccessToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)).thenReturn("johny123@email.com"); + when(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + when(personService.personExists(USERNAME)).thenReturn(false); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + when(decodedAccessToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)).thenReturn(FIRST_NAME); + when(decodedAccessToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)).thenReturn(LAST_NAME); + when(decodedAccessToken.getClaim(PersonClaims.EMAIL_CLAIM_NAME)).thenReturn(EMAIL); + jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, personService, transactionService, identityServiceConfig); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); - assertEquals("John", result.get().firstName()); - assertEquals("Doe", result.get().lastName()); - assertEquals("johny123@email.com", result.get().email()); + assertEquals(USERNAME, result.get().username()); + assertEquals(FIRST_NAME, result.get().firstName()); + assertEquals(LAST_NAME, result.get().lastName()); + assertEquals(EMAIL, result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldExtractUserInfoFromUserInfoEndpointAndCreateUser() { - when(userInfo.username()).thenReturn("johny123"); - when(userInfo.firstName()).thenReturn("John"); - when(userInfo.lastName()).thenReturn("Doe"); - when(userInfo.email()).thenReturn("johny123@email.com"); - - when(personService.personExists("johny123")).thenReturn(false); - - when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); + when(decodedTokenUser.username()).thenReturn(USERNAME); + when(decodedTokenUser.firstName()).thenReturn(FIRST_NAME); + when(decodedTokenUser.lastName()).thenReturn(LAST_NAME); + when(decodedTokenUser.email()).thenReturn(EMAIL); + when(personService.personExists(USERNAME)).thenReturn(false); + when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(USERNAME); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(decodedTokenUser)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); - assertEquals("John", result.get().firstName()); - assertEquals("Doe", result.get().lastName()); - assertEquals("johny123@email.com", result.get().email()); + assertEquals(USERNAME, result.get().username()); + assertEquals(FIRST_NAME, result.get().firstName()); + assertEquals(LAST_NAME, result.get().lastName()); + assertEquals(EMAIL, result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldReturnEmptyOptionalIfUsernameNotExtracted() { - - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(decodedTokenUser)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertFalse(result.isPresent()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldCallUserInfoEndpointToGetUsername() { - when(personService.personExists("johny123")).thenReturn(true); - + when(personService.personExists(USERNAME)).thenReturn(true); when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(""); - - when(userInfo.username()).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); - + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(DecodedTokenUser.validateAndCreate(USERNAME, null, null, null))); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertEquals("", result.get().firstName()); assertEquals("", result.get().lastName()); assertEquals("", result.get().email()); assertFalse(result.get().allFieldsNotEmpty()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test public void shouldCallUserInfoEndpointToGetUsernameWithProvidedPrincipalAttribute() { - when(identityServiceConfig.getPrincipalAttribute()).thenReturn("nickname"); - when(personService.personExists("johny123")).thenReturn(true); - - when(decodedAccessToken.getClaim("nickname")).thenReturn(""); - - when(userInfo.username()).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN, "nickname")).thenReturn(Optional.of(userInfo)); + when(identityServiceConfig.getPrincipalAttribute()).thenReturn(USERNAME_CLAIM); + when(personService.personExists(USERNAME)).thenReturn(true); + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(""); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, expectedMapping)).thenReturn(Optional.of(DecodedTokenUser.validateAndCreate(USERNAME, null, null, null))); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertTrue(result.isPresent()); - assertEquals("johny123", result.get().username()); + assertEquals(USERNAME, result.get().username()); assertEquals("", result.get().firstName()); assertEquals("", result.get().lastName()); assertEquals("", result.get().email()); assertFalse(result.get().allFieldsNotEmpty()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN, "nickname"); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, expectedMapping); } @Test @@ -232,8 +249,8 @@ public class IdentityServiceJITProvisioningHandlerUnitTest verify(personService, never()).createPerson(any()); verify(identityServiceFacade, never()).decodeToken(null); verify(identityServiceFacade, never()).decodeToken(""); - verify(identityServiceFacade, never()).getUserInfo(null, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); - verify(identityServiceFacade, never()).getUserInfo("", PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo(null, expectedMapping); + verify(identityServiceFacade, never()).getUserInfo(null, expectedMapping); } } diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java index 11df2ddae7..72e29c2d27 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java @@ -38,6 +38,7 @@ import jakarta.servlet.http.HttpServletRequest; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; import junit.framework.TestCase; +import org.mockito.Mockito; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; import org.alfresco.repo.security.authentication.AuthenticationException; @@ -96,12 +97,14 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase private IdentityServiceRemoteUserMapper givenMapper(Map> tokenToUser) { final TransactionService transactionService = mock(TransactionService.class); - final IdentityServiceFacade facade = mock(IdentityServiceFacade.class); + final IdentityServiceFacade facade = mock(IdentityServiceFacade.class, Mockito.RETURNS_DEEP_STUBS); final PersonService personService = mock(PersonService.class); final IdentityServiceConfig identityServiceConfig = mock(IdentityServiceConfig.class); when(transactionService.isReadOnly()).thenReturn(true); when(facade.decodeToken(anyString())) .thenAnswer(i -> new TestDecodedToken(tokenToUser.get(i.getArgument(0, String.class)))); + when(facade.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()) + .thenReturn(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); when(personService.getUserIdentifier(anyString())).thenAnswer(i -> i.getArgument(0, String.class)); diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java index fa4a942eba..34670b0a88 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java @@ -40,12 +40,14 @@ import org.springframework.web.client.RestOperations; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenDecodingException; +import org.alfresco.repo.security.authentication.identityservice.user.UserInfoAttrMapping; public class SpringBasedIdentityServiceFacadeUnitTest { private static final String USER_NAME = "user"; private static final String PASSWORD = "password"; private static final String TOKEN = "tEsT-tOkEn"; + private static final UserInfoAttrMapping USER_INFO_ATTR_MAPPING = new UserInfoAttrMapping("preferred_username", "given_name", "family_name", "email"); @Test public void shouldThrowVerificationExceptionOnFailure() @@ -82,7 +84,7 @@ public class SpringBasedIdentityServiceFacadeUnitTest final JwtDecoder jwtDecoder = mock(JwtDecoder.class); final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); - assertThat(facade.getUserInfo(TOKEN, "preferred_username").isEmpty()).isTrue(); + assertThat(facade.getUserInfo(TOKEN, USER_INFO_ATTR_MAPPING).isEmpty()).isTrue(); } private ClientRegistration testRegistration() diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java index 75861184a9..6d1ca62de4 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.time.Instant; import java.util.Arrays; import java.util.Map; +import java.util.Set; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -155,6 +156,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest { String redirectPath = "/alfresco/s/admin/admin-communitysummary"; + when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn("/alfresco/s/admin/admin-communitysummary"); ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" @@ -178,6 +180,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest String redirectPath = "/alfresco/s/admin/admin-communitysummary"; when(identityServiceConfig.getAudience()).thenReturn(audience); when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(redirectPath); + when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" .formatted("http://localhost:8080", redirectPath); diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java new file mode 100644 index 0000000000..f41a313e51 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/AccessTokenToDecodedTokenUserMapperUnitTest.java @@ -0,0 +1,109 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; + +public class AccessTokenToDecodedTokenUserMapperUnitTest +{ + + @Mock + private IdentityServiceFacade.DecodedAccessToken decodedAccessToken; + + private AccessTokenToDecodedTokenUserMapper tokenToDecodedTokenUserMapper; + + public static final String USERNAME_CLAIM = "nickname"; + public static final String EMAIL_CLAIM = "email"; + public static final String FIRST_NAME_CLAIM = "given_name"; + public static final String LAST_NAME_CLAIM = "family_name"; + + @Before + public void setup() + { + initMocks(this); + UserInfoAttrMapping userInfoAttrMapping = new UserInfoAttrMapping(USERNAME_CLAIM, FIRST_NAME_CLAIM, LAST_NAME_CLAIM, EMAIL_CLAIM); + tokenToDecodedTokenUserMapper = new AccessTokenToDecodedTokenUserMapper(userInfoAttrMapping); + } + + @Test + public void shouldMapToDecodedTokenUserWithAllFieldsPopulated() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn("johny123"); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn("John"); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn("johny123@email.com"); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertTrue(result.isPresent()); + assertEquals("johny123", result.get().username()); + assertEquals("John", result.get().firstName()); + assertEquals("Doe", result.get().lastName()); + assertEquals("johny123@email.com", result.get().email()); + } + + @Test + public void shouldMapToDecodedTokenUserWithSomeFieldsEmpty() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn("johny123"); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn(""); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn(""); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertTrue(result.isPresent()); + assertEquals("johny123", result.get().username()); + assertEquals("", result.get().firstName()); + assertEquals("Doe", result.get().lastName()); + assertEquals("", result.get().email()); + } + + @Test + public void shouldReturnEmptyOptionalForNullUsername() + { + when(decodedAccessToken.getClaim(USERNAME_CLAIM)).thenReturn(null); + when(decodedAccessToken.getClaim(FIRST_NAME_CLAIM)).thenReturn("John"); + when(decodedAccessToken.getClaim(LAST_NAME_CLAIM)).thenReturn("Doe"); + when(decodedAccessToken.getClaim(EMAIL_CLAIM)).thenReturn("johny123@email.com"); + + Optional result = tokenToDecodedTokenUserMapper.toDecodedTokenUser(decodedAccessToken); + + assertFalse(result.isPresent()); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java new file mode 100644 index 0000000000..6602f7dab5 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/user/TokenUserToOIDCUserMapperUnitTest.java @@ -0,0 +1,95 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.user; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import org.alfresco.service.cmr.security.PersonService; + +public class TokenUserToOIDCUserMapperUnitTest +{ + + @Mock + private PersonService personService; + + @InjectMocks + private TokenUserToOIDCUserMapper tokenUserToOIDCUserMapper; + + @Before + public void setup() + { + initMocks(this); + } + + @Test + public void shouldMapToOIDCUserWithAllFieldsPopulated() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser("JOHNY123", "John", "Doe", "johny123@email.com"); + when(personService.getUserIdentifier("JOHNY123")).thenReturn("johny123"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertEquals("johny123", oidcUserInfo.username()); + assertEquals("John", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("johny123@email.com", oidcUserInfo.email()); + } + + @Test + public void shouldMapToOIDCUserWithSomeFieldsEmpty() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser("johny123", "", "Doe", ""); + when(personService.getUserIdentifier("johny123")).thenReturn("johny123"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertEquals("johny123", oidcUserInfo.username()); + assertEquals("", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("", oidcUserInfo.email()); + } + + @Test + public void shouldReturnNullForNullUsername() + { + DecodedTokenUser decodedTokenUser = new DecodedTokenUser(null, "John", "Doe", "johny123@email.com"); + + OIDCUserInfo oidcUserInfo = tokenUserToOIDCUserMapper.toOIDCUser(decodedTokenUser); + + assertNull(oidcUserInfo.username()); + assertEquals("John", oidcUserInfo.firstName()); + assertEquals("Doe", oidcUserInfo.lastName()); + assertEquals("johny123@email.com", oidcUserInfo.email()); + } +} diff --git a/repository/src/test/resources/alfresco-global.properties b/repository/src/test/resources/alfresco-global.properties index 20f5389408..53848254c4 100644 --- a/repository/src/test/resources/alfresco-global.properties +++ b/repository/src/test/resources/alfresco-global.properties @@ -28,6 +28,9 @@ identity-service.register-node-at-startup=true identity-service.register-node-period=50 identity-service.token-store=SESSION identity-service.principal-attribute=preferred_username +identity-service.first-name-attribute=given_name +identity-service.last-name-attribute=family_name +identity-service.email-attribute=email identity-service.turn-off-change-session-id-on-login=true identity-service.token-minimum-time-to-live=10 identity-service.min-time-between-jwks-requests=60