From 4fbe8f30fc688a6c3b0713f161026704c5ab31b5 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 12:45:44 +0800 Subject: [PATCH 1/7] Create and register skeleton ms handler --- .../MicrosoftLoginHandler.java | 25 +++++++++++++++++++ .../java/teammates/ui/output/LoginMethod.java | 1 + .../teammates/ui/servlets/AuthServlet.java | 4 ++- src/web/types/api-output.ts | 1 + 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java new file mode 100644 index 000000000000..34bab888e687 --- /dev/null +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -0,0 +1,25 @@ +package teammates.ui.loginmethodhandlers; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServletRequest; + +import teammates.ui.exception.AuthException; + +/** + * Login method handler for Microsoft Entra login. + */ +public class MicrosoftLoginHandler implements LoginMethodHandler { + @Override + public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException { + // Implement Microsoft Entra login logic here + throw new UnsupportedOperationException("Microsoft Entra login is not yet implemented."); + } + + @Override + public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws IOException, AuthException { + // Implement Microsoft Entra login callback logic here + throw new UnsupportedOperationException("Microsoft Entra login callback is not yet implemented."); + } + +} diff --git a/src/main/java/teammates/ui/output/LoginMethod.java b/src/main/java/teammates/ui/output/LoginMethod.java index 10a679f7b3be..da38e703955f 100644 --- a/src/main/java/teammates/ui/output/LoginMethod.java +++ b/src/main/java/teammates/ui/output/LoginMethod.java @@ -7,6 +7,7 @@ */ public enum LoginMethod { GOOGLE("google"), + MICROSOFT("microsoft"), DEV_SERVER("devserver"); private final String method; diff --git a/src/main/java/teammates/ui/servlets/AuthServlet.java b/src/main/java/teammates/ui/servlets/AuthServlet.java index fdb0d6482827..a88bcae23c03 100644 --- a/src/main/java/teammates/ui/servlets/AuthServlet.java +++ b/src/main/java/teammates/ui/servlets/AuthServlet.java @@ -13,6 +13,7 @@ import teammates.ui.loginmethodhandlers.DevServerLoginHandler; import teammates.ui.loginmethodhandlers.GoogleLoginHandler; import teammates.ui.loginmethodhandlers.LoginMethodHandler; +import teammates.ui.loginmethodhandlers.MicrosoftLoginHandler; import teammates.ui.output.LoginMethod; /** @@ -22,7 +23,8 @@ abstract class AuthServlet extends HttpServlet { private static final Map LOGIN_HANDLERS = Map.of( LoginMethod.DEV_SERVER, new DevServerLoginHandler(), - LoginMethod.GOOGLE, new GoogleLoginHandler()); + LoginMethod.GOOGLE, new GoogleLoginHandler(), + LoginMethod.MICROSOFT, new MicrosoftLoginHandler()); Cookie getLoginInvalidationCookie() { Cookie cookie = new Cookie(Const.SecurityConfig.AUTH_COOKIE_NAME, ""); diff --git a/src/web/types/api-output.ts b/src/web/types/api-output.ts index bf018ac2f676..b2a1b882e959 100644 --- a/src/web/types/api-output.ts +++ b/src/web/types/api-output.ts @@ -852,6 +852,7 @@ export enum JoinState { export enum LoginMethod { GOOGLE = "google", + MICROSOFT = "microsoft", DEV_SERVER = "devserver", } From dc17f3fefbd1611b72ae340b395b01c718cd500a Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 15:01:20 +0800 Subject: [PATCH 2/7] Implement ms entra handler --- build.gradle | 1 + .../common/datatransfer/Provider.java | 1 + .../java/teammates/common/util/Config.java | 12 ++ .../MicrosoftLoginHandler.java | 166 ++++++++++++++++- .../teammates/ui/servlets/LoginServlet.java | 1 + .../ui/servlets/OAuth2CallbackServlet.java | 15 ++ .../resources/build-dev.template.properties | 2 +- src/main/resources/build.template.properties | 9 +- .../MicrosoftLoginHandlerTest.java | 175 ++++++++++++++++++ 9 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java diff --git a/build.gradle b/build.gradle index 54b2cb2a5e5d..c211943943a6 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,7 @@ dependencies { staticAnalysis("org.jacoco:org.jacoco.build:${jacocoVersion}") implementation("com.google.api-client:google-api-client:2.9.0") + implementation("com.microsoft.azure:msal4j:1.25.0") implementation(platform("com.google.cloud:google-cloud-bom:0.256.0")) implementation("com.google.cloud:google-cloud-tasks") implementation("com.google.cloud:google-cloud-logging") diff --git a/src/main/java/teammates/common/datatransfer/Provider.java b/src/main/java/teammates/common/datatransfer/Provider.java index 63f760994830..10c8e22b44ea 100644 --- a/src/main/java/teammates/common/datatransfer/Provider.java +++ b/src/main/java/teammates/common/datatransfer/Provider.java @@ -6,4 +6,5 @@ public enum Provider { TEAMMATES_DEV, GOOGLE, + MICROSOFT, } diff --git a/src/main/java/teammates/common/util/Config.java b/src/main/java/teammates/common/util/Config.java index 353c9b269acf..3bf7a5a97700 100644 --- a/src/main/java/teammates/common/util/Config.java +++ b/src/main/java/teammates/common/util/Config.java @@ -90,6 +90,15 @@ public final class Config { /** Value of {@code app.oidc.google.client.secret}. */ public static final String OIDC_GOOGLE_CLIENT_SECRET; + /** Value of {@code app.oidc.microsoft.client.id}. */ + public static final String OIDC_MICROSOFT_CLIENT_ID; + + /** Value of {@code app.oidc.microsoft.client.secret}. */ + public static final String OIDC_MICROSOFT_CLIENT_SECRET; + + /** Value of {@code app.oidc.microsoft.tenant.id}. */ + public static final String OIDC_MICROSOFT_TENANT_ID; + /** Value of {@code app.captcha.secretkey}. */ public static final String CAPTCHA_SECRET_KEY; @@ -209,6 +218,9 @@ public final class Config { LOGIN_METHODS = getLoginMethods(getProperty(properties, devProperties, "app.login.methods").split(",")); OIDC_GOOGLE_CLIENT_ID = getProperty(properties, devProperties, "app.oidc.google.client.id"); OIDC_GOOGLE_CLIENT_SECRET = getProperty(properties, devProperties, "app.oidc.google.client.secret"); + OIDC_MICROSOFT_CLIENT_ID = getProperty(properties, devProperties, "app.oidc.microsoft.client.id"); + OIDC_MICROSOFT_CLIENT_SECRET = getProperty(properties, devProperties, "app.oidc.microsoft.client.secret"); + OIDC_MICROSOFT_TENANT_ID = getProperty(properties, devProperties, "app.oidc.microsoft.tenant.id", "common"); CAPTCHA_SECRET_KEY = getProperty(properties, devProperties, "app.captcha.secretkey"); RECAPTCHA_SERVICE = getProperty(properties, devProperties, "app.recaptcha.service", IS_DEV_SERVER ? "local" : "google"); diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index 34bab888e687..de5902bf68b4 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -1,25 +1,183 @@ package teammates.ui.loginmethodhandlers; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; import jakarta.servlet.http.HttpServletRequest; +import org.apache.http.HttpStatus; + +import com.microsoft.aad.msal4j.AuthorizationCodeParameters; +import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters; +import com.microsoft.aad.msal4j.ClientCredentialFactory; +import com.microsoft.aad.msal4j.ConfidentialClientApplication; +import com.microsoft.aad.msal4j.IAuthenticationResult; + +import teammates.common.datatransfer.Provider; +import teammates.common.util.Config; +import teammates.common.util.JsonUtils; +import teammates.common.util.Logger; +import teammates.common.util.StringHelper; import teammates.ui.exception.AuthException; +import teammates.ui.output.LoginMethod; + +import tools.jackson.core.type.TypeReference; /** * Login method handler for Microsoft Entra login. */ public class MicrosoftLoginHandler implements LoginMethodHandler { + + private static final Logger log = Logger.getLogger(); + + private static final Set SCOPES = Set.of("openid", "email"); + private static final String AUTHORITY = "https://login.microsoftonline.com/" + Config.OIDC_MICROSOFT_TENANT_ID; + private static final Set MULTI_TENANT_AUTHORITIES = Set.of("common", "organizations"); + @Override public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException { - // Implement Microsoft Entra login logic here - throw new UnsupportedOperationException("Microsoft Entra login is not yet implemented."); + AuthState state = new AuthState(nextUrl, req.getSession().getId(), LoginMethod.MICROSOFT); + AuthorizationRequestUrlParameters parameters = AuthorizationRequestUrlParameters + .builder(getRedirectUri(req), SCOPES) + .state(StringHelper.encrypt(JsonUtils.toCompactJson(state))) + .build(); + + log.request(req, HttpStatus.SC_MOVED_TEMPORARILY, "Redirect to Microsoft Entra sign-in page"); + + return getClientApplication().getAuthorizationRequestUrl(parameters).toString(); } @Override public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws IOException, AuthException { - // Implement Microsoft Entra login callback logic here - throw new UnsupportedOperationException("Microsoft Entra login callback is not yet implemented."); + String error = req.getParameter("error"); + if (error != null) { + throw new AuthException("Error in Microsoft Entra OAuth2 callback: " + error); + } + + String code = req.getParameter("code"); + if (code == null) { + throw new AuthException("Missing authorization code"); + } + + String sessionId = state.sessionId(); + if (!sessionId.equals(req.getSession().getId())) { + String message = String.format("Different session ID: expected %s, got %s", + sessionId, req.getSession().getId()); + throw new AuthException(message); + } + + IAuthenticationResult token = requestToken(code, getRedirectUri(req)); + Map claims = parseIdTokenClaims(token.idToken()); + + String tenantId = getRequiredClaim(claims, "tid"); + if (!isExpectedTenantId(tenantId)) { + throw new AuthException("Invalid tenant ID: expected " + Config.OIDC_MICROSOFT_TENANT_ID + + ", got " + tenantId); + } + + String subject = getRequiredClaim(claims, "sub"); + String email = getRequiredClaim(claims, "email", "preferred_username"); + + return new AuthResult(Provider.MICROSOFT, subject, tenantId, email); + } + + IAuthenticationResult requestToken(String code, String redirectUri) throws AuthException { + try { + AuthorizationCodeParameters parameters = AuthorizationCodeParameters + .builder(code, new URI(redirectUri)) + .scopes(SCOPES) + .build(); + return getClientApplication().acquireToken(parameters).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AuthException("Interrupted while requesting Microsoft Entra token", e); + } catch (ExecutionException | URISyntaxException e) { + throw new AuthException("Failed to request Microsoft Entra token", e); + } + } + + private Map parseIdTokenClaims(String rawIdToken) throws AuthException { + if (StringHelper.isEmpty(rawIdToken)) { + throw new AuthException("Missing ID token"); + } + + String[] tokenParts = rawIdToken.split("\\."); + if (tokenParts.length < 2) { + throw new AuthException("Invalid ID token"); + } + try { + String claimsJson = new String(Base64.getUrlDecoder().decode(tokenParts[1]), StandardCharsets.UTF_8); + return JsonUtils.fromJson(claimsJson, new TypeReference>() {}); + } catch (IllegalArgumentException e) { + throw new AuthException("Invalid ID token claims", e); + } + } + + private String getRequiredClaim(Map claims, String claimName) throws AuthException { + return getRequiredClaim(claims, claimName, new String[0]); + } + + private String getRequiredClaim(Map claims, String claimName, String... fallbackClaimNames) + throws AuthException { + Object rawValue = claims.get(claimName); + String value = rawValue instanceof String ? (String) rawValue : null; + if (!StringHelper.isEmpty(value)) { + return value; + } + + for (String fallbackClaimName : fallbackClaimNames) { + rawValue = claims.get(fallbackClaimName); + value = rawValue instanceof String ? (String) rawValue : null; + if (!StringHelper.isEmpty(value)) { + return value; + } + } + + throw new AuthException("Missing " + claimName + " claim"); + } + + boolean isExpectedTenantId(String tenantId) { + if (StringHelper.isEmpty(tenantId)) { + return false; + } + return MULTI_TENANT_AUTHORITIES.contains(Config.OIDC_MICROSOFT_TENANT_ID) + || Config.OIDC_MICROSOFT_TENANT_ID.equals(tenantId); + } + + private ConfidentialClientApplication getClientApplication() throws AuthException { + try { + return ConfidentialClientApplication + .builder(Config.OIDC_MICROSOFT_CLIENT_ID, + ClientCredentialFactory.createFromSecret(Config.OIDC_MICROSOFT_CLIENT_SECRET)) + .authority(AUTHORITY) + .build(); + } catch (Exception e) { + throw new AuthException("Failed to create Microsoft Entra client application", e); + } + } + + /** + * Returns the redirect URI for the given HTTP servlet request. + */ + private String getRedirectUri(HttpServletRequest req) throws AuthException { + String requestUrl = req.getRequestURL().toString(); + if (Config.isDevServerLoginEnabled()) { + requestUrl = requestUrl.replaceFirst("^https://", "http://"); + } else { + requestUrl = requestUrl.replaceFirst("^http://", "https://"); + } + try { + URI uri = new URI(requestUrl); + return new URI(uri.getScheme(), uri.getAuthority(), "/oauth2callback", null, null).toString(); + } catch (URISyntaxException e) { + throw new AuthException("Invalid Microsoft Entra redirect URI", e); + } } } diff --git a/src/main/java/teammates/ui/servlets/LoginServlet.java b/src/main/java/teammates/ui/servlets/LoginServlet.java index 5c694b43fc88..339684ee296f 100644 --- a/src/main/java/teammates/ui/servlets/LoginServlet.java +++ b/src/main/java/teammates/ui/servlets/LoginServlet.java @@ -65,6 +65,7 @@ public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOExc redirectUrl = resp.encodeRedirectURL(redirectUrl); resp.sendRedirect(redirectUrl); } catch (Exception e) { + log.severe("Failed to handle login request", e); resp.sendError(HttpStatus.SC_INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/teammates/ui/servlets/OAuth2CallbackServlet.java b/src/main/java/teammates/ui/servlets/OAuth2CallbackServlet.java index bd5acb867a1e..0bebefb51335 100644 --- a/src/main/java/teammates/ui/servlets/OAuth2CallbackServlet.java +++ b/src/main/java/teammates/ui/servlets/OAuth2CallbackServlet.java @@ -42,10 +42,23 @@ public OAuth2CallbackServlet() { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + handleCallback(req, resp); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + handleCallback(req, resp); + } + + /** + * Handles the main callback logic. + */ + private void handleCallback(HttpServletRequest req, HttpServletResponse resp) throws IOException { AuthState state; try { state = getValidAuthStateFromCallback(req); } catch (AuthException e) { + log.warning("Invalid state parameter in OAuth2 callback", e); rejectLogin(req, resp, HttpStatus.SC_BAD_REQUEST); return; } @@ -63,9 +76,11 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO AuthResult authResult = loginHandler.handleCallback(req, state); cookie = getLoginCookie(authResult); } catch (AuthException e) { + log.warning("Failed to handle OAuth2 callback", e); rejectLogin(req, resp, HttpStatus.SC_BAD_REQUEST); return; } catch (Exception e) { + log.severe("Unexpected error during OAuth2 callback", e); rejectLogin(req, resp, HttpStatus.SC_INTERNAL_SERVER_ERROR); return; } diff --git a/src/main/resources/build-dev.template.properties b/src/main/resources/build-dev.template.properties index 78bc4c6f083f..832527c16471 100644 --- a/src/main/resources/build-dev.template.properties +++ b/src/main/resources/build-dev.template.properties @@ -19,7 +19,7 @@ app.postgres.username=teammates app.postgres.password=teammates # This is the list of login methods allowed for users to log in to the system. -# Acceptable values are google, devserver (Only for development environment) +# Acceptable values are google, microsoft, devserver (Only for development environment) # Separate with commas for multiple values with no leading/trailing spaces. app.login.methods=devserver diff --git a/src/main/resources/build.template.properties b/src/main/resources/build.template.properties index 197dc14bd8a6..3e666d8befb3 100644 --- a/src/main/resources/build.template.properties +++ b/src/main/resources/build.template.properties @@ -71,7 +71,7 @@ app.encryption.key=5360b12f6a07af7be93437d215f72fca1234567890ABCDEF1234567890ABC app.hmac.key=47e3fb2793b364f008c389d6b59e8a29108c911f2a9d224173b3d14108458940 # This is the list of login methods allowed for users to log in to the system. -# Acceptable values are google, devserver (Only for development environment) +# Acceptable values are google, microsoft, devserver (Only for development environment) # Separate with commas for multiple values with no leading/trailing spaces. app.login.methods= @@ -82,6 +82,13 @@ app.login.methods= app.oidc.google.client.id= app.oidc.google.client.secret= +# This is only relevant if Microsoft Entra is used as an OIDC provider. +# Use "common" for multi-tenant apps, "organizations" for work/school accounts, +# or a specific tenant ID for single-tenant apps. +app.oidc.microsoft.client.id= +app.oidc.microsoft.client.secret= +app.oidc.microsoft.tenant.id=common + # This is the secret key for communication between the site and reCAPTCHA. # You can get a pair of keys from the Google reCAPTCHA website. # e.g. "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" is a secret key for test environments. diff --git a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java new file mode 100644 index 000000000000..12530df30e57 --- /dev/null +++ b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java @@ -0,0 +1,175 @@ +package teammates.ui.loginmethodhandlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.apache.http.client.methods.HttpGet; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.api.client.http.GenericUrl; +import com.microsoft.aad.msal4j.IAuthenticationResult; + +import teammates.common.datatransfer.Provider; +import teammates.common.util.Config; +import teammates.common.util.JsonUtils; +import teammates.common.util.StringHelper; +import teammates.test.BaseTestCase; +import teammates.test.MockHttpServletRequest; +import teammates.ui.exception.AuthException; +import teammates.ui.output.LoginMethod; + +/** + * SUT: {@link MicrosoftLoginHandler}. + */ +public class MicrosoftLoginHandlerTest extends BaseTestCase { + + private static final String LOGIN_URL = "http://localhost:8080/login"; + private static final String OAUTH_CALLBACK_URL = "http://localhost:8080/oauth2callback"; + + private MicrosoftLoginHandler microsoftLoginHandler; + + @BeforeMethod + public void setUpMethod() { + microsoftLoginHandler = new MicrosoftLoginHandler(); + } + + @Test + public void handleLogin_validRequest_returnsValidRedirectUrl() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + + String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); + + GenericUrl url = new GenericUrl(loginUrl); + assertEquals("https", url.getScheme()); + assertEquals("login.microsoftonline.com", url.getHost()); + assertEquals("/" + Config.OIDC_MICROSOFT_TENANT_ID + "/oauth2/v2.0/authorize", url.getRawPath()); + } + + @Test + public void handleLogin_validRequest_returnsRedirectUrlWithValidClientId() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + + String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); + + GenericUrl url = new GenericUrl(loginUrl); + assertEquals(Config.OIDC_MICROSOFT_CLIENT_ID, getQueryParam(url, "client_id")); + } + + @Test + public void handleLogin_validRequest_returnsRedirectUrlWithValidState() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + + String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); + + GenericUrl url = new GenericUrl(loginUrl); + String encryptedState = getQueryParam(url, "state"); + AuthState state = JsonUtils.fromJson(StringHelper.decrypt(encryptedState), AuthState.class); + assertEquals("/web/instructor/home", state.nextUrl()); + assertEquals("1234", state.sessionId()); + assertEquals(LoginMethod.MICROSOFT, state.loginMethod()); + } + + @Test + public void handleCallback_validResponse_returnsValidAuthResult() throws Exception { + MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler()); + IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); + doReturn(createIdToken()).when(tokenResponse).idToken(); + doReturn(tokenResponse).when(loginHandler).requestToken(eq("authorization-code"), eq(OAUTH_CALLBACK_URL)); + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + req.addParam("code", "authorization-code"); + AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); + + AuthResult result = loginHandler.handleCallback(req, state); + + assertEquals(Provider.MICROSOFT, result.provider()); + assertEquals("microsoft-subject", result.subject()); + assertEquals("actual-tenant-id", result.tenantId()); + assertEquals("user@example.com", result.email()); + } + + @Test + public void handleCallback_missingEmailClaim_usesPreferredUsername() throws Exception { + MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler()); + IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); + doReturn(createIdToken(Map.of( + "sub", "microsoft-subject", + "tid", "actual-tenant-id", + "preferred_username", "user@example.com"))).when(tokenResponse).idToken(); + doReturn(tokenResponse).when(loginHandler).requestToken(eq("authorization-code"), eq(OAUTH_CALLBACK_URL)); + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + req.addParam("code", "authorization-code"); + AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); + + AuthResult result = loginHandler.handleCallback(req, state); + + assertEquals("user@example.com", result.email()); + } + + @Test + public void handleCallback_errorResponse_throwsInvalidAuthStateException() { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + req.addParam("error", "access_denied"); + AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); + + assertThrows(AuthException.class, + () -> microsoftLoginHandler.handleCallback(req, state)); + } + + @Test + public void handleCallback_differentSessionId_throwsInvalidAuthStateException() { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + req.addParam("code", "authorization-code"); + AuthState state = new AuthState("/", "different-session-id", LoginMethod.MICROSOFT); + + assertThrows(AuthException.class, + () -> microsoftLoginHandler.handleCallback(req, state)); + } + + @Test + public void isExpectedTenantId_multiTenantAuthority_returnsTrue() { + assertTrue(microsoftLoginHandler.isExpectedTenantId("actual-tenant-id")); + } + + @Test + public void isExpectedTenantId_blankTenantId_returnsFalse() { + assertFalse(microsoftLoginHandler.isExpectedTenantId("")); + } + + private static String getQueryParam(GenericUrl url, String name) { + Object value = url.get(name); + if (value instanceof List) { + return (String) ((List) value).get(0); + } + return (String) value; + } + + private static String createIdToken() { + return createIdToken("actual-tenant-id"); + } + + private static String createIdToken(String tenantId) { + return createIdToken(Map.of( + "sub", "microsoft-subject", + "tid", tenantId, + "email", "user@example.com")); + } + + private static String createIdToken(Map claimsMap) { + String claims = JsonUtils.toCompactJson(claimsMap); + String encodedClaims = Base64.getUrlEncoder().withoutPadding() + .encodeToString(claims.getBytes(StandardCharsets.UTF_8)); + return "header." + encodedClaims + ".signature"; + } +} From ed1264b2c26246045319aed05d0f1292de3fdd3d Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 15:50:54 +0800 Subject: [PATCH 3/7] Add fallback email claim --- .../MicrosoftLoginHandler.java | 32 +++++++++++------ .../MicrosoftLoginHandlerTest.java | 36 +++++++++++++------ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index de5902bf68b4..263c55a789aa 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -36,10 +36,19 @@ public class MicrosoftLoginHandler implements LoginMethodHandler { private static final Logger log = Logger.getLogger(); - private static final Set SCOPES = Set.of("openid", "email"); - private static final String AUTHORITY = "https://login.microsoftonline.com/" + Config.OIDC_MICROSOFT_TENANT_ID; + private static final Set SCOPES = Set.of("openid", "email", "profile"); private static final Set MULTI_TENANT_AUTHORITIES = Set.of("common", "organizations"); + private final String expectedTenantId; + + public MicrosoftLoginHandler() { + this(Config.OIDC_MICROSOFT_TENANT_ID); + } + + MicrosoftLoginHandler(String expectedTenantId) { + this.expectedTenantId = expectedTenantId; + } + @Override public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException { AuthState state = new AuthState(nextUrl, req.getSession().getId(), LoginMethod.MICROSOFT); @@ -77,11 +86,12 @@ public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws String tenantId = getRequiredClaim(claims, "tid"); if (!isExpectedTenantId(tenantId)) { - throw new AuthException("Invalid tenant ID: expected " + Config.OIDC_MICROSOFT_TENANT_ID + throw new AuthException("Invalid tenant ID: expected " + expectedTenantId + ", got " + tenantId); } String subject = getRequiredClaim(claims, "sub"); + // Add a fallback as email claim may not be present in the ID token. String email = getRequiredClaim(claims, "email", "preferred_username"); return new AuthResult(Provider.MICROSOFT, subject, tenantId, email); @@ -113,17 +123,17 @@ private Map parseIdTokenClaims(String rawIdToken) throws AuthExc } try { String claimsJson = new String(Base64.getUrlDecoder().decode(tokenParts[1]), StandardCharsets.UTF_8); - return JsonUtils.fromJson(claimsJson, new TypeReference>() {}); + return JsonUtils.fromJson(claimsJson, new TypeReference<>() {}); } catch (IllegalArgumentException e) { throw new AuthException("Invalid ID token claims", e); } } private String getRequiredClaim(Map claims, String claimName) throws AuthException { - return getRequiredClaim(claims, claimName, new String[0]); + return getRequiredClaim(claims, claimName, null); } - private String getRequiredClaim(Map claims, String claimName, String... fallbackClaimNames) + private String getRequiredClaim(Map claims, String claimName, String fallbackClaimName) throws AuthException { Object rawValue = claims.get(claimName); String value = rawValue instanceof String ? (String) rawValue : null; @@ -131,7 +141,7 @@ private String getRequiredClaim(Map claims, String claimName, St return value; } - for (String fallbackClaimName : fallbackClaimNames) { + if (!StringHelper.isEmpty(fallbackClaimName)) { rawValue = claims.get(fallbackClaimName); value = rawValue instanceof String ? (String) rawValue : null; if (!StringHelper.isEmpty(value)) { @@ -142,12 +152,12 @@ private String getRequiredClaim(Map claims, String claimName, St throw new AuthException("Missing " + claimName + " claim"); } - boolean isExpectedTenantId(String tenantId) { + private boolean isExpectedTenantId(String tenantId) { if (StringHelper.isEmpty(tenantId)) { return false; } - return MULTI_TENANT_AUTHORITIES.contains(Config.OIDC_MICROSOFT_TENANT_ID) - || Config.OIDC_MICROSOFT_TENANT_ID.equals(tenantId); + return MULTI_TENANT_AUTHORITIES.contains(expectedTenantId) + || expectedTenantId.equals(tenantId); } private ConfidentialClientApplication getClientApplication() throws AuthException { @@ -155,7 +165,7 @@ private ConfidentialClientApplication getClientApplication() throws AuthExceptio return ConfidentialClientApplication .builder(Config.OIDC_MICROSOFT_CLIENT_ID, ClientCredentialFactory.createFromSecret(Config.OIDC_MICROSOFT_CLIENT_SECRET)) - .authority(AUTHORITY) + .authority("https://login.microsoftonline.com/" + expectedTenantId) .build(); } catch (Exception e) { throw new AuthException("Failed to create Microsoft Entra client application", e); diff --git a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java index 12530df30e57..aec27e1469d9 100644 --- a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java +++ b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java @@ -1,9 +1,7 @@ package teammates.ui.loginmethodhandlers; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -83,7 +81,7 @@ public void handleLogin_validRequest_returnsRedirectUrlWithValidState() throws E @Test public void handleCallback_validResponse_returnsValidAuthResult() throws Exception { - MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler()); + MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler("actual-tenant-id")); IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); doReturn(createIdToken()).when(tokenResponse).idToken(); doReturn(tokenResponse).when(loginHandler).requestToken(eq("authorization-code"), eq(OAUTH_CALLBACK_URL)); @@ -100,8 +98,8 @@ public void handleCallback_validResponse_returnsValidAuthResult() throws Excepti } @Test - public void handleCallback_missingEmailClaim_usesPreferredUsername() throws Exception { - MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler()); + public void handleCallback_validResponseWithFallbackEmailClaim_returnsValidAuthResult() throws Exception { + MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler("actual-tenant-id")); IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); doReturn(createIdToken(Map.of( "sub", "microsoft-subject", @@ -114,6 +112,9 @@ public void handleCallback_missingEmailClaim_usesPreferredUsername() throws Exce AuthResult result = loginHandler.handleCallback(req, state); + assertEquals(Provider.MICROSOFT, result.provider()); + assertEquals("microsoft-subject", result.subject()); + assertEquals("actual-tenant-id", result.tenantId()); assertEquals("user@example.com", result.email()); } @@ -127,6 +128,15 @@ public void handleCallback_errorResponse_throwsInvalidAuthStateException() { () -> microsoftLoginHandler.handleCallback(req, state)); } + @Test + public void handleCallback_missingCode_throwsInvalidAuthStateException() { + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); + + assertThrows(AuthException.class, + () -> microsoftLoginHandler.handleCallback(req, state)); + } + @Test public void handleCallback_differentSessionId_throwsInvalidAuthStateException() { MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); @@ -138,13 +148,17 @@ public void handleCallback_differentSessionId_throwsInvalidAuthStateException() } @Test - public void isExpectedTenantId_multiTenantAuthority_returnsTrue() { - assertTrue(microsoftLoginHandler.isExpectedTenantId("actual-tenant-id")); - } + public void handleCallback_invalidTenantId_throwsInvalidAuthStateException() throws Exception { + MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler("actual-tenant-id")); + IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); + doReturn(createIdToken("invalid-tenant-id")).when(tokenResponse).idToken(); + doReturn(tokenResponse).when(loginHandler).requestToken(eq("authorization-code"), eq(OAUTH_CALLBACK_URL)); + MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); + req.addParam("code", "authorization-code"); + AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); - @Test - public void isExpectedTenantId_blankTenantId_returnsFalse() { - assertFalse(microsoftLoginHandler.isExpectedTenantId("")); + assertThrows(AuthException.class, + () -> loginHandler.handleCallback(req, state)); } private static String getQueryParam(GenericUrl url, String name) { From 26d8722e1325f7d798eab62f9c8ee6b84529b323 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 16:01:19 +0800 Subject: [PATCH 4/7] Throw exception for missing email claim --- .../MicrosoftLoginHandler.java | 29 +++++++------------ .../MicrosoftLoginHandlerTest.java | 13 +++------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index 263c55a789aa..c5d9204f26b0 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -90,9 +90,14 @@ public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws + ", got " + tenantId); } + String email = getRequiredClaim(claims, "email"); + if (StringHelper.isEmpty(email)) { + // Require a valid email. + // Microsoft Entra ID tokens may not always contain the email claim. + throw new AuthException("Missing email claim in Microsoft Entra ID token"); + } + String subject = getRequiredClaim(claims, "sub"); - // Add a fallback as email claim may not be present in the ID token. - String email = getRequiredClaim(claims, "email", "preferred_username"); return new AuthResult(Provider.MICROSOFT, subject, tenantId, email); } @@ -130,26 +135,12 @@ private Map parseIdTokenClaims(String rawIdToken) throws AuthExc } private String getRequiredClaim(Map claims, String claimName) throws AuthException { - return getRequiredClaim(claims, claimName, null); - } - - private String getRequiredClaim(Map claims, String claimName, String fallbackClaimName) - throws AuthException { Object rawValue = claims.get(claimName); String value = rawValue instanceof String ? (String) rawValue : null; - if (!StringHelper.isEmpty(value)) { - return value; + if (StringHelper.isEmpty(value)) { + throw new AuthException("Missing " + claimName + " claim"); } - - if (!StringHelper.isEmpty(fallbackClaimName)) { - rawValue = claims.get(fallbackClaimName); - value = rawValue instanceof String ? (String) rawValue : null; - if (!StringHelper.isEmpty(value)) { - return value; - } - } - - throw new AuthException("Missing " + claimName + " claim"); + return value; } private boolean isExpectedTenantId(String tenantId) { diff --git a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java index aec27e1469d9..f1eb7798aaa1 100644 --- a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java +++ b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java @@ -98,24 +98,19 @@ public void handleCallback_validResponse_returnsValidAuthResult() throws Excepti } @Test - public void handleCallback_validResponseWithFallbackEmailClaim_returnsValidAuthResult() throws Exception { + public void handleCallback_missingEmailClaim_throwsAuthException() throws Exception { MicrosoftLoginHandler loginHandler = spy(new MicrosoftLoginHandler("actual-tenant-id")); IAuthenticationResult tokenResponse = mock(IAuthenticationResult.class); doReturn(createIdToken(Map.of( "sub", "microsoft-subject", - "tid", "actual-tenant-id", - "preferred_username", "user@example.com"))).when(tokenResponse).idToken(); + "tid", "actual-tenant-id"))).when(tokenResponse).idToken(); doReturn(tokenResponse).when(loginHandler).requestToken(eq("authorization-code"), eq(OAUTH_CALLBACK_URL)); MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, OAUTH_CALLBACK_URL); req.addParam("code", "authorization-code"); AuthState state = new AuthState("/", "1234", LoginMethod.MICROSOFT); - AuthResult result = loginHandler.handleCallback(req, state); - - assertEquals(Provider.MICROSOFT, result.provider()); - assertEquals("microsoft-subject", result.subject()); - assertEquals("actual-tenant-id", result.tenantId()); - assertEquals("user@example.com", result.email()); + assertThrows(AuthException.class, + () -> loginHandler.handleCallback(req, state)); } @Test From 3dbf79d8739de024b06d00d31fd923e3845d6b4c Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 16:16:52 +0800 Subject: [PATCH 5/7] Mock methods --- .../MicrosoftLoginHandler.java | 16 ++++++++++++---- .../MicrosoftLoginHandlerTest.java | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index c5d9204f26b0..4595d35f73f8 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -52,13 +52,21 @@ public MicrosoftLoginHandler() { @Override public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException { AuthState state = new AuthState(nextUrl, req.getSession().getId(), LoginMethod.MICROSOFT); - AuthorizationRequestUrlParameters parameters = AuthorizationRequestUrlParameters - .builder(getRedirectUri(req), SCOPES) - .state(StringHelper.encrypt(JsonUtils.toCompactJson(state))) - .build(); + String encryptedState = StringHelper.encrypt(JsonUtils.toCompactJson(state)); + + String loginUrl = getAuthorizationRequestUrl(getRedirectUri(req), encryptedState); log.request(req, HttpStatus.SC_MOVED_TEMPORARILY, "Redirect to Microsoft Entra sign-in page"); + return loginUrl; + } + + String getAuthorizationRequestUrl(String redirectUri, String encryptedState) throws AuthException { + AuthorizationRequestUrlParameters parameters = AuthorizationRequestUrlParameters + .builder(redirectUri, SCOPES) + .state(encryptedState) + .build(); + return getClientApplication().getAuthorizationRequestUrl(parameters).toString(); } diff --git a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java index f1eb7798aaa1..98e251119adf 100644 --- a/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java +++ b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java @@ -2,7 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; @@ -40,12 +42,14 @@ public class MicrosoftLoginHandlerTest extends BaseTestCase { @BeforeMethod public void setUpMethod() { - microsoftLoginHandler = new MicrosoftLoginHandler(); + microsoftLoginHandler = spy(new MicrosoftLoginHandler()); } @Test public void handleLogin_validRequest_returnsValidRedirectUrl() throws Exception { MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + doReturn(createAuthorizationRequestUrl("state")).when(microsoftLoginHandler) + .getAuthorizationRequestUrl(eq(OAUTH_CALLBACK_URL), anyString()); String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); @@ -58,6 +62,8 @@ public void handleLogin_validRequest_returnsValidRedirectUrl() throws Exception @Test public void handleLogin_validRequest_returnsRedirectUrlWithValidClientId() throws Exception { MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + doReturn(createAuthorizationRequestUrl("state")).when(microsoftLoginHandler) + .getAuthorizationRequestUrl(eq(OAUTH_CALLBACK_URL), anyString()); String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); @@ -68,6 +74,8 @@ public void handleLogin_validRequest_returnsRedirectUrlWithValidClientId() throw @Test public void handleLogin_validRequest_returnsRedirectUrlWithValidState() throws Exception { MockHttpServletRequest req = new MockHttpServletRequest(HttpGet.METHOD_NAME, LOGIN_URL); + doAnswer(invocation -> createAuthorizationRequestUrl(invocation.getArgument(1))).when(microsoftLoginHandler) + .getAuthorizationRequestUrl(eq(OAUTH_CALLBACK_URL), anyString()); String loginUrl = microsoftLoginHandler.handleLogin(req, "/web/instructor/home"); @@ -164,6 +172,14 @@ private static String getQueryParam(GenericUrl url, String name) { return (String) value; } + private static String createAuthorizationRequestUrl(String state) { + GenericUrl url = new GenericUrl("https://login.microsoftonline.com/" + + Config.OIDC_MICROSOFT_TENANT_ID + "/oauth2/v2.0/authorize"); + url.set("client_id", Config.OIDC_MICROSOFT_CLIENT_ID); + url.set("state", state); + return url.build(); + } + private static String createIdToken() { return createIdToken("actual-tenant-id"); } From 8ad0c54a9e11f9cb9b140c6b64f58923709d55e3 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 26 Jun 2026 17:50:12 +0800 Subject: [PATCH 6/7] Add comment --- .../teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index 4595d35f73f8..99b19bf81bb0 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -155,6 +155,7 @@ private boolean isExpectedTenantId(String tenantId) { if (StringHelper.isEmpty(tenantId)) { return false; } + // Either expecting multi-tenant or the specific tenant ID. return MULTI_TENANT_AUTHORITIES.contains(expectedTenantId) || expectedTenantId.equals(tenantId); } From f0ae8bc06cee26646589457a07e7c8135dfdcca6 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Mon, 29 Jun 2026 10:11:30 +0800 Subject: [PATCH 7/7] Remove profile from scope --- .../ui/loginmethodhandlers/MicrosoftLoginHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java index 99b19bf81bb0..16ec527672e8 100644 --- a/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -36,7 +36,7 @@ public class MicrosoftLoginHandler implements LoginMethodHandler { private static final Logger log = Logger.getLogger(); - private static final Set SCOPES = Set.of("openid", "email", "profile"); + private static final Set SCOPES = Set.of("openid", "email"); private static final Set MULTI_TENANT_AUTHORITIES = Set.of("common", "organizations"); private final String expectedTenantId; @@ -45,6 +45,7 @@ public MicrosoftLoginHandler() { this(Config.OIDC_MICROSOFT_TENANT_ID); } + // For dependency injection. MicrosoftLoginHandler(String expectedTenantId) { this.expectedTenantId = expectedTenantId; }