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
This commit is contained in:
Damian Ujma
2024-02-13 18:43:44 +01:00
committed by GitHub
parent de6b062f3e
commit c4714b19eb
20 changed files with 1121 additions and 221 deletions

View File

@@ -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

View File

@@ -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)
@@ -144,14 +177,6 @@ public class IdentityServiceConfig
.orElse("");
}
public String getIssuerUrl()
{
return UriComponentsBuilder.fromUriString(getAuthServerUrl())
.pathSegment(REALMS, getRealm())
.build()
.toString();
}
public void setAllowAnyHostname(boolean allowAnyHostname)
{
this.allowAnyHostname = 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;
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>.
* #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);
}
}

View File

@@ -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<OIDCUserInfo> getUserInfo(String token);
Optional<OIDCUserInfo> getUserInfo(String token, String principalAttribute);
/**
* Gets a client registration

View File

@@ -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}. <br>
* This factory can return a null if it is disabled.
*
*/
public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentityServiceFacade>
{
@@ -189,9 +201,9 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
}
@Override
public Optional<OIDCUserInfo> getUserInfo(String token)
public Optional<OIDCUserInfo> getUserInfo(String token, String principalAttribute)
{
return getTargetFacade().getUserInfo(token);
return getTargetFacade().getUserInfo(token, principalAttribute);
}
@Override
@@ -247,17 +259,21 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
// * Client is authenticating itself using basic auth
// * Resource Owner Password Credentials Flow is used to authenticate Resource Owner
final ClientHttpRequestFactory httpRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClientProvider.get());
final ClientHttpRequestFactory httpRequestFactory = new CustomClientHttpRequestFactory(
httpClientProvider.get());
final RestTemplate restTemplate = new RestTemplate(httpRequestFactory);
final ClientRegistration clientRegistration = clientRegistrationProvider.apply(restTemplate);
final JwtDecoder jwtDecoder = jwtDecoderProvider.apply(restTemplate, clientRegistration.getProviderDetails());
final JwtDecoder jwtDecoder = jwtDecoderProvider.apply(restTemplate,
clientRegistration.getProviderDetails());
return new SpringBasedIdentityServiceFacade(createOAuth2RestTemplate(httpRequestFactory), clientRegistration, jwtDecoder);
return new SpringBasedIdentityServiceFacade(createOAuth2RestTemplate(httpRequestFactory),
clientRegistration, jwtDecoder);
}
private RestTemplate createOAuth2RestTemplate(ClientHttpRequestFactory requestFactory)
{
final RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
final RestTemplate restTemplate = new RestTemplate(
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
restTemplate.setRequestFactory(requestFactory);
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
@@ -309,7 +325,8 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig);
}
private void applySSLConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder) throws Exception
private void applySSLConfiguration(PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder)
throws Exception
{
SSLContextBuilder sslContextBuilder = null;
if (config.isDisableTrustManager())
@@ -351,7 +368,7 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
connectionManagerBuilder.setSSLSocketFactory(sslConnectionSocketFactory);
}
private char[] asCharArray(String value, char[] nullValue)
private char[] asCharArray(String value, char... nullValue)
{
return ofNullable(value)
.filter(not(String::isBlank))
@@ -360,11 +377,13 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
}
}
private static class ClientRegistrationProvider
static class ClientRegistrationProvider
{
private final IdentityServiceConfig config;
private ClientRegistrationProvider(IdentityServiceConfig config)
private static final Set<String> SCOPES = Set.of("openid", "profile", "email");
ClientRegistrationProvider(IdentityServiceConfig config)
{
this.config = requireNonNull(config);
}
@@ -377,12 +396,55 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
.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)
@@ -393,7 +455,9 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
final String issuerUri = Optional.of(metadata)
.map(OIDCProviderMetadata::getIssuer)
.map(Issuer::getValue)
.orElseGet(config::getIssuerUrl);
.orElseGet(() -> (StringUtils.isNotBlank(config.getRealm()) && StringUtils.isBlank(config.getIssuerUrl())) ?
config.getAuthServerUrl() :
config.getIssuerUrl());
return ClientRegistration
.withRegistrationId("ids")
@@ -402,10 +466,25 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
.jwkSetUri(metadata.getJWKSetURI().toASCIIString())
.issuerUri(issuerUri)
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString())
.scope("openid", "profile", "email")
.scope(getSupportedScopes(metadata.getScopes()))
.providerConfigurationMetadata(createMetadata(metadata))
.authorizationGrantType(AuthorizationGrantType.PASSWORD);
}
private Map<String, Object> createMetadata(OIDCProviderMetadata metadata)
{
Map<String, Object> 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)
{
builder.clientId(config.getResource());
@@ -418,6 +497,13 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
}
private Set<String> getSupportedScopes(Scope scopes)
{
return scopes.stream().filter(scope -> SCOPES.contains(scope.getValue()))
.map(Identifier::getValue)
.collect(Collectors.toSet());
}
private Optional<OIDCProviderMetadata> extractMetadata(RestOperations rest, URI metadataUri)
{
final String response;
@@ -426,7 +512,8 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
final ResponseEntity<String> 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,7 +536,17 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
private Collection<URI> possibleMetadataURIs()
{
return List.of(UriComponentsBuilder.fromUriString(config.getIssuerUrl())
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<IdentitySer
final NimbusJwtDecoder decoder = buildJwtDecoder(rest, providerDetails);
decoder.setJwtValidator(createJwtTokenValidator(providerDetails));
decoder.setClaimSetConverter(new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters()));
decoder.setClaimSetConverter(
new ClaimTypeConverter(OidcIdTokenDecoderFactory.createDefaultClaimTypeConverters()));
return decoder;
} catch (RuntimeException e)
}
catch (RuntimeException e)
{
LOGGER.warn("Failed to create JwtDecoder.", e);
throw authorizationServerCantBeUsedException(e);
@@ -504,9 +603,10 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
{
final Optional<RemoteJWKSet<SecurityContext>> jwkSource = ofNullable(jwtProcessor)
.map(ConfigurableJWTProcessor::getJWSKeySelector)
.filter(JWSVerificationKeySelector.class::isInstance).map(o -> (JWSVerificationKeySelector<SecurityContext>)o)
.filter(JWSVerificationKeySelector.class::isInstance)
.map(o -> (JWSVerificationKeySelector<SecurityContext>) o)
.map(JWSVerificationKeySelector::getJWKSource)
.filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet<SecurityContext>)o);
.filter(RemoteJWKSet.class::isInstance).map(o -> (RemoteJWKSet<SecurityContext>) o);
if (jwkSource.isEmpty())
{
LOGGER.warn("Not able to reconfigure the JWK Cache. Unexpected JWKSource.");
@@ -527,8 +627,10 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
return;
}
final DefaultJWKSetCache cache = new DefaultJWKSetCache(config.getPublicKeyCacheTtl(), -1, TimeUnit.SECONDS);
final JWKSource<SecurityContext> cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(), resourceRetriever.get(), cache);
final DefaultJWKSetCache cache = new DefaultJWKSetCache(config.getPublicKeyCacheTtl(), -1,
TimeUnit.SECONDS);
final JWKSource<SecurityContext> cachingJWKSource = new RemoteJWKSet<>(jwkSetUrl.get(),
resourceRetriever.get(), cache);
jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(
JWSAlgorithm.parse(SIGNATURE_ALGORITHM.getName()),
@@ -537,11 +639,18 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
private OAuth2TokenValidator<Jwt> createJwtTokenValidator(ProviderDetails providerDetails)
{
return new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.of(0, ChronoUnit.MILLIS)),
new JwtIssuerValidator(providerDetails.getIssuerUri()),
new JwtClaimValidator<String>("typ", "Bearer"::equals),
new JwtClaimValidator<String>(JwtClaimNames.SUB, Objects::nonNull));
List<OAuth2TokenValidator<Jwt>> 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<String>("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,7 +684,8 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
private String requireValidJwkSetUri(ProviderDetails providerDetails)
{
final String uri = providerDetails.getJwkSetUri();
if (!isDefined(uri)) {
if (!isDefined(uri))
{
OAuth2Error oauth2Error = new OAuth2Error("missing_signature_verifier",
"Failed to find a Signature Verifier for: '"
+ providerDetails.getIssuerUri()
@@ -615,8 +725,65 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
}
static class JwtAudienceValidator implements OAuth2TokenValidator<Jwt>
{
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<String>) 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();
}
}

View File

@@ -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<IdentityServiceFacade.DecodedAccessToken, Optional<? extends OIDCUserInfo>> mapTokenToUserInfoResponse = token -> {
Optional<String> firstName = Optional.ofNullable(token.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME))
private final BiFunction<DecodedAccessToken, String, Optional<? extends OIDCUserInfo>> mapTokenToUserInfoResponse = (token, usernameMappingClaim) -> {
Optional<String> firstName = Optional.ofNullable(token)
.map(jwtToken -> jwtToken.getClaim(PersonClaims.GIVEN_NAME_CLAIM_NAME))
.filter(String.class::isInstance)
.map(String.class::cast);
Optional<String> lastName = Optional.ofNullable(token.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME))
Optional<String> lastName = Optional.ofNullable(token)
.map(jwtToken -> jwtToken.getClaim(PersonClaims.FAMILY_NAME_CLAIM_NAME))
.filter(String.class::isInstance)
.map(String.class::cast);
Optional<String> email = Optional.ofNullable(token.getClaim(PersonClaims.EMAIL_CLAIM_NAME))
Optional<String> 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<OIDCUserInfo> 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<OIDCUserInfo> 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(""),

View File

@@ -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 <http://www.gnu.org/licenses/>.
* #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;
}
}

