From 0a09d1b19b984b3844e162d6e2ad62f1d8a55245 Mon Sep 17 00:00:00 2001 From: GrantTotinov Date: Sun, 6 Sep 2026 04:14:28 +0300 Subject: [PATCH 1/2] Add test locking external-login 2FA challenge behavior --- .../Identity.FunctionalTests/LoginTests.cs | 31 +++++++++++++++++++ .../Pages/Contoso/Login.cs | 20 ++++++++++++ .../Identity.FunctionalTests/UserStories.cs | 19 ++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/Identity/test/Identity.FunctionalTests/LoginTests.cs b/src/Identity/test/Identity.FunctionalTests/LoginTests.cs index 05caeb216d0e..bb4a227a1eb1 100644 --- a/src/Identity/test/Identity.FunctionalTests/LoginTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/LoginTests.cs @@ -281,6 +281,37 @@ void ConfigureTestServices(IServiceCollection services) => services await UserStories.LoginExistingUserAsync(newClient, userName, password); } + [Fact] + public async Task ExternalLoginIsChallengedForTwoFactorWhenAccountHasTwoFactorEnabled() + { + // Arrange + void ConfigureTestServices(IServiceCollection services) => + services.SetupTestThirdPartyLogin(); + + var server = ServerFactory.WithWebHostBuilder(whb => whb.ConfigureServices(ConfigureTestServices)); + + var client = server.CreateClient(); + var newClient = server.CreateClient(); + + var guid = Guid.NewGuid(); + var userName = $"{guid}"; + var email = $"{guid}@example.com"; + + var index = await UserStories.RegisterNewUserWithSocialLoginAsync(client, userName, email); + var manage = await index.ClickManageLinkWithExternalLoginAsync(); + var twoFactor = await manage.ClickTwoFactorLinkAsync(); + var enableAuthenticator = await twoFactor.ClickEnableAuthenticatorLinkAsync(); + var showRecoveryCodes = await enableAuthenticator.SendValidCodeAsync(); + + var twoFactorKey = showRecoveryCodes.Context.AuthenticatorKey; + + // Act & Assert + // Use a new client to simulate a new browser session. Signing in again via the same + // external provider must now be challenged for the second factor instead of completing + // immediately, matching the behavior of the password sign-in path. + await UserStories.LoginWithSocialLogin2FaAsync(newClient, userName, twoFactorKey); + } + [Fact] public async Task CanLoginWithASocialLoginProvider() { diff --git a/src/Identity/test/Identity.FunctionalTests/Pages/Contoso/Login.cs b/src/Identity/test/Identity.FunctionalTests/Pages/Contoso/Login.cs index 9f6d429fbae4..db33da319b21 100644 --- a/src/Identity/test/Identity.FunctionalTests/Pages/Contoso/Login.cs +++ b/src/Identity/test/Identity.FunctionalTests/Pages/Contoso/Login.cs @@ -31,6 +31,26 @@ public async Task SendExistingUserNameAsync(string userName) return new Index(Client, externalLogin, Context.WithAuthenticatedUser()); } + // Used when the existing external-login account has two-factor authentication enabled: + // the external sign-in must be challenged for a second factor instead of completing immediately. + public async Task SendExistingUserNameWith2FaAsync(string userName) + { + var contosoResponse = await Client.SendAsync(_loginForm, new Dictionary + { + ["Input_Login"] = userName + }); + + var goToExternalLogin = ResponseAssert.IsRedirect(contosoResponse); + var externalLogInResponse = await Client.GetAsync(goToExternalLogin); + + var goToLoginWith2fa = ResponseAssert.IsRedirect(externalLogInResponse); + Assert.StartsWith(Account.LoginWith2fa.Path, goToLoginWith2fa.ToString()); + var loginWith2faResponse = await Client.GetAsync(goToLoginWith2fa); + var loginWith2fa = await ResponseAssert.IsHtmlDocumentAsync(loginWith2faResponse); + + return new Account.LoginWith2fa(Client, loginWith2fa, Context); + } + private async Task SendLoginForm(string userName) { var contosoResponse = await Client.SendAsync(_loginForm, new Dictionary diff --git a/src/Identity/test/Identity.FunctionalTests/UserStories.cs b/src/Identity/test/Identity.FunctionalTests/UserStories.cs index 8b6ee9d36760..ca9fd042d934 100644 --- a/src/Identity/test/Identity.FunctionalTests/UserStories.cs +++ b/src/Identity/test/Identity.FunctionalTests/UserStories.cs @@ -131,6 +131,25 @@ internal static async Task LoginWithSocialLoginAsync(HttpClient client, s return await contosoLogin.SendExistingUserNameAsync(userName); } + // Covers the case where an existing external-login account has two-factor authentication + // enabled: the sign-in must route through the two-factor page before completing. + internal static async Task LoginWithSocialLogin2FaAsync(HttpClient client, string userName, string twoFactorKey) + { + var index = await Index.CreateAsync( + client, + new DefaultUIContext() + .WithSocialLoginEnabled() + .WithExistingUser()); + + var login = await index.ClickLoginLinkAsync(); + + var contosoLogin = await login.ClickLoginWithContosoLinkAsync(); + + var login2Fa = await contosoLogin.SendExistingUserNameWith2FaAsync(userName); + + return await login2Fa.Send2FACodeAsync(twoFactorKey); + } + internal static async Task LoginExistingUser2FaAsync(HttpClient client, string userName, string password, string twoFactorKey) { var index = await Index.CreateAsync(client); From 8920bdd965cdce2ea726177bceb0a9edfbb9f755 Mon Sep 17 00:00:00 2001 From: GrantTotinov Date: Sun, 6 Sep 2026 04:14:36 +0300 Subject: [PATCH 2/2] Honor two-factor on external-login sign-in --- .../Identity/Pages/V4/Account/ExternalLogin.cshtml.cs | 8 +++++++- .../Identity/Pages/V5/Account/ExternalLogin.cshtml.cs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs index a92c3b7964f6..3d131470c23e 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs @@ -138,7 +138,9 @@ public override async Task OnGetCallbackAsync(string? returnUrl = } // Sign in the user with this external login provider if the user already has a login. - var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + // bypassTwoFactor is false so that accounts with two-factor authentication enabled are + // still challenged for a second factor, consistent with the password sign-in path. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: false); if (result.Succeeded) { if (_logger.IsEnabled(LogLevel.Information)) @@ -147,6 +149,10 @@ public override async Task OnGetCallbackAsync(string? returnUrl = } return LocalRedirect(returnUrl); } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl }); + } if (result.IsLockedOut) { return RedirectToPage("./Lockout"); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs index 4deb95928f69..3b1eb785500b 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs @@ -138,7 +138,9 @@ public override async Task OnGetCallbackAsync(string? returnUrl = } // Sign in the user with this external login provider if the user already has a login. - var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + // bypassTwoFactor is false so that accounts with two-factor authentication enabled are + // still challenged for a second factor, consistent with the password sign-in path. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: false); if (result.Succeeded) { if (_logger.IsEnabled(LogLevel.Information)) @@ -147,6 +149,10 @@ public override async Task OnGetCallbackAsync(string? returnUrl = } return LocalRedirect(returnUrl); } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl }); + } if (result.IsLockedOut) { return RedirectToPage("./Lockout");