From c4714b19ebeaace49c726ddb79006ffb33eef4ac Mon Sep 17 00:00:00 2001
From: Damian Ujma <92095156+damianujma@users.noreply.github.com>
Date: Tue, 13 Feb 2024 18:43:44 +0100
Subject: [PATCH] ACS-6601 Implement Repository OIDC Compliance (#2447)
* ACS-6677 Enhance OIDC Configuration Flexibility (#2426)
* ACS-6603 Implement OIDC Compliance (#2442)
* ACS-6677 Enhance OIDC Configuration Flexibility
* ACS-6677 Revert changing http header
* ACS-6677 Add unit test to suite
* ACS-6677 Rename var
* ACS-6677 Fix PMD issues
* ACS-6677 Fix PMD issues
* ACS-6677 Improve code
* ACS-6677 Fix compatibility
* ACS-6677 Add JwtAudienceValidator
* ACS-6677 Change domain
* ACS-6603 Oidc compliance
* ACS-6603 Add Auth0 test
* ACS-6603 Reformat
* ACS-6603 Enable User Info Endpoint test + Refactor
* ACS-6603 Change test condition
* ACS-6603 Add state parameter + reformat stream
* ACS-6603 Use enum type
---
.github/workflows/ci.yml | 10 +-
.../IdentityServiceConfig.java | 77 +++-
.../IdentityServiceException.java | 41 +++
.../IdentityServiceFacade.java | 6 +-
.../IdentityServiceFacadeFactoryBean.java | 343 +++++++++++++-----
...IdentityServiceJITProvisioningHandler.java | 32 +-
.../IdentityServiceMetadataKey.java | 44 +++
.../SpringBasedIdentityServiceFacade.java | 150 +++++---
...ntityServiceAdminConsoleAuthenticator.java | 82 ++++-
...dentity-service-authentication-context.xml | 19 +
...identity-service-authentication.properties | 1 +
.../java/org/alfresco/AllUnitTestsSuite.java | 4 +-
.../ClientRegistrationProviderUnitTest.java | 266 ++++++++++++++
.../IdentityServiceFacadeFactoryBeanTest.java | 81 ++++-
...tityServiceJITProvisioningHandlerTest.java | 55 ++-
...ServiceJITProvisioningHandlerUnitTest.java | 67 +++-
.../IdentityServiceRemoteUserMapperTest.java | 5 +-
...ingBasedIdentityServiceFacadeUnitTest.java | 4 +-
...viceAdminConsoleAuthenticatorUnitTest.java | 53 ++-
.../test/resources/realms/alfresco-realm.json | 2 +-
20 files changed, 1121 insertions(+), 221 deletions(-)
create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceException.java
create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceMetadataKey.java
create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/ClientRegistrationProviderUnitTest.java
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",