View File

@@ -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,7 +82,8 @@ 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);
@@ -83,7 +91,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
this.clients = Map.of(
AuthorizationGrantType.AUTHORIZATION_CODE, createAuthorizationCodeClient(restOperations),
AuthorizationGrantType.REFRESH_TOKEN, createRefreshTokenClient(restOperations),
AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations));
AuthorizationGrantType.PASSWORD, createPasswordClient(restOperations, clientRegistration));
}
@Override
@@ -112,7 +120,7 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
}
@Override
public Optional<OIDCUserInfo> getUserInfo(String tokenParameter)
public Optional<OIDCUserInfo> getUserInfo(String tokenParameter, String principalAttribute)
{
return Optional.ofNullable(tokenParameter)
.filter(Predicate.not(String::isEmpty))
@@ -123,7 +131,8 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
.flatMap(uri -> {
try
{
return Optional.of(new UserInfoRequest(new URI(uri), new BearerAccessToken(token)).toHTTPRequest().send());
return Optional.of(
new UserInfoRequest(new URI(uri), new BearerAccessToken(token)).toHTTPRequest().send());
}
catch (IOException | URISyntaxException e)
{
@@ -144,7 +153,8 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
})
.map(UserInfoResponse::toSuccessResponse)
.map(UserInfoSuccessResponse::getUserInfo))
.map(userInfo -> new OIDCUserInfo(userInfo.getPreferredUsername(), userInfo.getGivenName(), userInfo.getFamilyName(), userInfo.getEmailAddress()));
.map(userInfo -> new OIDCUserInfo(userInfo.getStringClaim(principalAttribute), userInfo.getGivenName(),
userInfo.getFamilyName(), userInfo.getEmailAddress()));
}
@Override
@@ -188,7 +198,8 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
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())
@@ -221,27 +232,52 @@ class SpringBasedIdentityServiceFacade implements IdentityServiceFacade
return client;
}
private static OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> createAuthorizationCodeClient(RestOperations rest)
private static OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> createAuthorizationCodeClient(
RestOperations rest)
{
final DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
client.setRestOperations(rest);
return client;
}
private static OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> createRefreshTokenClient(RestOperations rest)
private static OAuth2AccessTokenResponseClient<OAuth2RefreshTokenGrantRequest> createRefreshTokenClient(
RestOperations rest)
{
final DefaultRefreshTokenTokenResponseClient client = new DefaultRefreshTokenTokenResponseClient();
client.setRestOperations(rest);
return client;
}
private static OAuth2AccessTokenResponseClient<OAuth2PasswordGrantRequest> createPasswordClient(RestOperations rest)
private static OAuth2AccessTokenResponseClient<OAuth2PasswordGrantRequest> 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<OAuth2PasswordGrantRequest, MultiValueMap<String, String>> audienceParameterConverter(
String audienceValue)
{
return (grantRequest) -> {
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
parameters.set("audience", audienceValue);
return parameters;
};
}
private static class SpringAccessTokenAuthorization implements AccessTokenAuthorization
{
private final OAuth2AccessTokenResponse tokenResponse;

View File

@@ -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<String> 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<String> 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<String> 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()
{

View File

@@ -89,6 +89,12 @@
</bean>
<bean name="identityServiceConfig" class="org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig">
<property name="issuerUrl">
<value>${identity-service.issuer-url:#{null}}</value>
</property>
<property name="audience">
<value>${identity-service.audience:#{null}}</value>
</property>
<property name="realm">
<value>${identity-service.realm}</value>
</property>
@@ -140,6 +146,15 @@
<property name="publicClient">
<value>${identity-service.public-client:false}</value>
</property>
<property name="principalAttribute">
<value>${identity-service.principal-attribute:preferred_username}</value>
</property>
<property name="clientIdValidationDisabled">
<value>${identity-service.client-id.validation.disabled:true}</value>
</property>
<property name="adminConsoleRedirectPath">
<value>${identity-service.admin-console.redirect-path}</value>
</property>
</bean>
<!-- Enable control over mapping between request and user ID -->
@@ -176,12 +191,16 @@
<property name="remoteUserMapper">
<ref bean="remoteUserMapper" />
</property>
<property name="identityServiceConfig">
<ref bean="identityServiceConfig" />
</property>
</bean>
<bean id="jitProvisioningHandler" class="org.alfresco.repo.security.authentication.identityservice.IdentityServiceJITProvisioningHandler">
<constructor-arg ref="PersonService"/>
<constructor-arg ref="identityServiceFacade"/>
<constructor-arg ref="transactionService"/>
<constructor-arg ref="identityServiceConfig"/>
</bean>
<bean id="authenticationDao" class="org.alfresco.repo.security.authentication.RepositoryAuthenticationDao">

View File

@@ -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

View File

@@ -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,

View File

@@ -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 <http://www.gnu.org/licenses/>.
* #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<RequestEntity> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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<OIDCProviderMetadata> 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);
}
}
}

View File

@@ -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,6 +115,64 @@ 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())
@@ -116,4 +181,12 @@ public class IdentityServiceFacadeFactoryBeanTest
.build();
}
private Jwt tokenWithAudience(Collection<String> audience)
{
return Jwt.withTokenValue(UUID.randomUUID().toString())
.audience(audience)
.header("JUST", "FOR TESTING")
.build();
}
}

