diff --git a/build.gradle b/build.gradle index 54b2cb2a5e5..c211943943a 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 63f76099483..10c8e22b44e 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 353c9b269ac..3bf7a5a9770 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 new file mode 100644 index 00000000000..16ec527672e --- /dev/null +++ b/src/main/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandler.java @@ -0,0 +1,194 @@ +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 Set MULTI_TENANT_AUTHORITIES = Set.of("common", "organizations"); + + private final String expectedTenantId; + + public MicrosoftLoginHandler() { + this(Config.OIDC_MICROSOFT_TENANT_ID); + } + + // For dependency injection. + 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); + 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(); + } + + @Override + public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws IOException, AuthException { + 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 " + expectedTenantId + + ", 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"); + + 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 { + Object rawValue = claims.get(claimName); + String value = rawValue instanceof String ? (String) rawValue : null; + if (StringHelper.isEmpty(value)) { + throw new AuthException("Missing " + claimName + " claim"); + } + return value; + } + + 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); + } + + private ConfidentialClientApplication getClientApplication() throws AuthException { + try { + return ConfidentialClientApplication + .builder(Config.OIDC_MICROSOFT_CLIENT_ID, + ClientCredentialFactory.createFromSecret(Config.OIDC_MICROSOFT_CLIENT_SECRET)) + .authority("https://login.microsoftonline.com/" + expectedTenantId) + .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/output/LoginMethod.java b/src/main/java/teammates/ui/output/LoginMethod.java index 10a679f7b3b..da38e703955 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 fdb0d648282..a88bcae23c0 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/main/java/teammates/ui/servlets/LoginServlet.java b/src/main/java/teammates/ui/servlets/LoginServlet.java index 5c694b43fc8..339684ee296 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 bd5acb867a1..0bebefb5133 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 78bc4c6f083..832527c1647 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 197dc14bd8a..3e666d8befb 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 00000000000..98e251119ad --- /dev/null +++ b/src/test/java/teammates/ui/loginmethodhandlers/MicrosoftLoginHandlerTest.java @@ -0,0 +1,200 @@ +package teammates.ui.loginmethodhandlers; + +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; + +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 = 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"); + + 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); + doReturn(createAuthorizationRequestUrl("state")).when(microsoftLoginHandler) + .getAuthorizationRequestUrl(eq(OAUTH_CALLBACK_URL), anyString()); + + 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); + doAnswer(invocation -> createAuthorizationRequestUrl(invocation.getArgument(1))).when(microsoftLoginHandler) + .getAuthorizationRequestUrl(eq(OAUTH_CALLBACK_URL), anyString()); + + 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("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)); + 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_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"))).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); + + assertThrows(AuthException.class, + () -> loginHandler.handleCallback(req, state)); + } + + @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_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); + req.addParam("code", "authorization-code"); + AuthState state = new AuthState("/", "different-session-id", LoginMethod.MICROSOFT); + + assertThrows(AuthException.class, + () -> microsoftLoginHandler.handleCallback(req, state)); + } + + @Test + 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); + + assertThrows(AuthException.class, + () -> loginHandler.handleCallback(req, state)); + } + + 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 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"); + } + + 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"; + } +} diff --git a/src/web/types/api-output.ts b/src/web/types/api-output.ts index f00f307d6d3..c7299f1d8f9 100644 --- a/src/web/types/api-output.ts +++ b/src/web/types/api-output.ts @@ -854,6 +854,7 @@ export enum JoinState { export enum LoginMethod { GOOGLE = "google", + MICROSOFT = "microsoft", DEV_SERVER = "devserver", }