diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a7b245df..2279d6cc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,8 @@ To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host o On a fresh database the admin asks for the first administrator at `/setup`; every other admin page needs a signed-in user. Data Protection keeps the keys that protect sign-in cookies, anti-forgery tokens and stored secrets in `DataProtection__KeysDirectory` (default `data-protection-keys` under the content root). Put it on storage that survives restarts and upgrades and that only the host's account can read; losing it signs everyone out. Sign-in and setup posts are limited per client address by `SignInRateLimit__PermitLimit` and `SignInRateLimit__Window`, ten a minute by default. +People can also sign in through one OpenID Connect provider (Keycloak, Authentik, Microsoft Entra, Google and similar). Set `Authentication__OpenIdConnect__Authority`, `Authentication__OpenIdConnect__ClientId` and `Authentication__OpenIdConnect__ClientSecret`, optionally `Authentication__OpenIdConnect__DisplayName` for the button, and register `https:///signin-oidc` as the redirect URI at the provider. Without an authority and client id the option does not appear. Users are not created from the provider: the first sign-in links the provider's account to the user with the same email, and only if the provider marks the email as verified. Later sign-ins match on the provider's issuer and subject. A provider that never sends `email_verified`, such as Microsoft Entra, needs `Authentication__OpenIdConnect__AssumeVerifiedEmail=true`; set it only if that provider issues verified addresses alone. + ## Database migrations Each provider has its own `DbContext` subclass and migrations folder (ADR-0005). After changing the model, add a migration for both; no database needs to be running, the design-time factories build the model on their own. diff --git a/Directory.Packages.props b/Directory.Packages.props index 039ae2491..0522f2e33 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/src/FluentCMS.Admin/AdminRoutes.cs b/src/FluentCMS.Admin/AdminRoutes.cs index a6ffe0657..7b10a64ae 100644 --- a/src/FluentCMS.Admin/AdminRoutes.cs +++ b/src/FluentCMS.Admin/AdminRoutes.cs @@ -6,6 +6,10 @@ public static class AdminRoutes public const string Setup = "/setup"; public const string SignIn = "/sign-in"; public const string SignOut = "/sign-out"; + public const string ExternalSignIn = "/sign-in/external"; + + // Not a page: the host's endpoint that sends the browser to the external provider. + public const string ExternalSignInStart = "/sign-in/external/start"; // Only a path on this site is followed after sign-in, so a crafted link cannot send someone elsewhere. public static bool IsLocal(string? url) diff --git a/src/FluentCMS.Admin/Components/Pages/ExternalSignIn.razor b/src/FluentCMS.Admin/Components/Pages/ExternalSignIn.razor new file mode 100644 index 000000000..6fe8a42dd --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/ExternalSignIn.razor @@ -0,0 +1,33 @@ +@page "/sign-in/external" +@layout AccountLayout +@attribute [AllowAnonymous] +@inject IUserSignIn UserSignIn +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@* Where the browser returns from the external provider. A success moves on at once; only a refusal renders. *@ +@L["Sign-in did not work"] + + + +

@L["Sign-in did not work"]

+ @L["Your account at the identity provider needs a verified email that belongs to an active FluentCMS user. Ask an administrator to add you, then try again."] +
+ + @L["Back to sign in"] + +
+ +@code { + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + var result = await UserSignIn.SignInExternalAsync(); + if (result.IsSuccess) + { + Navigation.NavigateTo(AdminRoutes.IsLocal(ReturnUrl) ? ReturnUrl! : ""); + } + } +} diff --git a/src/FluentCMS.Admin/Components/Pages/SignIn.razor b/src/FluentCMS.Admin/Components/Pages/SignIn.razor index 69232ebd3..007dfb787 100644 --- a/src/FluentCMS.Admin/Components/Pages/SignIn.razor +++ b/src/FluentCMS.Admin/Components/Pages/SignIn.razor @@ -14,7 +14,7 @@

@L["Sign in"]