View File

@@ -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<OIDCUserInfo> 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("johndoe123@alfresco.com", userInfoOptional.get().email());
assertEquals(IDS_USERNAME, nodeService.getProperty(person, ContentModel.PROP_USERNAME));
assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL));
if (!isAuth0Enabled)
{
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));
}
}
@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(IDS_USERNAME, nodeService.getProperty(person, ContentModel.PROP_USERNAME));
assertEquals("johndoe123@alfresco.com", userInfoOptional.get().email());
assertEquals("johndoe123@alfresco.com", nodeService.getProperty(person, ContentModel.PROP_EMAIL));
verify(idsServiceFacadeMock).decodeToken(accessToken);
verify(idsServiceFacadeMock, atLeast(1)).getUserInfo(accessToken, principalAttribute);
if (!isAuth0Enabled)
{
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));
verify(idsServiceFacadeMock).decodeToken(accessToken);
verify(idsServiceFacadeMock).getUserInfo(accessToken);
}
}
@After

View File

@@ -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<OIDCUserInfo> 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<OIDCUserInfo> 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<OIDCUserInfo> 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<OIDCUserInfo> 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<OIDCUserInfo> 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);
}
}

View File

@@ -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);

View File

@@ -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()

View File

@@ -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<String> 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<String> 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

View File

@@ -1889,7 +1889,7 @@
"disableableCredentialTypes": [
"password"
],
"email": "johndoe@test.com",
"email": "johndoe123@alfresco.com",
"emailVerified": false,
"enabled": true,
"firstName": "John",