// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenQA.Selenium; using OpenQA.Selenium.Support.UI; using Wasm.Authentication.Server; using Wasm.Authentication.Server.Data; using Xunit; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.Tests { public class WebAssemblyAuthenticationTests : ServerTestBase { private static readonly SqliteConnection _connection; // We create a conection here and open it as the in memory Db will delete the database // as soon as there are no open connections to it. static WebAssemblyAuthenticationTests() { _connection = new SqliteConnection($"DataSource=:memory:"); _connection.Open(); } public WebAssemblyAuthenticationTests( BrowserFixture browserFixture, AspNetSiteServerFixture serverFixture, ITestOutputHelper output) : base(browserFixture, serverFixture, output) { _serverFixture.ApplicationAssembly = typeof(Program).Assembly; _serverFixture.AdditionalArguments.Clear(); _serverFixture.BuildWebHostMethod = args => Program.CreateHostBuilder(args) .ConfigureServices(services => SetupTestDatabase(services, _connection)) .Build(); } public override Task InitializeAsync() => base.InitializeAsync(Guid.NewGuid().ToString()); protected override void InitializeAsyncCore() { Navigate("/", noReload: true); EnsureDatabaseCreated(_serverFixture.Host.Services); WaitUntilLoaded(); } [Fact] public void WasmAuthentication_Loads() { Browser.Equal("Wasm.Authentication.Client", () => Browser.Title); } [Fact] public void AnonymousUser_GetsRedirectedToLogin_AndBackToOriginalProtectedResource() { var link = By.PartialLinkText("Fetch data"); var page = "/Identity/Account/Login"; ClickAndNavigate(link, page); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; FirstTimeRegister(userName, password); ValidateFetchData(); } [Fact] public void CanPreserveApplicationState_DuringLogIn() { var originalAppState = Browser.Exists(By.Id("app-state")).Text; var link = By.PartialLinkText("Fetch data"); var page = "/Identity/Account/Login"; ClickAndNavigate(link, page); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; FirstTimeRegister(userName, password); ValidateFetchData(); var homeLink = By.PartialLinkText("Home"); var homePage = "/"; ClickAndNavigate(homeLink, homePage); var restoredAppState = Browser.Exists(By.Id("app-state")).Text; Assert.Equal(originalAppState, restoredAppState); } [Fact] public void CanShareUserRolesBetweenClientAndServer() { ClickAndNavigate(By.PartialLinkText("Log in"), "/Identity/Account/Login"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; FirstTimeRegister(userName, password); ClickAndNavigate(By.PartialLinkText("Make admin"), "/new-admin"); ClickAndNavigate(By.PartialLinkText("Settings"), "/admin-settings"); Browser.Exists(By.Id("admin-action")).Click(); Browser.Exists(By.Id("admin-success")); } private void ClickAndNavigate(By link, string page) { Browser.Exists(link).Click(); Browser.Contains(page, () => Browser.Url); } [Fact] public void AnonymousUser_CanRegister_AndGetLoggedIn() { ClickAndNavigate(By.PartialLinkText("Register"), "/Identity/Account/Register"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; RegisterCore(userName, password); CompleteProfileDetails(); // Need to navigate to fetch page Browser.Exists(By.PartialLinkText("Fetch data")).Click(); // Can navigate to the 'fetch data' page ValidateFetchData(); } [Fact] public void AuthenticatedUser_ProfileIncludesDetails_And_AccessToken() { ClickAndNavigate(By.PartialLinkText("User"), "/Identity/Account/Login"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; FirstTimeRegister(userName, password); Browser.Contains("user", () => Browser.Url); Browser.Equal($"Welcome {userName}", () => Browser.Exists(By.TagName("h1")).Text); var claims = Browser.FindElements(By.CssSelector("p.claim")) .Select(e => { var pair = e.Text.Split(":"); return (pair[0].Trim(), pair[1].Trim()); }) .Where(c => !new[] { "s_hash", "auth_time", "sid", "sub" }.Contains(c.Item1)) .OrderBy(o => o.Item1) .ToArray(); Assert.Equal(5, claims.Length); Assert.Equal(new[] { ("amr", "pwd"), ("idp", "local"), ("name", userName), ("NewUser", "true"), ("preferred_username", userName) }, claims); var token = Browser.Exists(By.Id("access-token")).Text; Assert.NotNull(token); var payload = JsonSerializer.Deserialize(Base64UrlTextEncoder.Decode(token.Split(".")[1])); Assert.StartsWith("http://127.0.0.1", payload.Issuer); Assert.StartsWith("Wasm.Authentication.ServerAPI", payload.Audience); Assert.StartsWith("Wasm.Authentication.Client", payload.ClientId); Assert.Equal(new[] { "openid", "profile", "Wasm.Authentication.ServerAPI" }, payload.Scopes.OrderBy(id => id)); var currentTime = DateTimeOffset.Parse(Browser.Exists(By.Id("current-time")).Text); var tokenExpiration = DateTimeOffset.Parse(Browser.Exists(By.Id("access-token-expires")).Text); Assert.True(currentTime.AddMinutes(50) < tokenExpiration); Assert.True(currentTime.AddMinutes(60) >= tokenExpiration); } [Fact] public void AuthenticatedUser_CanGoToProfile() { ClickAndNavigate(By.PartialLinkText("Register"), "/Identity/Account/Register"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; RegisterCore(userName, password); CompleteProfileDetails(); ClickAndNavigate(By.PartialLinkText($"Hello, {userName}!"), "/Identity/Account/Manage"); Browser.Navigate().Back(); Browser.Equal("/", () => new Uri(Browser.Url).PathAndQuery); } [Fact] public void RegisterAndBack_DoesNotCause_RedirectLoop() { Browser.Exists(By.PartialLinkText("Register")).Click(); // We will be redirected to the identity UI Browser.Contains("/Identity/Account/Register", () => Browser.Url); Browser.Navigate().Back(); Browser.Equal("/", () => new Uri(Browser.Url).PathAndQuery); } [Fact] public void LoginAndBack_DoesNotCause_RedirectLoop() { Browser.Exists(By.PartialLinkText("Log in")).Click(); // We will be redirected to the identity UI Browser.Contains("/Identity/Account/Login", () => Browser.Url); Browser.Navigate().Back(); Browser.Equal("/", () => new Uri(Browser.Url).PathAndQuery); } [Fact] public void NewlyRegisteredUser_CanLogOut() { ClickAndNavigate(By.PartialLinkText("Register"), "/Identity/Account/Register"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; RegisterCore(userName, password); CompleteProfileDetails(); ValidateLogout(); } [Fact] public void AlreadyRegisteredUser_CanLogOut() { ClickAndNavigate(By.PartialLinkText("Register"), "/Identity/Account/Register"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; RegisterCore(userName, password); CompleteProfileDetails(); ValidateLogout(); Browser.Navigate().GoToUrl("data:"); Navigate("/"); WaitUntilLoaded(); ClickAndNavigate(By.PartialLinkText("Log in"), "/Identity/Account/Login"); // Now we can login LoginCore(userName, password); ValidateLoggedIn(userName); ValidateLogout(); } [Fact] public void LoggedInUser_OnTheIdP_CanLogInSilently() { ClickAndNavigate(By.PartialLinkText("Register"), "/Identity/Account/Register"); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; RegisterCore(userName, password); CompleteProfileDetails(); ValidateLoggedIn(userName); // Clear the existing storage on the page and refresh Browser.Exists(By.Id("test-clear-storage")).Click(); Browser.Exists(By.Id("test-refresh-page")).Click(); ValidateLoggedIn(userName); } [Fact(Skip = "Browser logs cannot be retrieved: https://github.com/dotnet/aspnetcore/issues/25803")] public void CanNotRedirect_To_External_ReturnUrl() { Browser.Navigate().GoToUrl(new Uri(new Uri(Browser.Url), "/authentication/login?returnUrl=https%3A%2F%2Fwww.bing.com").AbsoluteUri); WaitUntilLoaded(skipHeader: true); Browser.Exists(By.CssSelector("[style=\"display: block;\"]")); Assert.NotEmpty(Browser.GetBrowserLogs(LogLevel.Severe)); } [Fact] public async Task CanNotTrigger_Logout_WithNavigation() { Browser.Navigate().GoToUrl(new Uri(new Uri(Browser.Url), "/authentication/logout").AbsoluteUri); WaitUntilLoaded(skipHeader: true); Browser.Contains("/authentication/logout-failed", () => Browser.Url); await Task.Delay(3000); Browser.Contains("/authentication/logout-failed", () => Browser.Url); } private void ValidateLoggedIn(string userName) { Browser.Exists(By.CssSelector("button.nav-link.btn.btn-link")); Browser.Exists(By.PartialLinkText($"Hello, {userName}!")); } private void LoginCore(string userName, string password) { Browser.Exists(By.PartialLinkText("Login")).Click(); Browser.Exists(By.Name("Input.Email")); Browser.Exists(By.Name("Input.Email")).SendKeys(userName); Browser.Exists(By.Name("Input.Password")).SendKeys(password); Browser.Exists(By.Id("login-submit")).Click(); } private void ValidateLogout() { Browser.Exists(By.CssSelector("button.nav-link.btn.btn-link")); // Click logout button Browser.Exists(By.CssSelector("button.nav-link.btn.btn-link")).Click(); Browser.Contains("/authentication/logged-out", () => Browser.Url); Browser.True(() => Browser.FindElements(By.TagName("p")).Any(e => e.Text == "You are logged out.")); } private void ValidateFetchData() { // Can navigate to the 'fetch data' page Browser.Contains("fetchdata", () => Browser.Url); Browser.Equal("Weather forecast", () => Browser.Exists(By.TagName("h1")).Text); // Asynchronously loads and displays the table of weather forecasts Browser.Exists(By.CssSelector("table>tbody>tr")); Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count); } private void FirstTimeRegister(string userName, string password) { Browser.Exists(By.PartialLinkText("Register as a new user")).Click(); RegisterCore(userName, password); CompleteProfileDetails(); } private void CompleteProfileDetails() { Browser.Exists(By.PartialLinkText("Home")); Browser.Contains("/preferences", () => Browser.Url); Browser.Exists(By.Id("color-preference")).SendKeys("Red"); Browser.Exists(By.Id("submit-preference")).Click(); } private void RegisterCore(string userName, string password) { Browser.Exists(By.Name("Input.Email")); Browser.Exists(By.Name("Input.Email")).SendKeys(userName); Browser.Exists(By.Name("Input.Password")).SendKeys(password); Browser.Exists(By.Name("Input.ConfirmPassword")).SendKeys(password); Browser.Exists(By.Id("registerSubmit")).Click(); // We will be redirected to the RegisterConfirmation Browser.Contains("/Identity/Account/RegisterConfirmation", () => Browser.Url); try { // For some reason the test sometimes get stuck here. Given that this is not something we are testing, to avoid // this we'll retry once to minify the chances it happens on CI runs. ClickAndNavigate(By.PartialLinkText("Click here to confirm your account"), "/Identity/Account/ConfirmEmail"); } catch { ClickAndNavigate(By.PartialLinkText("Click here to confirm your account"), "/Identity/Account/ConfirmEmail"); } // Now we can login Browser.Exists(By.PartialLinkText("Login")).Click(); Browser.Exists(By.Name("Input.Email")); Browser.Exists(By.Name("Input.Email")).SendKeys(userName); Browser.Exists(By.Name("Input.Password")).SendKeys(password); Browser.Exists(By.Id("login-submit")).Click(); } private void WaitUntilLoaded(bool skipHeader = false) { Browser.Exists(By.TagName("app")); Browser.True(() => Browser.Exists(By.TagName("app")).Text != "Loading..."); if (!skipHeader) { // All pages in the text contain an h1 element. This helps us wait until the router has intercepted links as that // happens before rendering the underlying page. Browser.Exists(By.TagName("h1")); } } public static IServiceCollection SetupTestDatabase(IServiceCollection services, DbConnection connection) where TContext : DbContext { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); if (descriptor != null) { services.Remove(descriptor); } services.AddScoped(p => DbContextOptionsFactory( p, (sp, options) => options .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)) .UseSqlite(connection))); return services; } private static DbContextOptions DbContextOptionsFactory( IServiceProvider applicationServiceProvider, Action optionsAction) where TContext : DbContext { var builder = new DbContextOptionsBuilder( new DbContextOptions(new Dictionary())); builder.UseApplicationServiceProvider(applicationServiceProvider); optionsAction?.Invoke(applicationServiceProvider, builder); return builder.Options; } private void EnsureDatabaseCreated(IServiceProvider services) { using var scope = services.CreateScope(); var applicationDbContext = scope.ServiceProvider.GetService(); if (applicationDbContext?.Database?.GetPendingMigrations()?.Any() == true) { applicationDbContext?.Database?.Migrate(); } } private class JwtPayload { [JsonPropertyName("iss")] public string Issuer { get; set; } [JsonPropertyName("aud")] public string Audience { get; set; } [JsonPropertyName("client_id")] public string ClientId { get; set; } [JsonPropertyName("sub")] public string Subject { get; set; } [JsonPropertyName("idp")] public string IdentityProvider { get; set; } [JsonPropertyName("scope")] public string[] Scopes { get; set; } } } }