File: MapIdentityApiTests.cs
Web Access
Project: src\src\Identity\test\Identity.FunctionalTests\Microsoft.AspNetCore.Identity.FunctionalTests.csproj (Microsoft.AspNetCore.Identity.FunctionalTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable enable
 
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
using Identity.DefaultUI.WebSite;
using Identity.DefaultUI.WebSite.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Net.Http.Headers;
using Xunit.Sdk;
 
namespace Microsoft.AspNetCore.Identity.FunctionalTests;
 
public class MapIdentityApiTests : LoggedTest
{
    private static string Email { get; } = $"{Guid.NewGuid()}@example.com";
    private static string Password { get; } = "[PLACEHOLDER]-1a";
    private static Uri BaseAddress { get; } = new Uri("http://example.com");
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanRegisterUser(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password }));
    }
 
    [Fact]
    public async Task RegisterFailsGivenNoEmail()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Password }));
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task RegisterFailsGivenInvalidEmail(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email = "invalid", Password }),
            "InvalidEmail");
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task RegisterFailsGivenDuplicateEmail(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password }));
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email, Password }),
            "DuplicateUserName");
    }
 
    [Fact]
    public async Task LoginFailsGivenUnregisteredUser()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
    }
 
    [Fact]
    public async Task LoginFailsGivenWrongPassword()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanLoginWithBearerToken(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        loginResponse.EnsureSuccessStatusCode();
        Assert.False(loginResponse.Headers.Contains(HeaderNames.SetCookie));
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var tokenType = loginContent.GetProperty("tokenType").GetString();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var expiresIn = loginContent.GetProperty("expiresIn").GetDouble();
        Assert.True(loginContent.TryGetProperty("refreshToken", out _));
 
        Assert.Equal("Bearer", tokenType);
        Assert.Equal(3600, expiresIn);
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanChangeAccessTokenResponseJsonOptions(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityActions[addIdentityMode](services);
            services.ConfigureHttpJsonOptions(options =>
            {
                options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        loginResponse.EnsureSuccessStatusCode();
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var tokenType = loginContent.GetProperty("token_type").GetString();
        var accessToken = loginContent.GetProperty("access_token").GetString();
        var expiresIn = loginContent.GetProperty("expires_in").GetDouble();
        Assert.True(loginContent.TryGetProperty("refresh_token", out _));
 
        Assert.Equal("Bearer", tokenType);
        Assert.Equal(3600, expiresIn);
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task CanCustomizeBearerTokenExpiration()
    {
        var clock = new FakeTimeProvider();
        var expireTimeSpan = TimeSpan.FromSeconds(42);
 
        await using var app = await CreateAppAsync(services =>
        {
            services.AddSingleton<TimeProvider>(clock);
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.BearerTokenExpiration = expireTimeSpan;
            });
        });
 
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var expiresIn = loginContent.GetProperty("expiresIn").GetDouble();
 
        Assert.Equal(expireTimeSpan.TotalSeconds, expiresIn);
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        // Works without time passing.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
 
        clock.Advance(TimeSpan.FromSeconds(expireTimeSpan.TotalSeconds - 1));
 
        // Still works one second before expiration.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
 
        clock.Advance(TimeSpan.FromSeconds(1));
 
        // Fails the second the BearerTokenExpiration elapses.
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task CanLoginWithCookies()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });
 
        AssertOkAndEmpty(loginResponse);
        Assert.True(loginResponse.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders));
        var setCookieHeader = Assert.Single(setCookieHeaders);
 
        // The compiler does not see Assert.True's DoesNotReturnIfAttribute :(
        if (setCookieHeader.Split(';', 2) is not [var cookie, _])
        {
            throw new XunitException("Invalid Set-Cookie header!");
        }
 
        client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task CannotLoginWithCookiesWithOnlyCoreServices()
    {
        await using var app = await CreateAppAsync(services => AddIdentityApiEndpointsBearerOnly(services));
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        await Assert.ThrowsAsync<InvalidOperationException>(()
            => client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password }));
    }
 
    [Fact]
    public async Task CanReadBearerTokenFromQueryString()
    {
        await using var app = await CreateAppAsync(services =>
        {
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.Events.OnMessageReceived = context =>
                {
                    context.Token = (string?)context.Request.Query["accessToken"];
                    return Task.CompletedTask;
                };
            });
        });
 
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
 
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync($"/auth/hello?accessToken={accessToken}"));
 
        // The normal header still works
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task Returns401UnauthorizedStatusGivenNoBearerTokenOrCookie(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
 
        client.DefaultRequestHeaders.Authorization = new("Bearer");
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", "");
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanUseRefreshToken(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
 
        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = refreshContent.GetProperty("accessToken").GetString();
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task Returns401UnauthorizedStatusGivenNullOrEmptyRefreshToken()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        string? refreshToken = null;
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
 
        refreshToken = "";
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
    }
 
    [Fact]
    public async Task CanCustomizeRefreshTokenExpiration()
    {
        var clock = new FakeTimeProvider();
        var expireTimeSpan = TimeSpan.FromHours(42);
 
        await using var app = await CreateAppAsync(services =>
        {
            services.AddSingleton<TimeProvider>(clock);
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.RefreshTokenExpiration = expireTimeSpan;
            });
        });
 
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
        var accessToken = loginContent.GetProperty("refreshToken").GetString();
 
        // Works without time passing.
        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        Assert.True(refreshResponse.IsSuccessStatusCode);
 
        clock.Advance(TimeSpan.FromSeconds(expireTimeSpan.TotalSeconds - 1));
 
        // Still works one second before expiration.
        refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        Assert.True(refreshResponse.IsSuccessStatusCode);
 
        // The bearer token stopped working 41 hours ago with the default 1 hour expiration.
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
 
        clock.Advance(TimeSpan.FromSeconds(1));
 
        // Fails the second the RefreshTokenExpiration elapses.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
 
        // But the last refreshToken from the successful /refresh only a second ago has not expired.
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        refreshToken = refreshContent.GetProperty("refreshToken").GetString();
 
        refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        accessToken = refreshContent.GetProperty("accessToken").GetString();
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var refreshToken = await LoginAsync(client);
 
        var userManager = app.Services.GetRequiredService<UserManager<ApplicationUser>>();
        var user = await userManager.FindByNameAsync(Email);
 
        Assert.NotNull(user);
 
        await userManager.UpdateSecurityStampAsync(user);
 
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
    }
 
    [Fact]
    public async Task RefreshUpdatesUserFromStore()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var refreshToken = await LoginAsync(client);
 
        var userManager = app.Services.GetRequiredService<UserManager<ApplicationUser>>();
        var user = await userManager.FindByNameAsync(Email);
 
        Assert.NotNull(user);
 
        var newUsername = $"{Guid.NewGuid()}@example.com";
        user.UserName = newUsername;
        await userManager.UpdateAsync(user);
 
        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = refreshContent.GetProperty("accessToken").GetString();
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {newUsername}!", await client.GetStringAsync("/auth/hello"));
    }
 
    [Fact]
    public async Task LoginCanBeLockedOut()
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.Configure<IdentityOptions>(options =>
            {
                options.Lockout.MaxFailedAccessAttempts = 2;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "LockedOut");
 
        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(3, "UserLockedOut"));
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "LockedOut");
    }
 
    [Fact]
    public async Task LockoutCanBeDisabled()
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.Configure<IdentityOptions>(options =>
            {
                options.Lockout.AllowedForNewUsers = false;
                options.Lockout.MaxFailedAccessAttempts = 1;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");
 
        Assert.DoesNotContain(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(3, "UserLockedOut"));
 
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }
 
    [Fact]
    public async Task AccountConfirmationCanBeEnabled()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);
 
        Assert.Single(emailSender.Emails);
        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(4, "UserCannotSignInWithoutConfirmedAccount"));
    }
 
    [Fact]
    public async Task EmailConfirmationCanBeEnabled()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedEmail = true;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);
 
        Assert.Single(emailSender.Emails);
        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(0, "UserCannotSignInWithoutConfirmedEmail"));
    }
 
    [Fact]
    public async Task EmailConfirmationCanBeResent()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedEmail = true;
            });
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        var firstEmail = Assert.Single(emailSender.Emails);
        Assert.Equal("Confirm your email", firstEmail.Subject);
        Assert.Equal(Email, firstEmail.Address);
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "NotAllowed");
 
        AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = "wrong" }));
        AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = Email }));
 
        // Even though both resendConfirmationEmail requests returned a 200, only one for a valid registration was sent
        Assert.Equal(2, emailSender.Emails.Count);
        var resentEmail = emailSender.Emails[1];
        Assert.Equal("Confirm your email", resentEmail.Subject);
        Assert.Equal(Email, resentEmail.Address);
 
        AssertOk(await client.GetAsync(GetEmailConfirmationLink(resentEmail)));
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }
 
    [Fact]
    public async Task AccountConfirmationEmailCanBeCustomized()
    {
        var emailSender = new TestEmailSender();
        var customEmailSender = new TestCustomEmailSender(emailSender);
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender<ApplicationUser>>(customEmailSender);
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        var email = Assert.Single(emailSender.Emails);
        Assert.Equal(Email, email.Address);
        Assert.Equal(TestCustomEmailSender.CustomSubject, email.Subject);
        Assert.Equal(TestCustomEmailSender.CustomMessage, email.HtmlMessage);
    }
 
    [Fact]
    public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType()
    {
        // Test with confirmation email since that tests link generation capabilities
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync<ApplicationUser, ApplicationDbContext>(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        }, autoStart: false);
 
        app.MapGroup("/identity2").MapIdentityApi<ApplicationUser>();
 
        await app.StartAsync();
        using var client = app.GetTestClient();
 
        // We have to use different user names to register twice since they use the same store.
        await RegisterAsync(client, "/identity", "a@example.com");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity", "a@example.com");
 
        await RegisterAsync(client, "/identity2", "b@example.com");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2", "b@example.com");
    }
 
    [Fact]
    public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes()
    {
        // Test with confirmation email since that tests link generation capabilities
        var emailSender = new TestEmailSender();
 
        // Even with OnModelCreating tricks to prefix table names, using the same database
        // for multiple user tables is difficult because index conflics, so we just use a different db.
        var dbConnection2 = new SqliteConnection("DataSource=:memory:");
 
        await using var app = await CreateAppAsync<ApplicationUser, ApplicationDbContext>(services =>
        {
            AddIdentityApiEndpoints<ApplicationUser, ApplicationDbContext>(services);
 
            // We just added cookie and/or bearer auth scheme(s) above. We cannot re-add these without an error.
            services
                .AddDbContext<IdentityDbContext>((sp, options) => options.UseSqlite(dbConnection2))
                .AddIdentityCore<IdentityUser>()
                .AddEntityFrameworkStores<IdentityDbContext>()
                .AddApiEndpoints();
 
            services.AddSingleton<IDisposable>(_ => dbConnection2);
 
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        }, autoStart: false);
 
        // The following two lines are already taken care of by CreateAppAsync for ApplicationUser and ApplicationDbContext
        await dbConnection2.OpenAsync();
        await app.Services.GetRequiredService<IdentityDbContext>().Database.EnsureCreatedAsync();
 
        app.MapGroup("/identity2").MapIdentityApi<IdentityUser>();
 
        await app.StartAsync();
        using var client = app.GetTestClient();
 
        // We can use the same email twice since we're using two distinct DbContexts.
        await RegisterAsync(client, "/identity");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity");
 
        await RegisterAsync(client, "/identity2");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2");
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
 
        AssertUnauthorizedAndEmpty(await client.PostAsync("/identity/manage/2fa", null));
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        // We cannot enable 2fa without verifying we can produce a valid token.
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = true }),
            "RequiresTwoFactor");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = true, TwoFactorCode = "wrong" }),
            "InvalidTwoFactorCode");
 
        // Even though we're now authenticated, we must send at least "{}" in the request body. An empty request fails.
        AssertBadRequestAndEmpty(await client.PostAsync("/identity/manage/2fa", null));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync<object?>("/identity/manage/2fa", null));
 
        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean());
 
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();
 
        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean());
 
        // We can still access auth'd endpoints with old access token.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
 
        // But the refresh token is invalidated by the security stamp.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
 
        client.DefaultRequestHeaders.Clear();
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");
 
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, twoFactorCode }));
    }
 
    [Fact]
    public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();
 
        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
 
        var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, recoveryCodes.Length);
 
        client.DefaultRequestHeaders.Clear();
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");
 
        var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] });
 
        var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryAccessToken = recoveryLoginContent.GetProperty("accessToken").GetString();
        Assert.NotEqual(accessToken, recoveryAccessToken);
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken);
 
        var disable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = false });
        var disable2faContent = await disable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(disable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
 
        client.DefaultRequestHeaders.Clear();
 
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }
 
    [Fact]
    public async Task CanResetSharedKey()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();
 
        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }),
            "CannotResetSharedKeyAndEnable");
 
        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
 
        var resetKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { ResetSharedKey = true });
        var resetKeyContent = await resetKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(resetKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());
 
        var resetSharedKey = resetKeyContent.GetProperty("sharedKey").GetString();
 
        var resetKeyBytes = Base32.FromBase32(sharedKey);
        var resetTwoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        // The old 2fa code no longer works
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true }),
            "InvalidTwoFactorCode");
 
        var reenable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { TwoFactorCode = resetTwoFactorCode, Enable = true });
        var reenable2faContent = await reenable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
    }
 
    [Fact]
    public async Task CanResetRecoveryCodes()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
 
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();
 
        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, enable2faContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Equal(10, recoveryCodes.Length);
 
        client.DefaultRequestHeaders.Clear();
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");
 
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }));
        // Cannot reuse codes
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }),
            "Failed");
 
        var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[1] });
        var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryAccessToken = recoveryLoginContent.GetProperty("accessToken").GetString();
        Assert.NotEqual(accessToken, recoveryAccessToken);
 
        client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken);
 
        var updated2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var updated2faContent = await updated2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.Equal(8, updated2faContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Null(updated2faContent.GetProperty("recoveryCodes").GetString());
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }),
            "CannotResetSharedKeyAndEnable");
 
        var resetRecoveryResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { ResetRecoveryCodes = true });
        var resetRecoveryContent = await resetRecoveryResponse.Content.ReadFromJsonAsync<JsonElement>();
        var resetRecoveryCodes = resetRecoveryContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, resetRecoveryContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Equal(10, resetRecoveryCodes.Length);
        Assert.Empty(recoveryCodes.Intersect(resetRecoveryCodes));
 
        client.DefaultRequestHeaders.Clear();
 
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = resetRecoveryCodes[0] }));
 
        // Even unused codes from before the reset now fail.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[2] }),
            "Failed");
    }
 
    [Fact]
    public async Task CanUsePersistentTwoFactorCookies()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });
        ApplyCookies(client, loginResponse);
 
        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean());
 
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();
 
        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);
 
        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean());
 
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");
 
        // The machine will not be remembered if useSessionCookies=true
        var sessionLoginResponse = await client.PostAsJsonAsync("/identity/login?useSessionCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, sessionLoginResponse);
 
        var session2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var session2faContent = await session2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(session2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(session2faContent.GetProperty("isMachineRemembered").GetBoolean());
 
        // Even if useCookies=true also
        var sessionLoginResponse2 = await client.PostAsJsonAsync("/identity/login?useCookies=true&useSessionCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, sessionLoginResponse2);
 
        var session2faResponse2 = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var session2faContent2 = await session2faResponse2.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(session2faContent2.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(session2faContent2.GetProperty("isMachineRemembered").GetBoolean());
 
        // But the machine will be remembered if just useCookies=true and not useSessionCookies=true
        var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, persistentLoginResponse);
 
        var persistent2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var persistent2faContent = await persistent2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(persistent2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.True(persistent2faContent.GetProperty("isMachineRemembered").GetBoolean());
    }
 
    [Fact]
    public async Task CanResetPassword()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();
 
        var confirmedEmail = "confirmed@example.com";
        var unconfirmedEmail = "unconfirmed@example.com";
 
        await RegisterAsync(client, email: confirmedEmail);
        await LoginWithEmailConfirmationAsync(client, emailSender, email: confirmedEmail);
 
        await RegisterAsync(client, email: unconfirmedEmail);
 
        // Two emails were sent, but only one was confirmed
        Assert.Equal(2, emailSender.Emails.Count);
 
        // Returns 200 status for invalid email addresses
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = confirmedEmail }));
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = unconfirmedEmail }));
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = "wrong" }));
 
        // But only one email was sent for the confirmed address
        Assert.Equal(3, emailSender.Emails.Count);
        var resetEmail = emailSender.Emails[2];
 
        Assert.Equal("Reset your password", resetEmail.Subject);
        Assert.Equal(confirmedEmail, resetEmail.Address);
 
        var resetCode = GetPasswordResetCode(resetEmail);
        var newPassword = $"{Password}!";
 
        // The same validation errors are returned even for invalid emails
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode }));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, resetCode }));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", resetCode }));
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", newPassword }),
            "InvalidToken");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, ResetCode = "wrong", newPassword }),
            "InvalidToken");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", ResetCode = "wrong", newPassword }),
            "InvalidToken");
 
        // Only with a valid reset code is it possible to get more problem details
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", NewPassword = "" }),
            "InvalidToken");
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, NewPassword = "" }),
            detail: null, title: "One or more validation errors occurred.", status: HttpStatusCode.BadRequest);
 
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, newPassword }));
 
        // The old password is no longer valid
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password }),
            "Failed");
 
        // But the new password is
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password = newPassword }));
    }
 
    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanChangeEmail(string addIdentityModes)
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityActions[addIdentityModes](services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();
 
        AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/manage/info"));
 
        await RegisterAsync(client);
        var originalRefreshToken = await LoginWithEmailConfirmationAsync(client, emailSender);
 
        var infoResponse = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(Email, infoResponse.GetProperty("email").GetString());
        Assert.True(infoResponse.GetProperty("isEmailConfirmed").GetBoolean());
 
        var infoClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal("pwd", GetSingleClaim(infoClaims, "amr"));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Email));
 
        var originalNameIdentifier = GetSingleClaim(infoClaims, ClaimTypes.NameIdentifier);
        var newEmail = $"New-{Email}";
 
        // The email must pass DataAnnotations validation by EmailAddressAttribute.
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { NewEmail = "invalid" }),
            "InvalidEmail");
 
        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { newEmail });
        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the new email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());
        Assert.True(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean());
 
        // And none of the claims have yet been updated.
        var infoPostClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(infoPostClaims, ClaimTypes.NameIdentifier));
 
        // We cannot log in with the new email until we confirm the email change.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password }),
            "Failed");
 
        // And we can still use the original refresh token since the email change has not yet been confirmed.
        AssertOk(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken }));
 
        // Two emails have now been sent. The first was sent during registration. And the second for the email change.
        Assert.Equal(2, emailSender.Emails.Count);
        var email = emailSender.Emails[1];
 
        Assert.Equal("Confirm your email", email.Subject);
        Assert.Equal(newEmail, email.Address);
 
        AssertOk(await client.GetAsync(GetEmailConfirmationLink(email)));
 
        var infoAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email is immediately updated after the email is confirmed.
        Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString());
 
        // The email still won't be available as a claim until we get a new token.
        var claimsAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.NameIdentifier));
 
        // And now the email has changed, the refresh token is invalidated by the security stamp.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken }));
 
        // We will finally see all the claims updated after logging in again.
        await LoginAsync(client, email: newEmail);
 
        var infoAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString());
        Assert.True(infoAfterFinalLogin.GetProperty("isEmailConfirmed").GetBoolean());
 
        var claimsAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Name));
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.NameIdentifier));
    }
 
    [Fact]
    public async Task CannotUpdateClaimsDuringInfoPostWithCookies()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();
 
        AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/manage/info"));
 
        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);
 
        // Clear bearer token. We just used the common login email for convenient email verification.
        client.DefaultRequestHeaders.Clear();
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });
        ApplyCookies(client, loginResponse);
 
        var infoResponse = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(Email, infoResponse.GetProperty("email").GetString());
 
        var infoClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal("pwd", GetSingleClaim(infoClaims, "amr"));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Email));
 
        var originalNameIdentifier = GetSingleClaim(infoClaims, ClaimTypes.NameIdentifier);
        var newEmail = $"NewEmailPrefix-{Email}";
 
        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { newEmail });
        // There are no cookie updates because nothing has changed yet.
        Assert.False(infoPostResponse.Headers.Contains(HeaderNames.SetCookie));
 
        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());
 
        // The claims have not been updated to match.
        var infoPostClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(infoPostClaims, ClaimTypes.NameIdentifier));
 
        // Two emails have now been sent. The first was sent during registration. And the second for the email change.
        Assert.Equal(2, emailSender.Emails.Count);
        var email = emailSender.Emails[1];
 
        Assert.Equal("Confirm your email", email.Subject);
        Assert.Equal(newEmail, email.Address);
 
        var emailConfirmationResponse = await client.GetAsync(GetEmailConfirmationLink(email));
        // Even though the user does change during this request, we still don't refresh the cookie, because this
        // request doesn't rely on authentication. It's entirely possible the client is logged in as a different user.
        Assert.False(emailConfirmationResponse.Headers.Contains(HeaderNames.SetCookie));
        AssertOk(emailConfirmationResponse);
 
        var infoAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email is immediately updated after the email is confirmed.
        Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString());
 
        // The email still won't be available as a claim until we get a new cookie.
        var claimsAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.NameIdentifier));
 
        // We will finally see all the claims updated after logging in again.
        var secondLoginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email = newEmail, Password });
        ApplyCookies(client, secondLoginResponse);
 
        var infoAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString());
 
        var claimsAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Name));
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.NameIdentifier));
    }
 
    [Fact]
    public async Task CanChangePasswordWithoutResetEmail()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
        await LoginAsync(client);
 
        var newPassword = $"{Password}!";
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { newPassword }),
            "OldPasswordRequired");
        AssertOk(await client.PostAsJsonAsync("/identity/manage/info", new { OldPassword = Password, newPassword }));
 
        client.DefaultRequestHeaders.Clear();
 
        // We can immediately log in with the new password
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword }));
    }
 
    [Fact]
    public async Task MustSendValidRequestToSendEmailChangeConfirmation()
    {
        var emailSender = new TestEmailSender();
 
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
        });
        using var client = app.GetTestClient();
 
        await RegisterAsync(client);
 
        // We're not going to bother to confirm the original email, but it should be there.
        Assert.Single(emailSender.Emails);
        emailSender.Emails.Clear();
 
        await LoginAsync(client);
 
        var newEmail = $"New-{Email}";
        var newPassword = $"{Password}!";
 
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { newPassword, newEmail }),
            "OldPasswordRequired");
 
        // Since the request is invalid, no change email confirmation was sent.
        Assert.Empty(emailSender.Emails);
 
        // We can in fact update multiple things at once if we do it correctly, though the response wont show a email update until we confirm the email.
        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { OldPassword = Password, newPassword, newEmail });
 
        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());
        Assert.False(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean());
 
        // We cannot login with the new email yet.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword }),
            "Failed");
        // And we cannot login with the old email and password either.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
        // We'll have to use the old email with the new password until we confirm the new email.
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword }));
 
        // Confirm the email change.
        var changeEmail = Assert.Single(emailSender.Emails);
        Assert.Equal(newEmail, changeEmail.Address);
        AssertOk(await client.GetAsync(GetEmailConfirmationLink(changeEmail)));
 
        var infoGetContent = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email isn't updated until the email is confirmed.
        Assert.Equal(newEmail, infoGetContent.GetProperty("email").GetString());
        Assert.True(infoGetContent.GetProperty("isEmailConfirmed").GetBoolean());
 
        // We can now login with the new email too.
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword }));
    }
 
    private async Task<WebApplication> CreateAppAsync<TUser, TContext>(Action<IServiceCollection>? configureServices, bool autoStart = true)
        where TUser : class, new()
        where TContext : DbContext
    {
        var builder = WebApplication.CreateSlimBuilder();
        builder.WebHost.UseTestServer(options =>
        {
            options.BaseAddress = BaseAddress;
        });
        builder.Services.AddSingleton(LoggerFactory);
        builder.Services.AddAuthorization();
 
        var dbConnection = new SqliteConnection("DataSource=:memory:");
        // Dispose SqliteConnection with host by registering as a singleton factory.
        builder.Services.AddSingleton(_ => dbConnection);
 
        configureServices ??= services => AddIdentityApiEndpoints<TUser, TContext>(services);
        configureServices(builder.Services);
 
        var app = builder.Build();
 
        app.UseAuthentication();
        app.UseAuthorization();
 
        app.MapGroup("/identity").MapIdentityApi<TUser>();
 
        var authGroup = app.MapGroup("/auth").RequireAuthorization();
        authGroup.MapGet("/hello",
            (ClaimsPrincipal user) => $"Hello, {user.Identity?.Name}!");
 
        authGroup.MapGet("/claims", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value }));
 
        await dbConnection.OpenAsync();
        await app.Services.GetRequiredService<TContext>().Database.EnsureCreatedAsync();
 
        if (autoStart)
        {
            await app.StartAsync();
        }
 
        return app;
    }
 
    private static IdentityBuilder AddIdentityApiEndpoints<TUser, TContext>(IServiceCollection services)
        where TUser : class, new()
        where TContext : DbContext
    {
        return services.AddDbContext<TContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()))
            .AddIdentityApiEndpoints<TUser>().AddEntityFrameworkStores<TContext>();
    }
 
    private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services)
        => AddIdentityApiEndpoints<ApplicationUser, ApplicationDbContext>(services);
 
    private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services)
    {
        services
            .AddAuthentication()
            .AddBearerToken(IdentityConstants.BearerScheme);
 
        return services
            .AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()))
            .AddIdentityCore<ApplicationUser>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddApiEndpoints();
    }
 
    private Task<WebApplication> CreateAppAsync(Action<IServiceCollection>? configureServices = null)
        => CreateAppAsync<ApplicationUser, ApplicationDbContext>(configureServices);
 
    private static Dictionary<string, Action<IServiceCollection>> AddIdentityActions { get; } = new()
    {
        [nameof(AddIdentityApiEndpoints)] = services => AddIdentityApiEndpoints(services),
        [nameof(AddIdentityApiEndpointsBearerOnly)] = services => AddIdentityApiEndpointsBearerOnly(services),
    };
 
    public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray();
 
    private static string? GetSingleClaim(JsonElement claims, string name)
        => claims.EnumerateArray().Single(e => e.GetProperty("type").GetString() == name).GetProperty("value").GetString();
 
    private static string GetEmailConfirmationLink(TestEmail email)
    {
        // Update if we add more links to the email.
        var confirmationMatch = Regex.Match(email.HtmlMessage, "href='(.*?)'");
        Assert.True(confirmationMatch.Success);
        Assert.Equal(2, confirmationMatch.Groups.Count);
 
        var url = WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value);
        Assert.StartsWith(BaseAddress.ToString(), url);
        return url;
    }
 
    private static string GetPasswordResetCode(TestEmail email)
    {
        // Update if we add more links to the email.
        var confirmationMatch = Regex.Match(email.HtmlMessage, "code: (.*?)$");
        Assert.True(confirmationMatch.Success);
        Assert.Equal(2, confirmationMatch.Groups.Count);
 
        return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value);
    }
 
    private async Task RegisterAsync(HttpClient client, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;
 
        AssertOkAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/register", new { email, Password }));
    }
 
    private async Task<string> LoginAsync(HttpClient client, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;
 
        await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password });
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { email, Password });
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
        Assert.NotNull(accessToken);
        Assert.NotNull(refreshToken);
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
 
        return refreshToken;
    }
 
    private async Task<string> LoginWithEmailConfirmationAsync(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;
 
        var receivedEmail = emailSender.Emails.Last();
 
        Assert.Equal("Confirm your email", receivedEmail.Subject);
        Assert.Equal(email, receivedEmail.Address);
 
        await AssertProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password }),
            "NotAllowed");
 
        AssertOk(await client.GetAsync(GetEmailConfirmationLink(receivedEmail)));
 
        return await LoginAsync(client, groupPrefix, email);
    }
 
    private static void AssertOk(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
 
    private static void AssertOkAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }
 
    private static void AssertBadRequestAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }
 
    private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }
 
    private static async Task AssertProblemAsync(HttpResponseMessage response, string? detail, string? title = null, HttpStatusCode status = HttpStatusCode.Unauthorized)
    {
        Assert.Equal(status, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString());
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.NotNull(problem);
        Assert.Equal(title ?? ReasonPhrases.GetReasonPhrase((int)status), problem.Title);
        Assert.Equal(detail, problem.Detail);
    }
 
    private static async Task AssertValidationProblemAsync(HttpResponseMessage response, string error)
    {
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString());
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.NotNull(problem);
        var errorEntry = Assert.Single(problem.Errors);
        Assert.Equal(error, errorEntry.Key);
    }
 
    private static void ApplyCookies(HttpClient client, HttpResponseMessage response)
    {
        AssertOk(response);
 
        Assert.True(response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders));
        foreach (var setCookieHeader in setCookieHeaders)
        {
            if (setCookieHeader.Split(';', 2) is not [var cookie, _])
            {
                throw new XunitException("Invalid Set-Cookie header!");
            }
 
            // Cookies starting with "CookieName=;" are being deleted
            if (!cookie.EndsWith("=", StringComparison.Ordinal))
            {
                client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie);
            }
        }
    }
 
    private sealed class TestTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser>
        where TUser : class
    {
        public async Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
        {
            return MakeToken(purpose, await manager.GetUserIdAsync(user));
        }
 
        public async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
        {
            return token == MakeToken(purpose, await manager.GetUserIdAsync(user));
        }
 
        public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
        {
            return Task.FromResult(true);
        }
 
        private static string MakeToken(string purpose, string userId)
        {
            return string.Join(":", userId, purpose, "ImmaToken");
        }
    }
 
    private sealed class TestEmailSender : IEmailSender
    {
        public List<TestEmail> Emails { get; set; } = new();
 
        public Task SendEmailAsync(string email, string subject, string htmlMessage)
        {
            Emails.Add(new(email, subject, htmlMessage));
            return Task.CompletedTask;
        }
    }
 
    private sealed class TestCustomEmailSender(IEmailSender emailSender) : IEmailSender<ApplicationUser>
    {
        public const string CustomSubject = "Custom subject";
        public const string CustomMessage = "Custom message";
 
        public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink)
        {
            Assert.Equal(user.Email, email);
            emailSender.SendEmailAsync(email, "Custom subject", "Custom message");
            return Task.CompletedTask;
        }
 
        public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
            throw new NotImplementedException();
 
        public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
            throw new NotImplementedException();
    }
 
    private sealed record TestEmail(string Address, string Subject, string HtmlMessage);
}