ACS-11311 Authentication setup for Microsoft Entra (#3989)

This commit is contained in:
Belal Ansari
2026-04-22 17:01:58 +05:30
committed by GitHub
parent 5ce63966cc
commit 643a4256a9
8 changed files with 154 additions and 20 deletions
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -78,6 +78,17 @@ public class IdentityServiceConfig
private long jwtClockSkewMs;
private String webScriptsHomeRedirectPath;
private String webScriptsHomeScopes;
private boolean scopeValidationDisabled;
public boolean isScopeValidationDisabled()
{
return scopeValidationDisabled;
}
public void setScopeValidationDisabled(boolean scopeValidationDisabled)
{
this.scopeValidationDisabled = scopeValidationDisabled;
}
public String getWebScriptsHomeRedirectPath()
{
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -510,6 +510,12 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean<IdentitySer
private Set<String> getSupportedScopes(Scope scopes)
{
if (config.isScopeValidationDisabled())
{
// Bypass scope filtering against the IDP's scopes_supported discovery metadata.
// Required for IDPs like MS Entra that do not advertise custom API scopes
return config.getPasswordGrantScopes();
}
return scopes.stream()
.filter(this::hasPasswordGrantScope)
.map(Identifier::getValue)
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -156,6 +156,11 @@ public abstract class AbstractIdentityServiceAuthenticator implements ExternalUs
private Set<String> getConfiguredScopes(ClientRegistration clientRegistration)
{
if (identityServiceConfig.isScopeValidationDisabled())
{
// Bypass filtering: send configured scopes as-is
return getConfiguredScopes();
}
return Optional.ofNullable(clientRegistration.getProviderDetails())
.map(ProviderDetails::getConfigurationMetadata)
.map(metadata -> metadata.get(SCOPES_SUPPORTED.getValue()))
@@ -189,6 +189,9 @@
<property name="webScriptsHomeRedirectPath">
<value>${identity-service.webscripts-home.redirect-path}</value>
</property>
<property name="scopeValidationDisabled">
<value>${identity-service.scope-validation.disabled:false}</value>
</property>
</bean>
<!-- Enable control over mapping between request and user ID -->
@@ -22,3 +22,11 @@ identity-service.webscripts-home.scopes=openid,profile,email,offline_access
identity-service.password-grant.scopes=openid,profile,email
identity-service.issuer-attribute=issuer
identity-service.jwt-clock-skew-ms=0
# Controls whether configured OAuth2 scopes are filtered against the IDP's scopes_supported
# from the OIDC discovery document before being included in authorization requests. (default - False)
#
# When set to 'true': Scope filtering is bypassed, all configured scopes are sent
# as-is without validation against the IDP's discovery document. This is required for
# IDPs like Microsoft Entra (Azure AD) that do not advertise custom API scopes.
identity-service.scope-validation.disabled=false
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -61,6 +61,7 @@ public class ClientRegistrationProviderUnitTest
private static final String ADMIN_CONSOLE_SCOPES = "openid,email,profile,offline_access";
private static final String PSSWD_GRANT_SCOPES = "openid,email,profile";
private static final String ISSUER_ATRR = "issuer";
private static final String CUSTOM_API_SCOPE = "api://client-id/alf_access";
private IdentityServiceConfig config;
private RestTemplate restTemplate;
@@ -302,6 +303,50 @@ public class ClientRegistrationProviderUnitTest
}
}
@Test
public void shouldBypassScopeFilteringWhenScopeValidationDisabled()
{
// Configure: scope validation disabled, custom API scope included
config.setIssuerUrl("https://login.serviceonline.alfresco/alfresco/v2.0");
config.setScopeValidationDisabled(true);
config.setPasswordGrantScopes("openid,profile,email," + CUSTOM_API_SCOPE);
try (MockedStatic<OIDCProviderMetadata> providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class))
{
// Simulate: scopes_supported does NOT include custom API scope
when(oidcResponse.getScopes()).thenReturn(new Scope("openid", "profile", "email", "offline_access"));
providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse);
ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration(
restTemplate);
// Custom API scope should be included when scope validation is disabled
assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("openid", "profile", "email", CUSTOM_API_SCOPE);
}
}
@Test
public void shouldFilterCustomApiScopeWhenScopeValidationEnabled()
{
// Configure with scope validation enabled (default behavior)
config.setIssuerUrl("https://login.serviceonline.alfresco/alfresco/v2.0");
config.setScopeValidationDisabled(false);
config.setPasswordGrantScopes("openid,profile,email," + CUSTOM_API_SCOPE);
try (MockedStatic<OIDCProviderMetadata> providerMetadata = Mockito.mockStatic(OIDCProviderMetadata.class))
{
// Simulate IDP: scopes_supported does NOT include custom API scope
when(oidcResponse.getScopes()).thenReturn(new Scope("openid", "profile", "email", "offline_access"));
providerMetadata.when(() -> OIDCProviderMetadata.parse(any(String.class))).thenReturn(oidcResponse);
ClientRegistration clientRegistration = new ClientRegistrationProvider(config).createClientRegistration(
restTemplate);
// Custom API scope should be filtered out when scope validation is enabled
assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("openid", "profile", "email");
}
}
private static JSONObject createJSONObject(String fieldName, String fieldValue)
{
JSONObject jsonObject = new JSONObject();
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 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.authentication.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -67,6 +68,8 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest
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 String REDIRECT_PATH = "/alfresco/s/admin/admin-communitysummary";
private static final String CUSTOM_API_SCOPE = "api://client-id/alf_access";
@Mock
HttpServletRequest request;
@@ -155,13 +158,11 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest
@Test
public void shouldCallAuthChallenge() throws IOException
{
String redirectPath = "/alfresco/s/admin/admin-communitysummary";
when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access"));
when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn("/alfresco/s/admin/admin-communitysummary");
when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(REDIRECT_PATH);
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);
.formatted("http://localhost:8080", REDIRECT_PATH);
authenticator.requestAuthentication(request, response);
@@ -178,13 +179,12 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest
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);
when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(REDIRECT_PATH);
when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access"));
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);
.formatted("http://localhost:8080", REDIRECT_PATH);
authenticator.requestAuthentication(request, response);
@@ -246,4 +246,32 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest
assertEquals("admin", username);
}
@Test
public void shouldReturnAllConfiguredScopesWhenScopeValidationIsDisabled() throws IOException
{
when(identityServiceConfig.isScopeValidationDisabled()).thenReturn(true);
when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "profile", CUSTOM_API_SCOPE));
when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(REDIRECT_PATH);
ArgumentCaptor<String> authenticationRequest = ArgumentCaptor.forClass(String.class);
authenticator.requestAuthentication(request, response);
verify(response).sendRedirect(authenticationRequest.capture());
assertThat(authenticationRequest.getValue()).contains("openid", "profile", CUSTOM_API_SCOPE);
}
@Test
public void shouldFilterScopesAgainstProviderWhenValidationEnabled() throws IOException
{
when(identityServiceConfig.isScopeValidationDisabled()).thenReturn(false);
when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "profile", CUSTOM_API_SCOPE));
when(identityServiceConfig.getAdminConsoleRedirectPath()).thenReturn(REDIRECT_PATH);
ArgumentCaptor<String> authenticationRequest = ArgumentCaptor.forClass(String.class);
authenticator.requestAuthentication(request, response);
verify(response).sendRedirect(authenticationRequest.capture());
assertThat(authenticationRequest.getValue()).contains("openid", "profile").doesNotContain(CUSTOM_API_SCOPE);
}
}
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2025 Alfresco Software Limited
* Copyright (C) 2005 - 2026 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.webscript;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -69,6 +70,8 @@ public class IdentityServiceWebScriptsHomeAuthenticatorUnitTest
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 String REDIRECT_PATH = "/alfresco/s/index";
private static final String CUSTOM_API_SCOPE = "api://client-id/alf_access";
@Mock
HttpServletRequest request;
@@ -157,14 +160,12 @@ public class IdentityServiceWebScriptsHomeAuthenticatorUnitTest
@Test
public void shouldCallAuthChallengeWebScriptHome() throws IOException
{
String redirectPath = "/alfresco/s/index";
when(request.getRequestURL()).thenReturn(webScriptHomeURL);
when(identityServiceConfig.getWebScriptsHomeScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access"));
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(redirectPath);
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(REDIRECT_PATH);
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);
.formatted("http://localhost:8080", REDIRECT_PATH);
authenticator.requestAuthentication(request, response);
@@ -181,14 +182,13 @@ public class IdentityServiceWebScriptsHomeAuthenticatorUnitTest
public void shouldCallAuthChallengeWebScriptHomeWithAudience() throws IOException
{
String audience = "http://localhost:8082";
String redirectPath = "/alfresco/s/index";
when(request.getRequestURL()).thenReturn(webScriptHomeURL);
when(identityServiceConfig.getAudience()).thenReturn(audience);
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(redirectPath);
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(REDIRECT_PATH);
when(identityServiceConfig.getWebScriptsHomeScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access"));
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);
.formatted("http://localhost:8080", REDIRECT_PATH);
authenticator.requestAuthentication(request, response);
@@ -250,4 +250,32 @@ public class IdentityServiceWebScriptsHomeAuthenticatorUnitTest
assertEquals("admin", username);
}
@Test
public void shouldReturnAllConfiguredScopesWhenScopeValidationIsDisabled() throws IOException
{
when(identityServiceConfig.isScopeValidationDisabled()).thenReturn(true);
when(identityServiceConfig.getWebScriptsHomeScopes()).thenReturn(Set.of("openid", "profile", CUSTOM_API_SCOPE));
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(REDIRECT_PATH);
ArgumentCaptor<String> authenticationRequest = ArgumentCaptor.forClass(String.class);
authenticator.requestAuthentication(request, response);
verify(response).sendRedirect(authenticationRequest.capture());
assertThat(authenticationRequest.getValue()).contains("openid", "profile", CUSTOM_API_SCOPE);
}
@Test
public void shouldFilterScopesAgainstProviderWhenValidationEnabled() throws IOException
{
when(identityServiceConfig.isScopeValidationDisabled()).thenReturn(false);
when(identityServiceConfig.getWebScriptsHomeScopes()).thenReturn(Set.of("openid", "profile", CUSTOM_API_SCOPE));
when(identityServiceConfig.getWebScriptsHomeRedirectPath()).thenReturn(REDIRECT_PATH);
ArgumentCaptor<String> authenticationRequest = ArgumentCaptor.forClass(String.class);
authenticator.requestAuthentication(request, response);
verify(response).sendRedirect(authenticationRequest.capture());
assertThat(authenticationRequest.getValue()).contains("openid", "profile").doesNotContain(CUSTOM_API_SCOPE);
}
}