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 c8b4b4be1d..6dbc91e2d0 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
@@ -4,30 +4,35 @@
* %%
* 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
+ * 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 java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.web.util.UriComponentsBuilder;
/**
@@ -35,6 +40,7 @@ import org.springframework.web.util.UriComponentsBuilder;
*
* @author Gavin Cornwell
*/
+@SuppressWarnings("PMD.ExcessivePublicCount")
public class IdentityServiceConfig
{
private static final String REALMS = "realms";
@@ -62,6 +68,7 @@ public class IdentityServiceConfig
private String principalAttribute;
private boolean clientIdValidationDisabled;
private String adminConsoleRedirectPath;
+ private String signatureAlgorithms;
/**
*
@@ -306,4 +313,18 @@ public class IdentityServiceConfig
{
this.adminConsoleRedirectPath = adminConsoleRedirectPath;
}
+
+ public Set getSignatureAlgorithms()
+ {
+ return Stream.of(signatureAlgorithms.split(","))
+ .map(String::trim)
+ .map(SignatureAlgorithm::from)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ public void setSignatureAlgorithms(String signatureAlgorithms)
+ {
+ this.signatureAlgorithms = signatureAlgorithms;
+ }
}
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 54ef842e99..80dd2b9c91 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
@@ -58,10 +58,13 @@ import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.ResourceRetriever;
@@ -129,6 +132,9 @@ import org.springframework.web.util.UriComponentsBuilder;
public class IdentityServiceFacadeFactoryBean implements FactoryBean
{
private static final Log LOGGER = LogFactory.getLog(IdentityServiceFacadeFactoryBean.class);
+
+ private static final JOSEObjectType AT_JWT = new JOSEObjectType("at+jwt");
+
private boolean enabled;
private SpringBasedIdentityServiceFacadeFactory factory;
@@ -554,12 +560,20 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean signatureAlgorithms;
JwtDecoderProvider(IdentityServiceConfig config)
{
this.config = requireNonNull(config);
+ this.signatureAlgorithms = ofNullable(config.getSignatureAlgorithms())
+ .filter(not(Set::isEmpty))
+ .orElseGet(() -> {
+ LOGGER.warn("Unable to find any valid signature algorithms in the configuration. "
+ + "Using the default signature algorithm: " + DEFAULT_SIGNATURE_ALGORITHM.getName() + ".");
+ return Set.of(DEFAULT_SIGNATURE_ALGORITHM);
+ });
}
public JwtDecoder createJwtDecoder(RestOperations rest, ProviderDetails providerDetails)
@@ -587,13 +601,13 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean(
- JWSAlgorithm.parse(SIGNATURE_ALGORITHM.getName()),
+ signatureAlgorithms.stream()
+ .map(signatureAlgorithm -> JWSAlgorithm.parse(signatureAlgorithm.getName()))
+ .collect(Collectors.toSet()),
cachingJWKSource));
+ jwtProcessor.setJWSTypeVerifier(new CustomJOSEObjectTypeVerifier(JOSEObjectType.JWT, AT_JWT));
}
private OAuth2TokenValidator createJwtTokenValidator(ProviderDetails providerDetails)
@@ -759,7 +776,6 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean
+ {
+ public CustomJOSEObjectTypeVerifier(JOSEObjectType... allowedTypes)
+ {
+ super(Set.of(allowedTypes));
+ }
+
+ @Override
+ public void verify(JOSEObjectType type, SecurityContext context) throws BadJOSEException
+ {
+ super.verify(type, context);
+ }
+ }
+
private static boolean isDefined(String value)
{
return value != null && !value.isBlank();
}
-
}
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 2e7a2da573..748baf8cec 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
@@ -155,6 +155,9 @@
${identity-service.admin-console.redirect-path}
+
+ ${identity-service.signature-algorithms:RS256,PS256}
+
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 4a26aa1d94..7357b01644 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,4 +11,5 @@ 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
+identity-service.admin-console.redirect-path=/alfresco/s/admin/admin-communitysummary
+identity-service.signature-algorithms=RS256,PS256
\ No newline at end of file
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 646e887119..ef0bf2201e 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
@@ -25,53 +25,156 @@
*/
package org.alfresco.repo.security.authentication.identityservice;
+import static com.nimbusds.jose.HeaderParameterNames.KEY_ID;
+
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
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.Set;
import java.util.UUID;
+import com.nimbusds.jose.Algorithm;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
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;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.web.client.RestOperations;
public class IdentityServiceFacadeFactoryBeanTest
{
private static final String EXPECTED_ISSUER = "expected-issuer";
private static final String EXPECTED_AUDIENCE = "expected-audience";
+ public final IdentityServiceConfig config = mock(IdentityServiceConfig.class);
+ public final RestOperations restOperations = mock(RestOperations.class);
+ public final ResponseEntity responseEntity = mock(ResponseEntity.class);
+ public final ProviderDetails providerDetails = mock(ProviderDetails.class);
@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.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");
final JwtDecoderProvider provider = new JwtDecoderProvider(config);
final JwtDecoder decoder = provider.createJwtDecoder(null, providerDetails);
- final Jwt decodedToken = decoder.decode("eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjIxNDc0ODM2NDcsImp0aSI6IjEyMzQiLCJpc3MiOiJodHRwczovL215Lmlzc3VlciIsInN1YiI6ImFiYzEyMyIsInR5cCI6IkJlYXJlciIsInByZWZlcnJlZF91c2VybmFtZSI6InBpb3RyZWsifQ.k_KaOrLLh3QsT8mKphkcz2vKpulgxp92UoEDccpHJ1mxE3Pa3gFXPKTj4goUBKXieGPZRMvBDhfWNxMvRYZPiQr2NXJKapkh0bTd0qoaSWz9ICe9Nu3eg7_VA_nwUVPz_35wwmrxgVk0_kpUYQN_VtaO7ZgFE2sJzFjbkVls5aqfAMnEjEgQl837hqZvmlW2ZRWebtxXfQxAjtp0gcTg-xtAHKIINYo_1_uAtt_H9L8KqFaioxrVAEDDIlcKnb-Ks3Y62CrZauaGUJeN_aNj2gdOpdkhvCw79yJyZSGZ7okjGbidCNSAf7Bo2Y6h3dP1Gga7kRmD648ftZESrNvbyg");
+ final Jwt decodedToken = decoder.decode(
+ "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjIxNDc0ODM2NDcsImp0aSI6IjEyMzQiLCJpc3MiOiJodHRwczovL215Lmlzc3VlciIsInN1YiI6ImFiYzEyMyIsInR5cCI6IkJlYXJlciIsInByZWZlcnJlZF91c2VybmFtZSI6InBpb3RyZWsifQ.k_KaOrLLh3QsT8mKphkcz2vKpulgxp92UoEDccpHJ1mxE3Pa3gFXPKTj4goUBKXieGPZRMvBDhfWNxMvRYZPiQr2NXJKapkh0bTd0qoaSWz9ICe9Nu3eg7_VA_nwUVPz_35wwmrxgVk0_kpUYQN_VtaO7ZgFE2sJzFjbkVls5aqfAMnEjEgQl837hqZvmlW2ZRWebtxXfQxAjtp0gcTg-xtAHKIINYo_1_uAtt_H9L8KqFaioxrVAEDDIlcKnb-Ks3Y62CrZauaGUJeN_aNj2gdOpdkhvCw79yJyZSGZ7okjGbidCNSAf7Bo2Y6h3dP1Gga7kRmD648ftZESrNvbyg");
assertThat(decodedToken).isNotNull();
final Map claims = decodedToken.getClaims();
assertThat(claims).isNotNull()
- .isNotEmpty()
- .containsEntry(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME, "piotrek");
+ .isNotEmpty()
+ .containsEntry(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME, "piotrek");
+ }
+
+ @Test
+ public void shouldAcceptAndDecodeAtJwtToken() throws JOSEException
+ {
+ when(config.isClientIdValidationDisabled()).thenReturn(true);
+ when(config.getSignatureAlgorithms()).thenReturn(Set.of(SignatureAlgorithm.PS256, SignatureAlgorithm.ES512));
+ when(restOperations.exchange(any(), any(Class.class))).thenReturn(responseEntity);
+
+ final RSAKey rsaKey = getRsaKey();
+ final RSAKey rsaPublicJWK = rsaKey.toPublicJWK();
+ when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK);
+ when(responseEntity.getBody()).thenReturn(String.format("{\"keys\": [%s]}", rsaPublicJWK.toJSONString()));
+
+ final SignedJWT signedJWT = getSignedJWT(rsaKey, "at+jwt", "userA", "https://my.issuer");
+ signedJWT.sign(new RSASSASigner(rsaKey));
+
+ when(providerDetails.getIssuerUri()).thenReturn("https://my.issuer");
+ when(providerDetails.getJwkSetUri()).thenReturn("https://my.jwkSetUri");
+
+ final JwtDecoderProvider provider = new JwtDecoderProvider(config);
+
+ final JwtDecoder decoder = provider.createJwtDecoder(restOperations, providerDetails);
+ final Jwt decodedToken = decoder.decode(signedJWT.serialize());
+ assertThat(decodedToken).isNotNull();
+
+ final Map claims = decodedToken.getClaims();
+ assertThat(claims).isNotNull()
+ .isNotEmpty()
+ .containsEntry(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME, "userA");
+ }
+
+ @Test
+ public void shouldFailWithNotMatchingAlgorithm() throws JOSEException
+ {
+ when(config.isClientIdValidationDisabled()).thenReturn(true);
+ when(config.getSignatureAlgorithms()).thenReturn(Set.of(SignatureAlgorithm.RS256));
+
+ when(restOperations.exchange(any(), any(Class.class))).thenReturn(responseEntity);
+
+ final RSAKey rsaKey = getRsaKey();
+ final RSAKey rsaPublicJWK = rsaKey.toPublicJWK();
+ when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK);
+ when(responseEntity.getBody()).thenReturn(String.format("{\"keys\": [%s]}", rsaPublicJWK.toJSONString()));
+
+ final SignedJWT signedJWT = getSignedJWT(rsaKey, "at+jwt", "userA", "https://my.issuer");
+ signedJWT.sign(new RSASSASigner(rsaKey));
+
+ when(providerDetails.getIssuerUri()).thenReturn("https://my.issuer");
+ when(providerDetails.getJwkSetUri()).thenReturn("https://my.jwkSetUri");
+
+ final JwtDecoderProvider provider = new JwtDecoderProvider(config);
+
+ final JwtDecoder decoder = provider.createJwtDecoder(restOperations, providerDetails);
+ assertThrows(BadJwtException.class, () -> decoder.decode(signedJWT.serialize()));
+ }
+
+ @Test
+ public void shouldFailWithNotAllowedJOSEHeaderTyp() throws JOSEException
+ {
+ when(config.isClientIdValidationDisabled()).thenReturn(true);
+ when(config.getSignatureAlgorithms()).thenReturn(Set.of(SignatureAlgorithm.PS256));
+ when(restOperations.exchange(any(), any(Class.class))).thenReturn(responseEntity);
+
+ final RSAKey rsaKey = getRsaKey();
+ final RSAKey rsaPublicJWK = rsaKey.toPublicJWK();
+ when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK);
+ when(responseEntity.getBody()).thenReturn(String.format("{\"keys\": [%s]}", rsaPublicJWK.toJSONString()));
+
+ final SignedJWT signedJWT = getSignedJWT(rsaKey, "not-allowed-type", "userA", "https://my.issuer");
+ signedJWT.sign(new RSASSASigner(rsaKey));
+
+ when(providerDetails.getIssuerUri()).thenReturn("https://my.issuer");
+ when(providerDetails.getJwkSetUri()).thenReturn("https://my.jwkSetUri");
+
+ final JwtDecoderProvider provider = new JwtDecoderProvider(config);
+
+ final JwtDecoder decoder = provider.createJwtDecoder(restOperations, providerDetails);
+ assertThrows(BadJwtException.class, () -> decoder.decode(signedJWT.serialize()));
}
@Test
@@ -79,7 +182,8 @@ public class IdentityServiceFacadeFactoryBeanTest
{
final JwtIssuerValidator issuerValidator = new JwtIssuerValidator(EXPECTED_ISSUER);
- final OAuth2TokenValidatorResult validationResult = issuerValidator.validate(tokenWithIssuer("different-issuer"));
+ final OAuth2TokenValidatorResult validationResult = issuerValidator.validate(
+ tokenWithIssuer("different-issuer"));
assertThat(validationResult).isNotNull();
assertThat(validationResult.hasErrors()).isTrue();
assertThat(validationResult.getErrors()).hasSize(1);
@@ -164,15 +268,35 @@ public class IdentityServiceFacadeFactoryBeanTest
final JwtAudienceValidator audienceValidator = new JwtAudienceValidator(EXPECTED_AUDIENCE);
final Jwt token = Jwt.withTokenValue(UUID.randomUUID().toString())
- .claim("aud", EXPECTED_AUDIENCE)
- .header("JUST", "FOR TESTING")
- .build();
+ .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 static RSAKey getRsaKey() throws JOSEException
+ {
+ return new RSAKeyGenerator(2048)
+ .keyUse(KeyUse.SIGNATURE)
+ .algorithm(new Algorithm("PS256"))
+ .keyID(KEY_ID)
+ .generate();
+ }
+
+ private static SignedJWT getSignedJWT(RSAKey rsaKey, String type, String usernameClaim, String issuer)
+ {
+ final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .issuer(issuer)
+ .claim(PersonClaims.PREFERRED_USERNAME_CLAIM_NAME, usernameClaim)
+ .build();
+ return new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.PS256)
+ .type(new JOSEObjectType(type))
+ .keyID(rsaKey.getKeyID()).build(), claimsSet);
+ }
+
private Jwt tokenWithIssuer(String issuer)
{
return Jwt.withTokenValue(UUID.randomUUID().toString())