diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0266826379..930f57912f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ env: CI_WORKSPACE: ${{ github.workspace }} TAS_ENVIRONMENT: ./packaging/tests/environment TAS_SCRIPTS: ../alfresco-community-repo/packaging/tests/scripts + AUTH0_CLIENT_ID: ${{ secrets.AUTH0_OIDC_ADMIN_CLIENT_ID }} + AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_OIDC_CLIENT_SECRET }} + AUTH0_ADMIN_PASSWORD: ${{ secrets.AUTH0_OIDC_ADMIN_PASSWORD }} jobs: prepare: @@ -387,7 +390,7 @@ jobs: run: bash ./scripts/ci/cleanup_cache.sh repository_app_context_test_suites: - name: Repository - ${{ matrix.testSuite }} + name: Repository - ${{ matrix.testSuite }} ${{ matrix.idp }} runs-on: ubuntu-latest needs: [prepare] if: > @@ -409,6 +412,11 @@ jobs: - testSuite: AppContext05TestSuite compose-profile: with-sso mvn-options: '-Didentity-service.auth-server-url=http://${HOST_IP}:8999/auth -Dauthentication.chain=identity-service1:identity-service,alfrescoNtlm1:alfrescoNtlm' + idp: Keycloak + - testSuite: AppContext05TestSuite + compose-profile: default + mvn-options: '-Didentity-service.auth-server-url=https://dev-ps-alfresco.auth0.com/ -Dauthentication.chain=identity-service1:identity-service,alfrescoNtlm1:alfrescoNtlm -Didentity-service.audience=http://localhost:3000 -Didentity-service.resource=${AUTH0_CLIENT_ID} -Didentity-service.credentials.secret=${AUTH0_CLIENT_SECRET} -Didentity-service.public-client=false -Didentity-service.realm= -Didentity-service.client-id.validation.disabled=false -Dadmin.user=admin@alfresco.com -Dadmin.password=${AUTH0_ADMIN_PASSWORD} -Dauth0.enabled=true -Dauth0.admin.password=${AUTH0_ADMIN_PASSWORD} -Didentity-service.principal-attribute=nickname' + idp: Auth0 - testSuite: AppContext06TestSuite compose-profile: with-transform-core-aio - testSuite: AppContextExtraTestSuite 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 2f41d8dc5b..c8b4b4be1d 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -27,6 +27,7 @@ package org.alfresco.repo.security.authentication.identityservice; import java.util.Optional; +import org.apache.commons.lang3.StringUtils; import org.springframework.web.util.UriComponentsBuilder; /** @@ -40,6 +41,8 @@ public class IdentityServiceConfig private int clientConnectionTimeout; private int clientSocketTimeout; + private String issuerUrl; + private String audience; // client id private String resource; private String clientSecret; @@ -56,6 +59,9 @@ public class IdentityServiceConfig private String realmKey; private int publicKeyCacheTtl; private boolean publicClient; + private String principalAttribute; + private boolean clientIdValidationDisabled; + private String adminConsoleRedirectPath; /** * @@ -103,9 +109,36 @@ public class IdentityServiceConfig return connectionPoolSize; } + public String getIssuerUrl() + { + return issuerUrl; + } + + public void setIssuerUrl(String issuerUrl) + { + this.issuerUrl = issuerUrl; + } + + public String getAudience() + { + return audience; + } + + public void setAudience(String audience) + { + this.audience = audience; + } + public String getAuthServerUrl() { - return authServerUrl; + return Optional.ofNullable(realm) + .filter(StringUtils::isNotBlank) + .filter(realm -> StringUtils.isNotBlank(authServerUrl)) + .map(realm -> UriComponentsBuilder.fromUriString(authServerUrl) + .pathSegment(REALMS, realm) + .build() + .toString()) + .orElse(authServerUrl); } public void setAuthServerUrl(String authServerUrl) @@ -141,15 +174,7 @@ public class IdentityServiceConfig public String getClientSecret() { return Optional.ofNullable(clientSecret) - .orElse(""); - } - - public String getIssuerUrl() - { - return UriComponentsBuilder.fromUriString(getAuthServerUrl()) - .pathSegment(REALMS, getRealm()) - .build() - .toString(); + .orElse(""); } public void setAllowAnyHostname(boolean allowAnyHostname) @@ -251,4 +276,34 @@ public class IdentityServiceConfig { return publicClient; } + + public String getPrincipalAttribute() + { + return principalAttribute; + } + + public void setPrincipalAttribute(String principalAttribute) + { + this.principalAttribute = principalAttribute; + } + + public boolean isClientIdValidationDisabled() + { + return clientIdValidationDisabled; + } + + public void setClientIdValidationDisabled(boolean clientIdValidationDisabled) + { + this.clientIdValidationDisabled = clientIdValidationDisabled; + } + + public String getAdminConsoleRedirectPath() + { + return adminConsoleRedirectPath; + } + + public void setAdminConsoleRedirectPath(String adminConsoleRedirectPath) + { + this.adminConsoleRedirectPath = adminConsoleRedirectPath; + } } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceException.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceException.java new file mode 100644 index 0000000000..bb8fa412e0 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceException.java @@ -0,0 +1,41 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2024 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; + +public class IdentityServiceException extends RuntimeException +{ + private static final long serialVersionUID = -7541541232589648112L; + + public IdentityServiceException(String message) + { + super(message); + } + + public IdentityServiceException(String message, Throwable cause) + { + super(message, cause); + } +} 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 946503bfde..d2027fb2cb 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -33,7 +33,6 @@ import java.util.Objects; import java.util.Optional; import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; /** * Allows to interact with the Identity Service @@ -60,9 +59,10 @@ public interface IdentityServiceFacade * Gets claims about the authenticated user, * such as name and email address, via the UserInfo endpoint of the OpenID provider. * @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. */ - Optional getUserInfo(String token); + Optional getUserInfo(String token, String principalAttribute); /** * 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 bdfe2910ea..54ef842e99 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -29,25 +29,34 @@ import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; import static java.util.function.Predicate.not; +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.AUDIENCE; +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.SCOPES_SUPPORTED; + import java.io.ByteArrayInputStream; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.interfaces.RSAPublicKey; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Objects; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Collectors; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; @@ -57,10 +66,13 @@ import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.util.ResourceRetriever; 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.op.OIDCProviderMetadata; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.hc.client5.http.classic.HttpClient; @@ -75,9 +87,11 @@ import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.ssl.SSLContextBuilder; import org.apache.hc.core5.ssl.SSLContexts; import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.converter.FormHttpMessageConverter; @@ -109,10 +123,8 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; /** - * * Creates an instance of {@link IdentityServiceFacade}.
* This factory can return a null if it is disabled. - * */ public class IdentityServiceFacadeFactoryBean implements FactoryBean { @@ -128,9 +140,9 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean getUserInfo(String token) + public Optional getUserInfo(String token, String principalAttribute) { - return getTargetFacade().getUserInfo(token); + return getTargetFacade().getUserInfo(token, principalAttribute); } @Override @@ -203,8 +215,8 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean targetFacade.updateAndGet(prev -> - ofNullable(prev).orElseGet(this::createTargetFacade))); + .orElseGet(() -> targetFacade.updateAndGet(prev -> + ofNullable(prev).orElseGet(this::createTargetFacade))); } private IdentityServiceFacade createTargetFacade() @@ -232,9 +244,9 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean jwtDecoderProvider; SpringBasedIdentityServiceFacadeFactory( - Supplier httpClientProvider, - Function clientRegistrationProvider, - BiFunction jwtDecoderProvider) + Supplier httpClientProvider, + Function clientRegistrationProvider, + BiFunction jwtDecoderProvider) { this.httpClientProvider = requireNonNull(httpClientProvider); this.clientRegistrationProvider = requireNonNull(clientRegistrationProvider); @@ -247,17 +259,21 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean SCOPES = Set.of("openid", "profile", "email"); + + ClientRegistrationProvider(IdentityServiceConfig config) { this.config = requireNonNull(config); } @@ -372,38 +391,98 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean extractMetadata(rest, u)) - .filter(Optional::isPresent) - .map(Optional::get) - .findFirst() - .map(this::createBuilder) - .map(this::configureClientAuthentication) - .map(Builder::build) - .orElseThrow(() -> new IllegalStateException("Failed to create ClientRegistration.")); + .stream() + .map(u -> extractMetadata(rest, u)) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst() + .map(this::validateDiscoveryDocument) + .map(this::createBuilder) + .map(this::configureClientAuthentication) + .map(Builder::build) + .orElseThrow(() -> new IllegalStateException("Failed to create ClientRegistration.")); + } + + private OIDCProviderMetadata validateDiscoveryDocument(OIDCProviderMetadata metadata) + { + validateOIDCEndpoint(metadata.getTokenEndpointURI(), "Token"); + validateOIDCEndpoint(metadata.getAuthorizationEndpointURI(), "Authorization"); + validateOIDCEndpoint(metadata.getUserInfoEndpointURI(), "User Info"); + validateOIDCEndpoint(metadata.getJWKSetURI(), "JWK Set"); + + if (metadata.getIssuer() != null) + { + try + { + URI metadataIssuerURI = new URI(metadata.getIssuer().getValue()); + validateOIDCEndpoint(metadataIssuerURI, "Issuer"); + if (StringUtils.isNotBlank(config.getIssuerUrl()) && + !metadataIssuerURI.equals(URI.create(config.getIssuerUrl()))) + { + throw new IdentityServiceException("Failed to create ClientRegistration. " + + "The Issuer value from the OIDC Discovery Endpoint does not align with the provided Issuer. Expected `%s` but found `%s`" + .formatted(config.getIssuerUrl(), metadata.getIssuer().getValue())); + } + } + catch (URISyntaxException e) + { + throw new IdentityServiceException("The provided Issuer value could not be parsed as a URI reference.", e); + } + } + else + { + throw new IdentityServiceException("The Issuer retrieved from the OIDC Discovery Endpoint cannot be null."); + } + + return metadata; + } + + private void validateOIDCEndpoint(URI value, String endpointName) + { + if (value == null || value.toASCIIString().isBlank()) + { + throw new IdentityServiceException("The `%s` Endpoint retrieved from the OIDC Discovery Endpoint cannot be empty.".formatted(endpointName)); + } } private ClientRegistration.Builder createBuilder(OIDCProviderMetadata metadata) { final String authUri = Optional.of(metadata) - .map(OIDCProviderMetadata::getAuthorizationEndpointURI) - .map(URI::toASCIIString) - .orElse(null); + .map(OIDCProviderMetadata::getAuthorizationEndpointURI) + .map(URI::toASCIIString) + .orElse(null); final String issuerUri = Optional.of(metadata) - .map(OIDCProviderMetadata::getIssuer) - .map(Issuer::getValue) - .orElseGet(config::getIssuerUrl); + .map(OIDCProviderMetadata::getIssuer) + .map(Issuer::getValue) + .orElseGet(() -> (StringUtils.isNotBlank(config.getRealm()) && StringUtils.isBlank(config.getIssuerUrl())) ? + config.getAuthServerUrl() : + config.getIssuerUrl()); return ClientRegistration - .withRegistrationId("ids") - .authorizationUri(authUri) - .tokenUri(metadata.getTokenEndpointURI().toASCIIString()) - .jwkSetUri(metadata.getJWKSetURI().toASCIIString()) - .issuerUri(issuerUri) - .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) - .scope("openid", "profile", "email") - .authorizationGrantType(AuthorizationGrantType.PASSWORD); + .withRegistrationId("ids") + .authorizationUri(authUri) + .tokenUri(metadata.getTokenEndpointURI().toASCIIString()) + .jwkSetUri(metadata.getJWKSetURI().toASCIIString()) + .issuerUri(issuerUri) + .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) + .scope(getSupportedScopes(metadata.getScopes())) + .providerConfigurationMetadata(createMetadata(metadata)) + .authorizationGrantType(AuthorizationGrantType.PASSWORD); + } + + private Map createMetadata(OIDCProviderMetadata metadata) + { + Map configurationMetadata = new LinkedHashMap<>(); + if(metadata.getScopes() != null) + { + configurationMetadata.put(SCOPES_SUPPORTED.getValue(), metadata.getScopes()); + } + if(StringUtils.isNotBlank(config.getAudience())) + { + configurationMetadata.put(AUDIENCE.getValue(), config.getAudience()); + } + return configurationMetadata; } private Builder configureClientAuthentication(Builder builder) @@ -412,10 +491,17 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean getSupportedScopes(Scope scopes) + { + return scopes.stream().filter(scope -> SCOPES.contains(scope.getValue())) + .map(Identifier::getValue) + .collect(Collectors.toSet()); } private Optional extractMetadata(RestOperations rest, URI metadataUri) @@ -426,7 +512,8 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean r = rest.exchange(RequestEntity.get(metadataUri).build(), String.class); if (r.getStatusCode() != HttpStatus.OK || !r.hasBody()) { - LOGGER.warn("Unexpected response from " + metadataUri + ". Status code: " + r.getStatusCode() + ", has body: " + r.hasBody() + "."); + LOGGER.warn("Unexpected response from " + metadataUri + ". Status code: " + r.getStatusCode() + + ", has body: " + r.hasBody() + "."); return Optional.empty(); } response = r.getBody(); @@ -449,9 +536,19 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean possibleMetadataURIs() { - return List.of(UriComponentsBuilder.fromUriString(config.getIssuerUrl()) - .pathSegment(".well-known", "openid-configuration") - .build().toUri()); + if (StringUtils.isBlank(config.getAuthServerUrl()) && StringUtils.isBlank(config.getIssuerUrl())) + { + throw new IdentityServiceException( + "Failed to create ClientRegistration. The values of issuer url and auth server url cannot both be empty."); + } + + String baseUrl = StringUtils.isNotBlank(config.getAuthServerUrl()) ? + config.getAuthServerUrl() : + config.getIssuerUrl(); + + return List.of(UriComponentsBuilder.fromUriString(baseUrl) + .pathSegment(".well-known", "openid-configuration") + .build().toUri()); } } @@ -472,10 +569,12 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean jwtProcessor) { final Optional> jwkSource = ofNullable(jwtProcessor) - .map(ConfigurableJWTProcessor::getJWSKeySelector) - .filter(JWSVerificationKeySelector.class::isInstance).map(o -> (JWSVerificationKeySelector)o) - .map(JWSVerificationKeySelector::getJWKSource) - .filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet)o); + .map(ConfigurableJWTProcessor::getJWSKeySelector) + .filter(JWSVerificationKeySelector.class::isInstance) + .map(o -> (JWSVerificationKeySelector) o) + .map(JWSVerificationKeySelector::getJWKSource) + .filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet) o); if (jwkSource.isEmpty()) { LOGGER.warn("Not able to reconfigure the JWK Cache. Unexpected JWKSource."); @@ -527,21 +627,30 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(), resourceRetriever.get(), cache); + final DefaultJWKSetCache cache = new DefaultJWKSetCache(config.getPublicKeyCacheTtl(), -1, + TimeUnit.SECONDS); + final JWKSource cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(), + resourceRetriever.get(), cache); jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>( - JWSAlgorithm.parse(SIGNATURE_ALGORITHM.getName()), - cachingJWKSource)); + JWSAlgorithm.parse(SIGNATURE_ALGORITHM.getName()), + cachingJWKSource)); } private OAuth2TokenValidator createJwtTokenValidator(ProviderDetails providerDetails) { - return new DelegatingOAuth2TokenValidator<>( - new JwtTimestampValidator(Duration.of(0, ChronoUnit.MILLIS)), - new JwtIssuerValidator(providerDetails.getIssuerUri()), - new JwtClaimValidator("typ", "Bearer"::equals), - new JwtClaimValidator(JwtClaimNames.SUB, Objects::nonNull)); + List> validators = new ArrayList<>(); + validators.add(new JwtTimestampValidator(Duration.of(0, ChronoUnit.MILLIS))); + validators.add(new JwtIssuerValidator(providerDetails.getIssuerUri())); + if (!config.isClientIdValidationDisabled()) + { + validators.add(new JwtClaimValidator("azp", config.getResource()::equals)); + } + if (StringUtils.isNotBlank(config.getAudience())) + { + validators.add(new JwtAudienceValidator(config.getAudience())); + } + return new DelegatingOAuth2TokenValidator<>(validators); } private RSAPublicKey parsePublicKey(String pem) @@ -575,12 +684,13 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean + { + private final String configuredAudience; + + public JwtAudienceValidator(String configuredAudience) + { + this.configuredAudience = configuredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) + { + requireNonNull(token, "token cannot be null"); + final Object audience = token.getClaim(JwtClaimNames.AUD); + if (audience != null) + { + if(audience instanceof List && ((List) audience).contains(configuredAudience)) + { + return OAuth2TokenValidatorResult.success(); + } + if(audience instanceof String && audience.equals(configuredAudience)) + { + return OAuth2TokenValidatorResult.success(); + } + } + + final OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, + "The aud claim is not valid. Expected configured audience `%s` not found.".formatted(configuredAudience), + "https://tools.ietf.org/html/rfc6750#section-3.1"); + return OAuth2TokenValidatorResult.failure(error); + } + } + + + static class CustomClientHttpRequestFactory extends HttpComponentsClientHttpRequestFactory + { + CustomClientHttpRequestFactory(HttpClient httpClient) + { + super(httpClient); + } + + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException + { + /* + * This is to avoid the Brotli content encoding that is not well-supported by the combination of + * the Apache Http Client and the Spring RestTemplate + */ + ClientHttpRequest request = super.createRequest(uri, httpMethod); + request.getHeaders() + .add("Accept-Encoding", "gzip, deflate"); + return request; + } + } + private static boolean isDefined(String value) { return value != null && !value.isBlank(); } + } 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 509576306b..6662d15d8f 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -30,7 +30,7 @@ import java.io.Serializable; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.function.Function; +import java.util.function.BiFunction; import java.util.function.Predicate; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; @@ -38,6 +38,7 @@ import com.nimbusds.openid.connect.sdk.claims.UserInfo; import org.alfresco.model.ContentModel; import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.DecodedAccessToken; import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.namespace.QName; import org.alfresco.service.transaction.TransactionService; @@ -50,22 +51,28 @@ import org.apache.commons.lang3.StringUtils; */ public class IdentityServiceJITProvisioningHandler { + private final IdentityServiceConfig identityServiceConfig; private final IdentityServiceFacade identityServiceFacade; private final PersonService personService; private final TransactionService transactionService; - private final Function> mapTokenToUserInfoResponse = token -> { - Optional firstName = Optional.ofNullable(token.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME)) + 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.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME)) + 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.getClaim(PersonClaims.EMAIL_CLAIM_NAME)) + 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(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)) + 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) @@ -74,11 +81,13 @@ public class IdentityServiceJITProvisioningHandler public IdentityServiceJITProvisioningHandler(IdentityServiceFacade identityServiceFacade, PersonService personService, - TransactionService transactionService) + TransactionService transactionService, + IdentityServiceConfig identityServiceConfig) { this.identityServiceFacade = identityServiceFacade; this.personService = personService; this.transactionService = transactionService; + this.identityServiceConfig = identityServiceConfig; } public Optional extractUserInfoAndCreateUserIfNeeded(String bearerToken) @@ -130,12 +139,15 @@ public class IdentityServiceJITProvisioningHandler { return Optional.ofNullable(bearerToken) .map(identityServiceFacade::decodeToken) - .flatMap(mapTokenToUserInfoResponse); + .flatMap(decodedToken -> mapTokenToUserInfoResponse.apply(decodedToken, + identityServiceConfig.getPrincipalAttribute())); } private Optional extractUserInfoResponseFromEndpoint(String bearerToken) { - return identityServiceFacade.getUserInfo(bearerToken) + 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(""), diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceMetadataKey.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceMetadataKey.java new file mode 100644 index 0000000000..46b8b49289 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceMetadataKey.java @@ -0,0 +1,44 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2024 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; + +public enum IdentityServiceMetadataKey +{ + AUDIENCE("audience"), + SCOPES_SUPPORTED("scopes_supported"); + + private String value; + + IdentityServiceMetadataKey(String value) + { + this.value = value; + } + + public String getValue() + { + return value; + } +} \ No newline at end of file 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 aff46db6d6..01cb8c8152 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -28,6 +28,8 @@ package org.alfresco.repo.security.authentication.identityservice; 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; @@ -44,6 +46,7 @@ import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.core.convert.converter.Converter; import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest; import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.DefaultPasswordTokenResponseClient; @@ -51,8 +54,10 @@ import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTo import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequestEntityConverter; 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.core.AbstractOAuth2Token; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; @@ -65,6 +70,8 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; 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; class SpringBasedIdentityServiceFacade implements IdentityServiceFacade @@ -75,15 +82,16 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade private final ClientRegistration clientRegistration; private final JwtDecoder jwtDecoder; - SpringBasedIdentityServiceFacade(RestOperations restOperations, ClientRegistration clientRegistration, JwtDecoder jwtDecoder) + SpringBasedIdentityServiceFacade(RestOperations restOperations, ClientRegistration clientRegistration, + JwtDecoder jwtDecoder) { requireNonNull(restOperations); this.clientRegistration = requireNonNull(clientRegistration); this.jwtDecoder = requireNonNull(jwtDecoder); this.clients = Map.of( - AuthorizationGrantType.AUTHORIZATION_CODE, createAuthorizationCodeClient(restOperations), - AuthorizationGrantType.REFRESH_TOKEN, createRefreshTokenClient(restOperations), - AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations)); + AuthorizationGrantType.AUTHORIZATION_CODE, createAuthorizationCodeClient(restOperations), + AuthorizationGrantType.REFRESH_TOKEN, createRefreshTokenClient(restOperations), + AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations, clientRegistration)); } @Override @@ -112,39 +120,41 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade } @Override - public Optional getUserInfo(String tokenParameter) + public Optional getUserInfo(String tokenParameter, String principalAttribute) { 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 - { - return Optional.of(UserInfoResponse.parse(httpResponse)); - } - 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.getPreferredUsername(), userInfo.getGivenName(), userInfo.getFamilyName(), userInfo.getEmailAddress())); + .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 + { + return Optional.of(UserInfoResponse.parse(httpResponse)); + } + 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())); } @Override @@ -182,27 +192,28 @@ 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)); + TokenType.BEARER, + "JUST_FOR_FULFILLING_THE_SPRING_API", + SOME_INSIGNIFICANT_DATE_IN_THE_PAST, + SOME_INSIGNIFICANT_DATE_IN_THE_PAST.plusSeconds(1)); final OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(grant.getRefreshToken(), null); - return new OAuth2RefreshTokenGrantRequest(clientRegistration, expiredAccessToken, refreshToken, clientRegistration.getScopes()); + return new OAuth2RefreshTokenGrantRequest(clientRegistration, expiredAccessToken, refreshToken, + clientRegistration.getScopes()); } if (grant.isAuthorizationCode()) { final OAuth2AuthorizationExchange authzExchange = new OAuth2AuthorizationExchange( - OAuth2AuthorizationRequest.authorizationCode() - .clientId(clientRegistration.getClientId()) - .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) - .redirectUri(grant.getRedirectUri()) - .scopes(clientRegistration.getScopes()) - .build(), - OAuth2AuthorizationResponse.success(grant.getAuthorizationCode()) - .redirectUri(grant.getRedirectUri()) - .build() + OAuth2AuthorizationRequest.authorizationCode() + .clientId(clientRegistration.getClientId()) + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(grant.getRedirectUri()) + .scopes(clientRegistration.getScopes()) + .build(), + OAuth2AuthorizationResponse.success(grant.getAuthorizationCode()) + .redirectUri(grant.getRedirectUri()) + .build() ); return new OAuth2AuthorizationCodeGrantRequest(clientRegistration, authzExchange); } @@ -221,27 +232,52 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade return client; } - private static OAuth2AccessTokenResponseClient createAuthorizationCodeClient(RestOperations rest) + private static OAuth2AccessTokenResponseClient createAuthorizationCodeClient( + RestOperations rest) { final DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient(); client.setRestOperations(rest); return client; } - private static OAuth2AccessTokenResponseClient createRefreshTokenClient(RestOperations rest) + private static OAuth2AccessTokenResponseClient createRefreshTokenClient( + RestOperations rest) { final DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient(); client.setRestOperations(rest); return client; } - private static OAuth2AccessTokenResponseClient createPasswordClient(RestOperations rest) + private static OAuth2AccessTokenResponseClient createPasswordClient(RestOperations rest, + ClientRegistration clientRegistration) { final DefaultPasswordTokenResponseClient client = new DefaultPasswordTokenResponseClient(); client.setRestOperations(rest); + Optional.of(clientRegistration) + .map(ClientRegistration::getProviderDetails) + .map(ProviderDetails::getConfigurationMetadata) + .map(metadata -> metadata.get(AUDIENCE.getValue())) + .filter(String.class::isInstance) + .map(String.class::cast) + .ifPresent(audienceValue -> { + final OAuth2PasswordGrantRequestEntityConverter requestEntityConverter = new OAuth2PasswordGrantRequestEntityConverter(); + requestEntityConverter.addParametersConverter(audienceParameterConverter(audienceValue)); + client.setRequestEntityConverter(requestEntityConverter); + }); return client; } + private static Converter> audienceParameterConverter( + String audienceValue) + { + return (grantRequest) -> { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set("audience", audienceValue); + + return parameters; + }; + } + private static class SpringAccessTokenAuthorization implements AccessTokenAuthorization { private final OAuth2AccessTokenResponse tokenResponse; @@ -261,9 +297,9 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade public String getRefreshTokenValue() { return Optional.of(tokenResponse) - .map(OAuth2AccessTokenResponse::getRefreshToken) - .map(AbstractOAuth2Token::getTokenValue) - .orElse(null); + .map(OAuth2AccessTokenResponse::getRefreshToken) + .map(AbstractOAuth2Token::getTokenValue) + .orElse(null); } } @@ -289,7 +325,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade } } - private static class SpringDecodedAccessToken extends SpringAccessToken implements DecodedAccessToken + private static class SpringDecodedAccessToken extends SpringAccessToken implements DecodedAccessToken { private final Jwt jwt; 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 b95687e264..326597e3e9 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -26,11 +26,21 @@ package org.alfresco.repo.security.authentication.identityservice.admin; import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant.authorizationCode; +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.SCOPES_SUPPORTED; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.id.Identifier; +import com.nimbusds.oauth2.sdk.id.State; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -38,12 +48,17 @@ import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.external.AdminConsoleAuthenticator; import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; 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.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.web.util.UriComponentsBuilder; /** * An {@link AdminConsoleAuthenticator} implementation to extract an externally authenticated user ID @@ -56,7 +71,9 @@ 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; private AdminConsoleAuthenticationCookiesService cookiesService; private RemoteUserMapper remoteUserMapper; @@ -177,13 +194,56 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut private String getAuthenticationRequest(HttpServletRequest request) { - return identityServiceFacade.getClientRegistration().getProviderDetails().getAuthorizationUri() - + "?client_id=" - + identityServiceFacade.getClientRegistration().getClientId() - + "&redirect_uri=" - + request.getRequestURL() - + "&response_type=code" - + "&scope=openid"; + ClientRegistration clientRegistration = identityServiceFacade.getClientRegistration(); + State state = new State(); + + UriComponentsBuilder authRequestBuilder = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("redirect_uri", getRedirectUri(request.getRequestURL().toString())) + .queryParam("response_type", "code") + .queryParam("scope", String.join("+", getScopes(clientRegistration))) + .queryParam("state", state.toString()); + + if(StringUtils.isNotBlank(identityServiceConfig.getAudience())) + { + authRequestBuilder.queryParam("audience", identityServiceConfig.getAudience()); + } + + return authRequestBuilder.build().toUriString(); + } + + private Set getScopes(ClientRegistration clientRegistration) + { + return Optional.ofNullable(clientRegistration.getProviderDetails()) + .map(ProviderDetails::getConfigurationMetadata) + .map(metadata -> metadata.get(SCOPES_SUPPORTED.getValue())) + .filter(Scope.class::isInstance) + .map(Scope.class::cast) + .map(this::getSupportedScopes) + .orElse(clientRegistration.getScopes()); + } + + private Set getSupportedScopes(Scope scopes) + { + return scopes.stream() + .filter(scope -> SCOPES.contains(scope.getValue())) + .map(Identifier::getValue) + .collect(Collectors.toSet()); + } + + private String getRedirectUri(String requestURL) + { + try + { + URI originalUri = new URI(requestURL); + URI redirectUri = new URI(originalUri.getScheme(), originalUri.getAuthority(), identityServiceConfig.getAdminConsoleRedirectPath(), originalUri.getQuery(), originalUri.getFragment()); + return redirectUri.toASCIIString(); + } + catch (URISyntaxException e) + { + LOGGER.error("Error while trying to get the redirect URI and respond with the authentication challenge: {}", e.getMessage(), e); + throw new AuthenticationException(e.getMessage(), e); + } } private void resetCookies(HttpServletResponse response) @@ -240,6 +300,12 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut this.cookiesService = cookiesService; } + public void setIdentityServiceConfig( + IdentityServiceConfig identityServiceConfig) + { + this.identityServiceConfig = identityServiceConfig; + } + @Override public boolean isActive() { 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 7bf04b29dd..2e7a2da573 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 @@ -89,6 +89,12 @@ + + ${identity-service.issuer-url:#{null}} + + + ${identity-service.audience:#{null}} + ${identity-service.realm} @@ -140,6 +146,15 @@ ${identity-service.public-client:false} + + ${identity-service.principal-attribute:preferred_username} + + + ${identity-service.client-id.validation.disabled:true} + + + ${identity-service.admin-console.redirect-path} + @@ -176,12 +191,16 @@ + + + + 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 5f16ff8fa3..4a26aa1d94 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 @@ -11,3 +11,4 @@ identity-service.realm=alfresco identity-service.resource=alfresco identity-service.credentials.secret= identity-service.public-client=true +identity-service.admin-console.redirect-path=/alfresco/s/admin/admin-communitysummary \ No newline at end of file diff --git a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java index 8b8787db3f..61dfdd4a01 100644 --- a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java +++ b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -25,6 +25,7 @@ */ package org.alfresco; +import org.alfresco.repo.security.authentication.identityservice.ClientRegistrationProviderUnitTest; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBeanTest; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceJITProvisioningHandlerUnitTest; import org.alfresco.repo.security.authentication.identityservice.LazyInstantiatingIdentityServiceFacadeUnitTest; @@ -151,6 +152,7 @@ import org.junit.runners.Suite; AdminConsoleAuthenticationCookiesServiceUnitTest.class, AdminConsoleHttpServletRequestWrapperUnitTest.class, IdentityServiceAdminConsoleAuthenticatorUnitTest.class, + ClientRegistrationProviderUnitTest.class, org.alfresco.repo.security.authentication.CompositePasswordEncoderTest.class, org.alfresco.repo.security.authentication.PasswordHashingTest.class, org.alfresco.repo.security.authority.script.ScriptAuthorityService_RegExTest.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 new file mode 100644 index 0000000000..67f24d05ed --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java @@ -0,0 +1,266 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2024 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +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 org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.ClientRegistrationProvider; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.web.client.RestTemplate; + +public class ClientRegistrationProviderUnitTest +{ + private static final String CLIENT_ID = "alfresco"; + 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 IdentityServiceConfig config; + private RestTemplate restTemplate; + private OIDCProviderMetadata oidcResponse; + + private ArgumentCaptor requestEntityCaptor = ArgumentCaptor.forClass(RequestEntity.class); + + @Before + public void setup() throws ParseException + { + config = new IdentityServiceConfig(); + config.setAuthServerUrl(AUTH_SERVER); + config.setResource(CLIENT_ID); + + restTemplate = mock(RestTemplate.class); + ResponseEntity responseEntity = mock(ResponseEntity.class); + when(restTemplate.exchange(requestEntityCaptor.capture(), eq(String.class))).thenReturn(responseEntity); + when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK); + when(responseEntity.hasBody()).thenReturn(true); + when(responseEntity.getBody()).thenReturn(""); + + oidcResponse = spy(OIDCProviderMetadata.parse(OPENID_CONFIGURATION)); + } + + @Test + public void shouldCreateClientRegistration() + { + config.setIssuerUrl("https://login.serviceonline.alfresco/alfresco/v2.0"); + 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).isNotNull(); + assertThat(clientRegistration.getClientId()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getTokenUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getJwkSetUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isNotNull(); + assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( + AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); + } + } + + @Test + public void shouldCreateClientRegistrationWithoutIssuerConfigured() + { + 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).isNotNull(); + assertThat(clientRegistration.getClientId()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getTokenUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getJwkSetUri()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint()).isNotNull(); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()).isNotNull(); + assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( + AUTH_SERVER + DISCOVERY_PATH_SEGMENTS); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfIssuerIsNotValid() + { + config.setIssuerUrl("https://invalidissuer.alfresco"); + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfIssuerIsNull() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getIssuer()).thenReturn(null); + + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfTokenEndpointIsNull() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getTokenEndpointURI()).thenReturn(null); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfAuthorizationEndpointIsNull() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getAuthorizationEndpointURI()).thenReturn(null); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfUserInfoEndpointIsNull() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getUserInfoEndpointURI()).thenReturn(null); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldThrowIdentityServiceExceptionIfJWKSetEndpointIsNull() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getJWKSetURI()).thenReturn(null); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + assertThrows(IdentityServiceException.class, + () -> new ClientRegistrationProvider(config).createClientRegistration(restTemplate)); + } + } + + @Test + public void shouldCreateDiscoveryEndpointWithRealm() + { + config.setRealm("alfresco"); + config.setIssuerUrl("https://login.serviceonline.alfresco/alfresco/v2.0"); + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + new ClientRegistrationProvider(config).createClientRegistration(restTemplate); + assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( + AUTH_SERVER + "/realms/alfresco" + DISCOVERY_PATH_SEGMENTS); + } + } + + @Test + public void shouldSetAllSupportedScopes() + { + 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.getScopes().containsAll( + Set.of("openid", "profile", "email"))).isTrue(); + } + } + + @Test + public void shouldSetOneSupportedScope() + { + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + when(oidcResponse.getScopes()).thenReturn(new Scope("openid")); + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration( + restTemplate); + assertThat(clientRegistration.getScopes().size()).isEqualTo(1); + assertThat(clientRegistration.getScopes().stream().findFirst().get()).isEqualTo("openid"); + } + } + + @Test + public void shouldCreateDiscoveryEndpointFromIssuer() + { + config.setAuthServerUrl(null); + config.setIssuerUrl("https://login.serviceonline.alfresco/alfresco/v2.0"); + try (MockedStatic providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class)) + { + providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse); + + new ClientRegistrationProvider(config).createClientRegistration(restTemplate); + assertThat(requestEntityCaptor.getValue().getUrl().toASCIIString()).isEqualTo( + "https://login.serviceonline.alfresco/alfresco/v2.0" + DISCOVERY_PATH_SEGMENTS); + } + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBeanTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBeanTest.java index 9f543c6190..646e887119 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBeanTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBeanTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -29,10 +29,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.UUID; import com.nimbusds.openid.connect.sdk.claims.PersonClaims; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.JwtAudienceValidator; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.JwtDecoderProvider; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.JwtIssuerValidator; import org.junit.Test; @@ -45,11 +49,14 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; public class IdentityServiceFacadeFactoryBeanTest { private static final String EXPECTED_ISSUER = "expected-issuer"; + private static final String EXPECTED_AUDIENCE = "expected-audience"; + @Test public void shouldCreateJwtDecoderWithoutIDSWhenPublicKeyIsProvided() { final IdentityServiceConfig config = mock(IdentityServiceConfig.class); when(config.getRealmKey()).thenReturn("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAve3MabX/rp3LbE7/zNqKxuid8WT7y4qSXsNaiPvl/OVbNWW/cu5td1VndItYhH6/gL7Z5W/r4MOeTlz/fOdXfjrRJou2f3UiPQwLV9RdOH3oS4/BUe+sviD8Q3eRfWBWWz3yw8f2YNtD4bMztIMMjqthvwdEEb9S9jbxxD0o71Bsrz/FwPi7HhSDA+Z/p01Hct8m4wx13ZlKRd4YjyC12FBmi9MSgsrFuWzyQHhHTeBDoALpfuiut3rhVxUtFmVTpy6p9vil7C5J5pok4MXPH0dJCyDNQz05ww5+fD+tfksIEpFeokRpN226F+P21oQVFUWwYIaXaFlG/hfvwmnlfQIDAQAB"); + when(config.isClientIdValidationDisabled()).thenReturn(true); final ProviderDetails providerDetails = mock(ProviderDetails.class); when(providerDetails.getIssuerUri()).thenReturn("https://my.issuer"); @@ -108,12 +115,78 @@ public class IdentityServiceFacadeFactoryBeanTest assertThat(validationResult.getErrors()).isEmpty(); } + @Test + public void shouldFailWithNotMatchingAudienceList() + { + final JwtAudienceValidator audienceValidator = new JwtAudienceValidator(EXPECTED_AUDIENCE); + + final OAuth2TokenValidatorResult validationResult = audienceValidator.validate( + tokenWithAudience(List.of("different-audience"))); + assertThat(validationResult).isNotNull(); + assertThat(validationResult.hasErrors()).isTrue(); + assertThat(validationResult.getErrors()).hasSize(1); + + final OAuth2Error error = validationResult.getErrors().iterator().next(); + assertThat(error).isNotNull(); + assertThat(error.getDescription()).contains(EXPECTED_AUDIENCE); + } + + @Test + public void shouldFailWithNullAudience() + { + final JwtAudienceValidator audienceValidator = new JwtAudienceValidator(EXPECTED_AUDIENCE); + + final OAuth2TokenValidatorResult validationResult = audienceValidator.validate(tokenWithAudience(null)); + assertThat(validationResult).isNotNull(); + assertThat(validationResult.hasErrors()).isTrue(); + assertThat(validationResult.getErrors()).hasSize(1); + + final OAuth2Error error = validationResult.getErrors().iterator().next(); + assertThat(error).isNotNull(); + assertThat(error.getDescription()).contains(EXPECTED_AUDIENCE); + } + + @Test + public void shouldSucceedWithMatchingAudienceList() + { + final JwtAudienceValidator audienceValidator = new JwtAudienceValidator(EXPECTED_AUDIENCE); + + final OAuth2TokenValidatorResult validationResult = audienceValidator.validate( + tokenWithAudience(List.of(EXPECTED_AUDIENCE))); + assertThat(validationResult).isNotNull(); + assertThat(validationResult.hasErrors()).isFalse(); + assertThat(validationResult.getErrors()).isEmpty(); + } + + @Test + public void shouldSucceedWithMatchingSingleAudience() + { + final JwtAudienceValidator audienceValidator = new JwtAudienceValidator(EXPECTED_AUDIENCE); + + final Jwt token = Jwt.withTokenValue(UUID.randomUUID().toString()) + .claim("aud", EXPECTED_AUDIENCE) + .header("JUST", "FOR TESTING") + .build(); + final OAuth2TokenValidatorResult validationResult = audienceValidator.validate(token); + assertThat(validationResult).isNotNull(); + assertThat(validationResult.hasErrors()).isFalse(); + assertThat(validationResult.getErrors()).isEmpty(); + } + private Jwt tokenWithIssuer(String issuer) { return Jwt.withTokenValue(UUID.randomUUID().toString()) - .issuer(issuer) - .header("JUST", "FOR TESTING") - .build(); + .issuer(issuer) + .header("JUST", "FOR TESTING") + .build(); + } + + private Jwt tokenWithAudience(Collection audience) + { + return Jwt.withTokenValue(UUID.randomUUID().toString()) + .audience(audience) + .header("JUST", "FOR TESTING") + .build(); } } \ No newline at end of file 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 ffa0b633c6..7945c2036c 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -25,6 +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; @@ -32,6 +33,8 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.util.Optional; +import com.nimbusds.openid.connect.sdk.claims.PersonClaims; + import org.alfresco.model.ContentModel; import org.alfresco.repo.management.subsystems.ChildApplicationContextFactory; import org.alfresco.repo.management.subsystems.DefaultChildApplicationContextManager; @@ -57,6 +60,14 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest private IdentityServiceFacade identityServiceFacade; private IdentityServiceJITProvisioningHandler jitProvisioningHandler; + private final boolean isAuth0Enabled = Optional.ofNullable(System.getProperty("auth0.enabled")) + .map(Boolean::valueOf) + .orElse(false); + + private final String userPassword = Optional.ofNullable(System.getProperty("admin.password")) + .filter(password -> isAuth0Enabled) + .orElse("password"); + @Before public void setup() { @@ -85,7 +96,8 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertFalse(personService.personExists(IDS_USERNAME)); IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = - identityServiceFacade.authorize(IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, "password")); + identityServiceFacade.authorize( + IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, userPassword)); Optional userInfoOptional = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( accessTokenAuthorization.getAccessToken().getTokenValue()); @@ -94,13 +106,17 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertTrue(userInfoOptional.isPresent()); assertEquals(IDS_USERNAME, userInfoOptional.get().username()); - assertEquals("John", userInfoOptional.get().firstName()); - assertEquals("Doe", userInfoOptional.get().lastName()); - assertEquals("johndoe@test.com", userInfoOptional.get().email()); + assertEquals("johndoe123@alfresco.com", userInfoOptional.get().email()); assertEquals(IDS_USERNAME, nodeService.getProperty(person, ContentModel.PROP_USERNAME)); - assertEquals("John", nodeService.getProperty(person, ContentModel.PROP_FIRSTNAME)); - assertEquals("Doe", nodeService.getProperty(person, ContentModel.PROP_LASTNAME)); - assertEquals("johndoe@test.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); + assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); + + if (!isAuth0Enabled) + { + assertEquals("John", userInfoOptional.get().firstName()); + assertEquals("Doe", userInfoOptional.get().lastName()); + assertEquals("John", nodeService.getProperty(person, ContentModel.PROP_FIRSTNAME)); + assertEquals("Doe", nodeService.getProperty(person, ContentModel.PROP_LASTNAME)); + } } @Test @@ -108,13 +124,15 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest { assertFalse(personService.personExists(IDS_USERNAME)); + String principalAttribute = isAuth0Enabled ? PersonClaims.NICKNAME_CLAIM_NAME : PersonClaims.PREFERRED_USERNAME_CLAIM_NAME; IdentityServiceFacade.AccessTokenAuthorization accessTokenAuthorization = - identityServiceFacade.authorize(IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, "password")); + identityServiceFacade.authorize( + IdentityServiceFacade.AuthorizationGrant.password(IDS_USERNAME, userPassword)); String accessToken = accessTokenAuthorization.getAccessToken().getTokenValue(); IdentityServiceFacade idsServiceFacadeMock = mock(IdentityServiceFacade.class); when(idsServiceFacadeMock.decodeToken(accessToken)).thenReturn(null); - when(idsServiceFacadeMock.getUserInfo(accessToken)).thenReturn(identityServiceFacade.getUserInfo(accessToken)); + when(idsServiceFacadeMock.getUserInfo(accessToken, principalAttribute)).thenReturn(identityServiceFacade.getUserInfo(accessToken, principalAttribute)); // Replace the original facade with a mocked one to prevent user information from being extracted from the access token. Field declaredField = jitProvisioningHandler.getClass() @@ -131,15 +149,18 @@ public class IdentityServiceJITProvisioningHandlerTest extends BaseSpringTest assertTrue(userInfoOptional.isPresent()); assertEquals(IDS_USERNAME, userInfoOptional.get().username()); - assertEquals("John", userInfoOptional.get().firstName()); - assertEquals("Doe", userInfoOptional.get().lastName()); - assertEquals("johndoe@test.com", userInfoOptional.get().email()); assertEquals(IDS_USERNAME, nodeService.getProperty(person, ContentModel.PROP_USERNAME)); - assertEquals("John", nodeService.getProperty(person, ContentModel.PROP_FIRSTNAME)); - assertEquals("Doe", nodeService.getProperty(person, ContentModel.PROP_LASTNAME)); - assertEquals("johndoe@test.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); + assertEquals("johndoe123@alfresco.com", userInfoOptional.get().email()); + assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL)); verify(idsServiceFacadeMock).decodeToken(accessToken); - verify(idsServiceFacadeMock).getUserInfo(accessToken); + verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, principalAttribute); + if (!isAuth0Enabled) + { + assertEquals("John", userInfoOptional.get().firstName()); + assertEquals("Doe", userInfoOptional.get().lastName()); + assertEquals("John", nodeService.getProperty(person, ContentModel.PROP_FIRSTNAME)); + assertEquals("Doe", nodeService.getProperty(person, ContentModel.PROP_LASTNAME)); + } } @After 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 a49bbb69a5..9fbac7b39e 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -60,6 +60,9 @@ public class IdentityServiceJITProvisioningHandlerUnitTest @Mock private TransactionService transactionService; + @Mock + private IdentityServiceConfig identityServiceConfig; + @Mock private OIDCUserInfo userInfo; @@ -76,7 +79,7 @@ public class IdentityServiceJITProvisioningHandlerUnitTest when(identityServiceFacade.decodeToken(JWT_TOKEN)).thenReturn(decodedAccessToken); when(personService.createMissingPeople()).thenReturn(true); jitProvisioningHandler = new IdentityServiceJITProvisioningHandler(identityServiceFacade, - personService, transactionService); + personService, transactionService, identityServiceConfig); } @Test @@ -91,7 +94,23 @@ public class IdentityServiceJITProvisioningHandlerUnitTest assertTrue(result.isPresent()); assertEquals("johny123", result.get().username()); assertFalse(result.get().allFieldsNotEmpty()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + } + + @Test + public void shouldExtractUserInfoForExistingUserWithProviderPrincipalAttribute() + { + when(identityServiceConfig.getPrincipalAttribute()).thenReturn("nickname"); + when(personService.personExists("johny123")).thenReturn(true); + when(decodedAccessToken.getClaim("nickname")).thenReturn("johny123"); + + Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( + JWT_TOKEN); + + assertTrue(result.isPresent()); + assertEquals("johny123", result.get().username()); + assertFalse(result.get().allFieldsNotEmpty()); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, "nickname"); } @Test @@ -114,7 +133,7 @@ public class IdentityServiceJITProvisioningHandlerUnitTest assertEquals("johny123@email.com", result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN); + verify(identityServiceFacade, never()).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); } @Test @@ -128,7 +147,7 @@ public class IdentityServiceJITProvisioningHandlerUnitTest when(personService.personExists("johny123")).thenReturn(false); when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN)).thenReturn(Optional.of(userInfo)); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); @@ -140,21 +159,21 @@ public class IdentityServiceJITProvisioningHandlerUnitTest assertEquals("johny123@email.com", result.get().email()); assertTrue(result.get().allFieldsNotEmpty()); verify(personService).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); } @Test public void shouldReturnEmptyOptionalIfUsernameNotExtracted() { - when(identityServiceFacade.getUserInfo(JWT_TOKEN)).thenReturn(Optional.of(userInfo)); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); assertFalse(result.isPresent()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); } @Test @@ -165,7 +184,7 @@ public class IdentityServiceJITProvisioningHandlerUnitTest when(decodedAccessToken.getClaim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(""); when(userInfo.username()).thenReturn("johny123"); - when(identityServiceFacade.getUserInfo(JWT_TOKEN)).thenReturn(Optional.of(userInfo)); + when(identityServiceFacade.getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME)).thenReturn(Optional.of(userInfo)); Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( JWT_TOKEN); @@ -177,7 +196,31 @@ public class IdentityServiceJITProvisioningHandlerUnitTest assertEquals("", result.get().email()); assertFalse(result.get().allFieldsNotEmpty()); verify(personService, never()).createPerson(any()); - verify(identityServiceFacade).getUserInfo(JWT_TOKEN); + verify(identityServiceFacade).getUserInfo(JWT_TOKEN, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + } + + @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)); + + Optional result = jitProvisioningHandler.extractUserInfoAndCreateUserIfNeeded( + JWT_TOKEN); + + assertTrue(result.isPresent()); + assertEquals("johny123", 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"); } @Test @@ -189,8 +232,8 @@ public class IdentityServiceJITProvisioningHandlerUnitTest verify(personService, never()).createPerson(any()); verify(identityServiceFacade, never()).decodeToken(null); verify(identityServiceFacade, never()).decodeToken(""); - verify(identityServiceFacade, never()).getUserInfo(null); - verify(identityServiceFacade, never()).getUserInfo(""); + verify(identityServiceFacade, never()).getUserInfo(null, PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); + verify(identityServiceFacade, never()).getUserInfo("", PersonClaims.PREFERRED_USERNAME_CLAIM_NAME); } } 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 d35a8deb45..386ad707a5 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -94,13 +94,14 @@ public class IdentityServiceRemoteUserMapperTest extends TestCase final TransactionService transactionService = mock(TransactionService.class); final IdentityServiceFacade facade = mock(IdentityServiceFacade.class); 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(personService.getUserIdentifier(anyString())).thenAnswer(i -> i.getArgument(0, String.class)); - final IdentityServiceJITProvisioningHandler jitProvisioning = new IdentityServiceJITProvisioningHandler(facade, personService, transactionService); + final IdentityServiceJITProvisioningHandler jitProvisioning = new IdentityServiceJITProvisioningHandler(facade, personService, transactionService, identityServiceConfig); final IdentityServiceRemoteUserMapper mapper = new IdentityServiceRemoteUserMapper(); mapper.setJitProvisioningHandler(jitProvisioning); 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 9d32a2ccce..95fb58fab7 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -83,7 +83,7 @@ public class SpringBasedIdentityServiceFacadeUnitTest final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(restOperations, testRegistration(), jwtDecoder); - assertThat(facade.getUserInfo(TOKEN).isEmpty()).isTrue(); + assertThat(facade.getUserInfo(TOKEN, "preferred_username").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 155b6be779..b8466bd97e 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited + * Copyright (C) 2005 - 2024 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -36,10 +36,15 @@ import static org.mockito.MockitoAnnotations.initMocks; import java.io.IOException; import java.time.Instant; +import java.util.Arrays; +import java.util.Map; + +import com.nimbusds.oauth2.sdk.Scope; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessToken; import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; @@ -68,6 +73,8 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest @Mock IdentityServiceFacade identityServiceFacade; @Mock + IdentityServiceConfig identityServiceConfig; + @Mock AdminConsoleAuthenticationCookiesService cookiesService; @Mock RemoteUserMapper remoteUserMapper; @@ -88,9 +95,12 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest initMocks(this); ClientRegistration clientRegistration = mock(ClientRegistration.class); ProviderDetails providerDetails = mock(ProviderDetails.class); + Scope scope = Scope.parse(Arrays.asList("openid", "profile", "email", "offline_access")); + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); when(clientRegistration.getClientId()).thenReturn("alfresco"); when(providerDetails.getAuthorizationUri()).thenReturn("http://localhost:8999/auth"); + when(providerDetails.getConfigurationMetadata()).thenReturn(Map.of("scopes_supported", scope)); when(identityServiceFacade.getClientRegistration()).thenReturn(clientRegistration); when(request.getRequestURL()).thenReturn(adminConsoleURL); when(remoteUserMapper.getRemoteUser(request)).thenReturn(null); @@ -100,6 +110,7 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest authenticator.setIdentityServiceFacade(identityServiceFacade); authenticator.setCookiesService(cookiesService); authenticator.setRemoteUserMapper(remoteUserMapper); + authenticator.setIdentityServiceConfig(identityServiceConfig); } @Test @@ -142,11 +153,45 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest @Test public void shouldCallAuthChallenge() throws IOException { - String authenticationRequest = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=" + adminConsoleURL - + "&response_type=code&scope=openid"; + String redirectPath = "/alfresco/s/admin/admin-communitysummary"; + + 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=" + .formatted("http://localhost:8080", redirectPath); + authenticator.requestAuthentication(request, response); - verify(response).sendRedirect(authenticationRequest); + verify(response).sendRedirect(authenticationRequest.capture()); + assertTrue(authenticationRequest.getValue().contains(expectedUri)); + assertTrue(authenticationRequest.getValue().contains("openid")); + assertTrue(authenticationRequest.getValue().contains("profile")); + assertTrue(authenticationRequest.getValue().contains("email")); + assertTrue(authenticationRequest.getValue().contains("offline_access")); + assertTrue(authenticationRequest.getValue().contains("state")); + } + + @Test + public void shouldCallAuthChallengeWithAudience() throws IOException + { + String audience = "http://localhost:8082"; + String redirectPath = "/alfresco/s/admin/admin-communitysummary"; + when(identityServiceConfig.getAudience()).thenReturn(audience); + when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(redirectPath); + 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); + + authenticator.requestAuthentication(request, response); + + verify(response).sendRedirect(authenticationRequest.capture()); + assertTrue(authenticationRequest.getValue().contains(expectedUri)); + assertTrue(authenticationRequest.getValue().contains("openid")); + assertTrue(authenticationRequest.getValue().contains("profile")); + assertTrue(authenticationRequest.getValue().contains("email")); + assertTrue(authenticationRequest.getValue().contains("offline_access")); + assertTrue(authenticationRequest.getValue().contains("audience=%s".formatted(audience))); + assertTrue(authenticationRequest.getValue().contains("state")); } @Test diff --git a/repository/src/test/resources/realms/alfresco-realm.json b/repository/src/test/resources/realms/alfresco-realm.json index 0a11c3d706..95e6f4c34b 100644 --- a/repository/src/test/resources/realms/alfresco-realm.json +++ b/repository/src/test/resources/realms/alfresco-realm.json @@ -1889,7 +1889,7 @@ "disableableCredentialTypes": [ "password" ], - "email": "johndoe@test.com", + "email": "johndoe123@alfresco.com", "emailVerified": false, "enabled": true, "firstName": "John",