488 lines
18 KiB
C#
488 lines
18 KiB
C#
// 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<AspNetSiteServerFixture>
|
|
{
|
|
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<ApplicationDbContext>(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<JwtPayload>(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<TContext>(IServiceCollection services, DbConnection connection) where TContext : DbContext
|
|
{
|
|
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TContext>));
|
|
if (descriptor != null)
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
|
|
services.AddScoped(p =>
|
|
DbContextOptionsFactory<TContext>(
|
|
p,
|
|
(sp, options) => options
|
|
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning))
|
|
.UseSqlite(connection)));
|
|
|
|
return services;
|
|
}
|
|
|
|
private static DbContextOptions<TContext> DbContextOptionsFactory<TContext>(
|
|
IServiceProvider applicationServiceProvider,
|
|
Action<IServiceProvider, DbContextOptionsBuilder> optionsAction)
|
|
where TContext : DbContext
|
|
{
|
|
var builder = new DbContextOptionsBuilder<TContext>(
|
|
new DbContextOptions<TContext>(new Dictionary<Type, IDbContextOptionsExtension>()));
|
|
|
|
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<ApplicationDbContext>();
|
|
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; }
|
|
}
|
|
}
|
|
}
|