@L["Enter your email and password to sign in to the admin."] - +
@if (_failed) @@ -29,11 +29,28 @@ + @if (_providers.Count > 0) + { +
+ @L["or"] +
+ @foreach (var provider in _providers) + { + @* A plain form: the host's endpoint starts the redirect to the provider, which a page cannot do. *@ +
+ + + + + + } + }
@code { private bool _failed; + private IReadOnlyList _providers = []; [SupplyParameterFromForm(FormName = "sign-in")] private SignInForm Form { get; set; } = default!; @@ -50,6 +67,8 @@ { Navigation.NavigateTo("setup"); } + + _providers = await UserSignIn.GetExternalProvidersAsync(); } private async Task SubmitAsync() diff --git a/src/FluentCMS.Core/Identity/IUserSignIn.cs b/src/FluentCMS.Core/Identity/IUserSignIn.cs index 29ad038c2..eb0ae7e2a 100644 --- a/src/FluentCMS.Core/Identity/IUserSignIn.cs +++ b/src/FluentCMS.Core/Identity/IUserSignIn.cs @@ -9,4 +9,13 @@ public interface IUserSignIn Task SignInAsync(string email, string password, CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); + + // The external identity providers the owner configured; empty when there are none (P10). + Task> GetExternalProvidersAsync(CancellationToken cancellationToken = default); + + // Completes the sign-in an external provider has just vouched for. There is no self-registration in M1: + // an identity that matches no user, by an earlier link or by a verified email, fails and nothing is linked. + Task SignInExternalAsync(CancellationToken cancellationToken = default); } + +public sealed record ExternalProvider(string Scheme, string DisplayName); diff --git a/src/FluentCMS.Host/FluentCMS.Host.csproj b/src/FluentCMS.Host/FluentCMS.Host.csproj index 43698c03f..d8f414a95 100644 --- a/src/FluentCMS.Host/FluentCMS.Host.csproj +++ b/src/FluentCMS.Host/FluentCMS.Host.csproj @@ -15,4 +15,8 @@
+ + + + diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 7abbd4b20..94add0f27 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -10,6 +10,7 @@ using FluentCMS.Infrastructure.Persistence; using FluentCMS.Mcp; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Console; @@ -42,21 +43,23 @@ public static WebApplication BuildWebApplication(string[] args, Action(); - AddCookieSignIn(builder.Services); + var provider = AddCookieSignIn(builder.Services, builder.Configuration, builder.Environment); builder.Services.AddSignInRateLimit(builder.Configuration); builder.Services.AddHealthChecks(); builder.Services.AddLocalization(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); var app = builder.Build(); - app.UseSecurityHeaders(); + app.UseSecurityHeaders(provider.IsConfigured ? [provider.AuthorityOrigin] : []); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + KeepStatusCodePagesToPageLoads(app); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); MapStaticAssets(app); app.MapHealthChecks("/health"); + app.MapExternalSignIn(); // Every admin page needs a signed-in user unless it allows anonymous access itself (setup, sign-in, not found). app.MapRazorComponents() @@ -66,9 +69,15 @@ public static WebApplication BuildWebApplication(string[] args, Action() ?? new OpenIdConnectProviderOptions(); + services.Configure(providerSection); + + var authentication = services.AddAuthentication(IdentityConstants.ApplicationScheme); + authentication.AddIdentityCookies(); + authentication.AddOpenIdConnectProvider(provider, environment); services.ConfigureApplicationCookie(options => { options.LoginPath = AdminRoutes.SignIn; @@ -77,6 +86,24 @@ private static void AddCookieSignIn(IServiceCollection services) new IdentityBuilder(typeof(FluentCmsUser), typeof(FluentCmsRole), services).AddSignInManager(); services.AddAuthorization(); services.AddScoped(); + return provider; + } + + // The not-found page is for page loads. Re-running a rejected post against it would check the anti-forgery token + // a second time, which throws once the first check has failed, and would call the rejected form "not found". + private static void KeepStatusCodePagesToPageLoads(WebApplication app) + { + app.Use((context, next) => + { + if (!HttpMethods.IsGet(context.Request.Method) + && !HttpMethods.IsHead(context.Request.Method) + && context.Features.Get() is { } statusCodePages) + { + statusCodePages.Enabled = false; + } + + return next(context); + }); } // The manifest is named after the entry assembly, which is the test host under xunit; without it the files are served unfingerprinted. diff --git a/src/FluentCMS.Host/Identity/CookieUserSignIn.cs b/src/FluentCMS.Host/Identity/CookieUserSignIn.cs index e59ec35ee..8f21519c2 100644 --- a/src/FluentCMS.Host/Identity/CookieUserSignIn.cs +++ b/src/FluentCMS.Host/Identity/CookieUserSignIn.cs @@ -4,7 +4,9 @@ using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; namespace FluentCMS.Host.Identity; @@ -13,12 +15,17 @@ internal sealed class CookieUserSignIn( SignInManager signInManager, IUnitOfWork unitOfWork, IAuditLog auditLog, - IHttpContextAccessor accessor) : IUserSignIn + IHttpContextAccessor accessor, + IOptions providerOptions) : IUserSignIn { private const string SubjectType = "user"; private static readonly Result Failed = Result.Failure("sign_in_failed", "The email or password is incorrect, or the account is locked."); + private static readonly Result ExternalFailed = Result.Failure( + "external_sign_in_failed", + "The identity provider's account matches no active user with the same verified email."); + public async Task SignInAsync(string email, string password, CancellationToken cancellationToken = default) { var user = await signInManager.UserManager.FindByEmailAsync(email); @@ -62,6 +69,68 @@ public async Task SignOutAsync(CancellationToken cancellationToken = default) await signInManager.SignOutAsync(); } + public async Task> GetExternalProvidersAsync(CancellationToken cancellationToken = default) + { + var schemes = await signInManager.GetExternalAuthenticationSchemesAsync(); + return schemes.Select(scheme => new ExternalProvider(scheme.Name, scheme.DisplayName ?? scheme.Name)).ToList(); + } + + public async Task SignInExternalAsync(CancellationToken cancellationToken = default) + { + var info = await signInManager.GetExternalLoginInfoAsync(); + + // The external cookie has done its job whatever happens next. + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + if (info?.Principal.FindFirst(ClaimTypes.NameIdentifier) is not { } subject) + { + return ExternalFailed; + } + + // A subject is unique only at the issuer that assigned it, so the link records both. + var login = new UserLoginInfo(subject.Issuer, subject.Value, info.ProviderDisplayName); + var users = signInManager.UserManager; + var user = await users.FindByLoginAsync(login.LoginProvider, login.ProviderKey); + var isNewLink = user is null; + if (user is null && VerifiedEmail(info.Principal) is { } email) + { + user = await users.FindByEmailAsync(email); + } + + if (user is null || await users.IsLockedOutAsync(user) || !await signInManager.CanSignInAsync(user)) + { + return ExternalFailed; + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + if (isNewLink && !(await users.AddLoginAsync(user, login)).Succeeded) + { + return ExternalFailed; + } + + HttpContext.User = await signInManager.CreateUserPrincipalAsync(user); + if (isNewLink) + { + await auditLog.AppendAsync(new NewAuditEvent("user.login_linked") { SubjectType = SubjectType, SubjectId = user.Id }, cancellationToken); + } + + await auditLog.AppendAsync(new NewAuditEvent("user.signed_in") { SubjectType = SubjectType, SubjectId = user.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + await signInManager.SignInWithClaimsAsync(user, isPersistent: false, [new Claim(ClaimTypes.AuthenticationMethod, info.LoginProvider)]); + return Result.Success(); + } + + // Linking by email is only as safe as the provider's word that the address belongs to the person signing in. + private string? VerifiedEmail(ClaimsPrincipal principal) + { + var email = principal.FindFirstValue(ClaimTypes.Email); + var verified = providerOptions.Value.AssumeVerifiedEmail + || string.Equals(principal.FindFirstValue("email_verified"), "true", StringComparison.OrdinalIgnoreCase); + + return verified && !string.IsNullOrWhiteSpace(email) ? email : null; + } + private HttpContext HttpContext => accessor.HttpContext ?? throw new InvalidOperationException("Cookie sign-in runs inside an HTTP request."); } diff --git a/src/FluentCMS.Host/Identity/ExternalSignIn.cs b/src/FluentCMS.Host/Identity/ExternalSignIn.cs new file mode 100644 index 000000000..42017abbb --- /dev/null +++ b/src/FluentCMS.Host/Identity/ExternalSignIn.cs @@ -0,0 +1,65 @@ +using FluentCMS.Admin; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace FluentCMS.Host.Identity; + +internal static class ExternalSignIn +{ + public static void AddOpenIdConnectProvider(this AuthenticationBuilder authentication, OpenIdConnectProviderOptions provider, IHostEnvironment environment) + { + if (!provider.IsConfigured) + { + return; + } + + // Caught here rather than by the handler, which would fail every request once the host is running. + var isDevelopment = environment.IsDevelopment(); + if (!Uri.TryCreate(provider.Authority, UriKind.Absolute, out var authority) + || (authority.Scheme != Uri.UriSchemeHttps && !(isDevelopment && authority.Scheme == Uri.UriSchemeHttp))) + { + throw new InvalidOperationException( + $"{OpenIdConnectProviderOptions.SectionName}:Authority must be an absolute HTTPS URL; plain HTTP is accepted only in Development."); + } + + // The provider's answer lands in Identity's external cookie; the admin page then signs the matching user in. + authentication.AddOpenIdConnect(OpenIdConnectProviderOptions.Scheme, provider.DisplayName, options => + { + options.SignInScheme = IdentityConstants.ExternalScheme; + options.RequireHttpsMetadata = !isDevelopment; + options.Authority = provider.Authority; + options.ClientId = provider.ClientId; + options.ClientSecret = provider.ClientSecret; + options.ResponseType = OpenIdConnectResponseType.Code; + options.ResponseMode = OpenIdConnectResponseMode.FormPost; + options.UsePkce = true; + options.Scope.Add("email"); + }); + } + + // The sign-in page's "Sign in with ..." form posts here; the form binding makes the anti-forgery token required. + public static void MapExternalSignIn(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost(AdminRoutes.ExternalSignInStart, async ( + [FromForm] string provider, + [FromForm] string? returnUrl, + SignInManager signInManager) => + { + var schemes = await signInManager.GetExternalAuthenticationSchemesAsync(); + if (!schemes.Any(scheme => scheme.Name == provider)) + { + return Results.NotFound(); + } + + var callback = AdminRoutes.IsLocal(returnUrl) + ? $"{AdminRoutes.ExternalSignIn}?ReturnUrl={Uri.EscapeDataString(returnUrl!)}" + : AdminRoutes.ExternalSignIn; + var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, callback); + return Results.Challenge(properties, [provider]); + }); + } +} diff --git a/src/FluentCMS.Host/Identity/OpenIdConnectProviderOptions.cs b/src/FluentCMS.Host/Identity/OpenIdConnectProviderOptions.cs new file mode 100644 index 000000000..ff3575523 --- /dev/null +++ b/src/FluentCMS.Host/Identity/OpenIdConnectProviderOptions.cs @@ -0,0 +1,25 @@ +namespace FluentCMS.Host.Identity; + +// The one external identity provider an owner can configure (USR-01). Off until the authority and client id are set (P10). +public sealed class OpenIdConnectProviderOptions +{ + public const string SectionName = "Authentication:OpenIdConnect"; + public const string Scheme = "oidc"; + + public string? Authority { get; set; } + + public string? ClientId { get; set; } + + // From the environment or a secret store, never from a file in the repository. + public string? ClientSecret { get; set; } + + public string DisplayName { get; set; } = "single sign-on"; + + // For providers that never send email_verified (Microsoft Entra). Only safe when the provider issues verified addresses alone, + // because the first sign-in links to the user with the same email. + public bool AssumeVerifiedEmail { get; set; } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(Authority) && !string.IsNullOrWhiteSpace(ClientId); + + public string AuthorityOrigin => new Uri(Authority!).GetLeftPart(UriPartial.Authority); +} diff --git a/src/FluentCMS.Host/Security/SecurityHeaders.cs b/src/FluentCMS.Host/Security/SecurityHeaders.cs index 1c3e018de..a20ecf474 100644 --- a/src/FluentCMS.Host/Security/SecurityHeaders.cs +++ b/src/FluentCMS.Host/Security/SecurityHeaders.cs @@ -4,12 +4,18 @@ internal static class SecurityHeaders { // Everything from this origin and nothing inline: Blazor's script, the admin's module and the stylesheet are files (SEC-07). // Blazor's default reconnect display injects a style element, which this blocks; it still works, unstyled, until the shell brings its own. - public const string ContentSecurityPolicy = - "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; " + - "script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'"; + // Browsers also hold the redirect that leaves a form post to form-action, so an external provider's origin is added there. + public static string ContentSecurityPolicy(IEnumerable formTargets) + { + return "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; " + + $"form-action {string.Join(' ', formTargets.Prepend("'self'"))}; " + + "script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'"; + } - public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, IEnumerable formTargets) { + var policy = ContentSecurityPolicy(formTargets); + return app.Use((context, next) => { // Set as the response starts, so status code pages and every endpoint get them, and the policy replaces @@ -17,7 +23,7 @@ public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder ap context.Response.OnStarting(() => { var headers = context.Response.Headers; - headers.ContentSecurityPolicy = ContentSecurityPolicy; + headers.ContentSecurityPolicy = policy; headers.XContentTypeOptions = "nosniff"; headers["Referrer-Policy"] = "same-origin"; return Task.CompletedTask; diff --git a/tests/FluentCMS.IntegrationTests/AdminHost.cs b/tests/FluentCMS.IntegrationTests/AdminHost.cs index 60ddbe74c..8d321a057 100644 --- a/tests/FluentCMS.IntegrationTests/AdminHost.cs +++ b/tests/FluentCMS.IntegrationTests/AdminHost.cs @@ -61,13 +61,15 @@ public static async Task StartAsync( TemporaryInstall install, string[]? args = null, IDictionary? settings = null, - CookieContainer? cookies = null) + CookieContainer? cookies = null, + Action? configure = null) { var app = FluentCmsHost.BuildWebApplication(args ?? [], builder => { builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Configuration.AddInMemoryCollection(install.Configuration); builder.Configuration.AddInMemoryCollection(settings ?? new Dictionary()); + configure?.Invoke(builder); }); await app.StartAsync(); @@ -82,7 +84,8 @@ public async Task CreateFirstAdministratorAsync(NewUser? user = null) } // Fetches the page with the form first, as a browser would, for the anti-forgery cookie and the token that goes with it. - public async Task SubmitAsync(string path, string handler, IReadOnlyDictionary fields, string? from = null) + // A null handler is for a form that posts to a host endpoint rather than to a page's named form. + public async Task SubmitAsync(string path, string? handler, IReadOnlyDictionary fields, string? from = null) { var page = await Client.GetStringAsync(from ?? path); var token = AntiforgeryToken().Match(page); @@ -91,8 +94,11 @@ public async Task SubmitAsync(string path, string handler, var form = new Dictionary(fields) { ["__RequestVerificationToken"] = WebUtility.HtmlDecode(token.Groups[1].Value), - ["_handler"] = handler, }; + if (handler is not null) + { + form["_handler"] = handler; + } return await Client.PostAsync(path, new FormUrlEncodedContent(form)); } diff --git a/tests/FluentCMS.IntegrationTests/Identity/ExternalSignInTests.cs b/tests/FluentCMS.IntegrationTests/Identity/ExternalSignInTests.cs new file mode 100644 index 000000000..be10b3c03 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Identity/ExternalSignInTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Identity; + +public sealed partial class ExternalSignInTests +{ + private const string Start = "/sign-in/external/start"; + private static readonly NewUser Editor = new("editor@example.com", "Editor", "An0ther!Passw0rd"); + + [Fact] + public async Task Without_a_provider_the_sign_in_page_offers_none_and_the_policy_allows_only_this_origin() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var signIn = await host.Client.GetAsync("/sign-in"); + var started = await host.SubmitAsync(Start, null, StartFields("oidc"), from: "/sign-in"); + + Assert.DoesNotContain("sign-in/external/start", await signIn.Content.ReadAsStringAsync()); + Assert.Contains("form-action 'self';", Policy(signIn)); + Assert.Equal(HttpStatusCode.NotFound, started.StatusCode); + } + + [Fact] + public async Task A_user_with_a_matching_verified_email_signs_in_through_the_provider_and_returns_to_the_route() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await StartAsync(install, provider); + var ownerId = await host.CreateFirstAdministratorAsync(); + + var signIn = await host.Client.GetAsync("/sign-in"); + var html = await signIn.Content.ReadAsStringAsync(); + Assert.Contains("Sign in with Test provider", html); + Assert.Contains($"form-action 'self' {provider.Authority};", Policy(signIn)); + + var signedIn = await SignInThroughProviderAsync(host, returnUrl: "/ui-spike"); + + Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); + Assert.Equal("/ui-spike", host.LocationOf(signedIn)); + Assert.True(host.IsSignedIn); + Assert.Contains(">Owner", await host.Client.GetStringAsync("/")); + + var rows = await AuditRowsAsync(host); + Assert.Equal(["user.login_linked", "user.signed_in"], rows.Select(row => row.Operation)); + Assert.All(rows, row => + { + Assert.Equal(InitiatorKinds.User, row.InitiatorKind); + Assert.Equal(ownerId, row.InitiatorId); + Assert.Equal(AdminHost.Owner.DisplayName, row.InitiatorName); + Assert.Equal(ownerId, row.SubjectId); + }); + } + + [Fact] + public async Task Later_sign_ins_are_recognised_by_issuer_and_subject_even_after_the_email_changes() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await StartAsync(install, provider); + await host.CreateFirstAdministratorAsync(); + + await SignInThroughProviderAsync(host); + await host.SubmitAsync("/sign-out", "sign-out", new Dictionary(), from: "/"); + Assert.False(host.IsSignedIn); + + provider.Identity = provider.Identity with { Email = "owner@renamed.example" }; + var again = await SignInThroughProviderAsync(host); + + Assert.Equal("/", host.LocationOf(again)); + Assert.Contains(">Owner", await host.Client.GetStringAsync("/")); + Assert.Single(await AuditRowsAsync(host), row => row.Operation == "user.login_linked"); + + await using var scope = host.App.Services.CreateAsyncScope(); + var login = Assert.Single(await scope.ServiceProvider.GetRequiredService().UserLogins.ToListAsync()); + Assert.Equal(provider.Authority, login.LoginProvider); + Assert.Equal("subject-1", login.ProviderKey); + } + + [Fact] + public async Task An_identity_without_an_active_user_and_a_verified_email_is_refused_and_nothing_is_linked() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await StartAsync(install, provider); + await host.CreateFirstAdministratorAsync(); + await using (var scope = host.App.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + await directory.DisableAsync((await directory.CreateAsync(Editor)).Value); + } + + var identities = new[] + { + new ProviderIdentity("subject-unknown", "nobody@example.com"), + new ProviderIdentity("subject-unverified", AdminHost.Owner.Email, EmailVerified: false), + new ProviderIdentity("subject-silent", AdminHost.Owner.Email, EmailVerified: null), + new ProviderIdentity("subject-disabled", Editor.Email), + }; + + foreach (var identity in identities) + { + provider.Identity = identity; + var refused = await SignInThroughProviderAsync(host); + + Assert.Equal(HttpStatusCode.OK, refused.StatusCode); + Assert.Contains("data-testid=\"external-sign-in-error\"", await refused.Content.ReadAsStringAsync()); + Assert.False(host.IsSignedIn); + } + + await using var check = host.App.Services.CreateAsyncScope(); + Assert.Empty(await check.ServiceProvider.GetRequiredService().UserLogins.ToListAsync()); + Assert.Empty(await AuditRowsAsync(host)); + } + + [Fact] + public async Task An_owner_can_trust_a_provider_that_never_sends_email_verified() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await StartAsync(install, provider, assumeVerifiedEmail: true); + await host.CreateFirstAdministratorAsync(); + provider.Identity = new ProviderIdentity("subject-entra", AdminHost.Owner.Email, EmailVerified: null); + + var signedIn = await SignInThroughProviderAsync(host); + + Assert.Equal("/", host.LocationOf(signedIn)); + Assert.True(host.IsSignedIn); + } + + [Fact] + public async Task Starting_an_external_sign_in_needs_an_antiforgery_token() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + await using var host = await StartAsync(install, provider); + await host.CreateFirstAdministratorAsync(); + + var started = await host.Client.PostAsync(Start, new FormUrlEncodedContent(StartFields("oidc"))); + + Assert.Equal(HttpStatusCode.BadRequest, started.StatusCode); + Assert.Null(started.Headers.Location); + } + + [Fact] + public async Task A_provider_on_plain_http_fails_at_startup_outside_development() + { + await using var provider = await TestOpenIdProvider.StartAsync(); + using var install = new TemporaryInstall(); + + var failure = await Assert.ThrowsAsync(() => + AdminHost.StartAsync(install, settings: ProviderSettings(provider))); + + Assert.Contains("Authentication:OpenIdConnect:Authority", failure.Message); + } + + // The local provider speaks plain HTTP, which the host accepts only in Development; and a client that is not a browser + // does not send Secure cookies over HTTP, so the handler's correlation and nonce cookies follow the request instead. + private static Task StartAsync(TemporaryInstall install, TestOpenIdProvider provider, bool assumeVerifiedEmail = false) + { + return AdminHost.StartAsync(install, ["--environment", "Development"], ProviderSettings(provider, assumeVerifiedEmail), configure: builder => + builder.Services.PostConfigure("oidc", options => + { + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + })); + } + + private static Dictionary ProviderSettings(TestOpenIdProvider provider, bool assumeVerifiedEmail = false) + { + return new Dictionary + { + ["Authentication:OpenIdConnect:Authority"] = provider.Authority, + ["Authentication:OpenIdConnect:ClientId"] = TestOpenIdProvider.ClientId, + ["Authentication:OpenIdConnect:ClientSecret"] = TestOpenIdProvider.ClientSecret, + ["Authentication:OpenIdConnect:DisplayName"] = "Test provider", + ["Authentication:OpenIdConnect:AssumeVerifiedEmail"] = assumeVerifiedEmail.ToString(), + }; + } + + // Plays the browser: the start post, the provider's authorize page, its form posted back, and the admin's completion page. + private static async Task SignInThroughProviderAsync(AdminHost host, string returnUrl = "") + { + var started = await host.SubmitAsync(Start, null, StartFields("oidc", returnUrl), from: "/sign-in"); + Assert.Equal(HttpStatusCode.Redirect, started.StatusCode); + + var authorize = await host.Client.GetStringAsync(started.Headers.Location); + var action = WebUtility.HtmlDecode(FormAction().Match(authorize).Groups[1].Value); + var fields = HiddenField().Matches(authorize) + .ToDictionary(match => match.Groups[1].Value, match => WebUtility.HtmlDecode(match.Groups[2].Value)); + + var callback = await host.Client.PostAsync(action, new FormUrlEncodedContent(fields)); + Assert.Equal(HttpStatusCode.Redirect, callback.StatusCode); + + return await host.Client.GetAsync(host.LocationOf(callback)); + } + + private static Dictionary StartFields(string provider, string returnUrl = "") + { + return new Dictionary { ["provider"] = provider, ["returnUrl"] = returnUrl }; + } + + private static string Policy(HttpResponseMessage response) + { + return Assert.Single(response.Headers.GetValues("Content-Security-Policy")); + } + + private static async Task> AuditRowsAsync(AdminHost host) + { + await using var scope = host.App.Services.CreateAsyncScope(); + var rows = await scope.ServiceProvider.GetRequiredService().AuditEvents.ToListAsync(); + return rows.OrderBy(row => row.Timestamp).ToList(); + } + + [GeneratedRegex("
_grants = new(); + private readonly WebApplication _app; + + private TestOpenIdProvider(WebApplication app) + { + _app = app; + app.MapGet("/.well-known/openid-configuration", Discovery); + app.MapGet("/jwks", Keys); + app.MapGet("/authorize", Authorize); + app.MapPost("/token", (Delegate)TokenAsync); + } + + public ProviderIdentity Identity { get; set; } = new("subject-1", "owner@example.com"); + + public string Authority => _app.Urls.Single(); + + public static async Task StartAsync() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + + var provider = new TestOpenIdProvider(builder.Build()); + await provider._app.StartAsync(); + return provider; + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + _rsa.Dispose(); + } + + private static string Issuer(HttpContext context) => $"{context.Request.Scheme}://{context.Request.Host}"; + + private static IResult Discovery(HttpContext context) + { + var issuer = Issuer(context); + return Results.Json(new Dictionary + { + ["issuer"] = issuer, + ["authorization_endpoint"] = issuer + "/authorize", + ["token_endpoint"] = issuer + "/token", + ["jwks_uri"] = issuer + "/jwks", + ["response_types_supported"] = new[] { "code" }, + ["response_modes_supported"] = new[] { "form_post" }, + ["subject_types_supported"] = new[] { "public" }, + ["id_token_signing_alg_values_supported"] = new[] { SecurityAlgorithms.RsaSha256 }, + ["code_challenge_methods_supported"] = new[] { "S256" }, + ["token_endpoint_auth_methods_supported"] = new[] { "client_secret_post" }, + }); + } + + private IResult Keys() + { + var parameters = _rsa.ExportParameters(includePrivateParameters: false); + return Results.Json(new Dictionary + { + ["keys"] = new[] + { + new Dictionary + { + ["kty"] = "RSA", + ["use"] = "sig", + ["alg"] = SecurityAlgorithms.RsaSha256, + ["kid"] = KeyId, + ["n"] = Base64UrlEncoder.Encode(parameters.Modulus), + ["e"] = Base64UrlEncoder.Encode(parameters.Exponent), + }, + }, + }); + } + + // Signs the current identity in at once and answers with the form a browser would post back to the client. + private IResult Authorize(HttpContext context) + { + var query = context.Request.Query; + if (query["client_id"] != ClientId || query["response_type"] != "code" || query["code_challenge_method"] != "S256") + { + return Results.BadRequest(); + } + + var code = Guid.NewGuid().ToString("N"); + _grants[code] = new Grant(Identity, query["nonce"].ToString(), query["code_challenge"].ToString(), query["redirect_uri"].ToString()); + + var html = $""" + + + + + """; + return Results.Content(html, "text/html"); + } + + private async Task TokenAsync(HttpContext context) + { + var form = await context.Request.ReadFormAsync(); + if (form["grant_type"] != "authorization_code" + || form["client_id"] != ClientId + || form["client_secret"] != ClientSecret + || !_grants.TryRemove(form["code"].ToString(), out var grant) + || form["redirect_uri"] != grant.RedirectUri + || Sha256Base64Url(form["code_verifier"].ToString()) != grant.CodeChallenge) + { + return Results.Json(new Dictionary { ["error"] = "invalid_grant" }, statusCode: StatusCodes.Status400BadRequest); + } + + var accessToken = Guid.NewGuid().ToString("N"); + return Results.Json(new Dictionary + { + ["access_token"] = accessToken, + ["token_type"] = "Bearer", + ["expires_in"] = 300, + ["id_token"] = IdToken(Issuer(context), grant, accessToken), + }); + } + + private string IdToken(string issuer, Grant grant, string accessToken) + { + var claims = new Dictionary + { + ["sub"] = grant.Identity.Subject, + ["nonce"] = grant.Nonce, + ["at_hash"] = AtHash(accessToken), + }; + if (grant.Identity.Email is { } email) + { + claims["email"] = email; + } + + if (grant.Identity.EmailVerified is { } verified) + { + claims["email_verified"] = verified; + } + + var now = DateTime.UtcNow; + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = ClientId, + IssuedAt = now, + NotBefore = now, + Expires = now.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(_rsa) { KeyId = KeyId }, SecurityAlgorithms.RsaSha256), + }); + } + + // The left half of the SHA-256 of the access token (OpenID Connect Core 3.1.3.6). + private static string AtHash(string accessToken) + { + var hash = SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)); + return Base64UrlEncoder.Encode(hash[..(hash.Length / 2)]); + } + + private static string Sha256Base64Url(string value) + { + return Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(value))); + } + + private sealed record Grant(ProviderIdentity Identity, string Nonce, string CodeChallenge, string RedirectUri); +}