diff --git a/docs/articles/authentication-hardening.md b/docs/articles/authentication-hardening.md index d17d8ee..fb9ca64 100644 --- a/docs/articles/authentication-hardening.md +++ b/docs/articles/authentication-hardening.md @@ -27,6 +27,28 @@ Before deployment, confirm: - Confirm login, callback, logout, and access-denied flows use the public origin. - Test security headers, HTTPS redirection, and callbacks behind the deployed proxy. +## Authentication Cookie Secure Policy + +The local authentication cookie uses `CookieSecurePolicy.Always` by default. The cookie therefore receives the `Secure` attribute independently of the request scheme perceived by the application. Production deployments must serve authentication flows over HTTPS; a reverse-proxy or forwarded-header misconfiguration does not downgrade this cookie to a non-secure cookie. + +Local plain-HTTP authentication is available only through an explicit Development-only override: + +```json +{ + "ProjectTemplate": { + "Authentication": { + "Cookie": { + "AllowInsecureHttp": true + } + } + } +} +``` + +Place this override only in local `appsettings.Development.json`, user secrets, or another Development-only configuration source. When enabled in Development, the cookie uses `CookieSecurePolicy.SameAsRequest`, so an HTTP request can receive the cookie without the `Secure` attribute. + +Startup validation rejects `ProjectTemplate:Authentication:Cookie:AllowInsecureHttp=true` in Testing, Staging, Production, or any environment other than Development. Do not use the override to compensate for TLS, proxy, forwarded-header, certificate, or callback configuration problems. + ## Redirect, Callback, ACS, and Logout URLs - Register OIDC and OAuth redirect URIs exactly. diff --git a/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs b/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs index e0ffb77..69a2631 100644 --- a/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs +++ b/src/ProjectTemplate.Web/Authentication/Extensions/AuthenticationServiceExtensions.cs @@ -17,7 +17,8 @@ namespace ProjectTemplate.Web.Authentication.Extensions; public static class AuthenticationServiceExtensions { /// - /// Adds application authentication services based on configuration. + /// Adds application authentication services based on configuration using the secure cookie policy without an + /// environment-specific plain HTTP override. /// /// The service collection to add authentication services to. /// The application configuration source. @@ -25,12 +26,28 @@ public static class AuthenticationServiceExtensions public static IServiceCollection AddApplicationAuthentication( this IServiceCollection services, IConfiguration configuration) + { + return AddApplicationAuthentication(services, configuration, environment: null); + } + + /// + /// Adds application authentication services based on configuration and the current hosting environment. + /// + /// The service collection to add authentication services to. + /// The application configuration source. + /// The current hosting environment. + /// The same instance for chaining. + public static IServiceCollection AddApplicationAuthentication( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment? environment) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); services.AddTransient(); - services.AddSingleton, ApplicationAuthenticationOptionsValidator>(); + services.AddSingleton>( + new ApplicationAuthenticationOptionsValidator(environment)); services .AddOptions() @@ -80,7 +97,10 @@ public static IServiceCollection AddApplicationAuthentication( options.Cookie.Name = ".ProjectTemplate.Web.Authentication"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SecurePolicy = applicationAuthenticationOptions.Cookie.AllowInsecureHttp && + environment?.IsDevelopment() == true + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; }); ApplicationAuthenticationOptions applicationAuthenticationOptions = configuration diff --git a/src/ProjectTemplate.Web/Authentication/Options/ApplicationAuthenticationOptionsValidator.cs b/src/ProjectTemplate.Web/Authentication/Options/ApplicationAuthenticationOptionsValidator.cs index 69a2bd2..2ef8fe0 100644 --- a/src/ProjectTemplate.Web/Authentication/Options/ApplicationAuthenticationOptionsValidator.cs +++ b/src/ProjectTemplate.Web/Authentication/Options/ApplicationAuthenticationOptionsValidator.cs @@ -7,8 +7,11 @@ namespace ProjectTemplate.Web.Authentication.Options; /// /// Validates application authentication configuration during application startup. /// -public sealed class ApplicationAuthenticationOptionsValidator : IValidateOptions +public sealed class ApplicationAuthenticationOptionsValidator(IHostEnvironment? environment) + : IValidateOptions { + private readonly IHostEnvironment? _environment = environment; + /// public ValidateOptionsResult Validate(string? name, ApplicationAuthenticationOptions options) { @@ -28,7 +31,7 @@ public ValidateOptionsResult Validate(string? name, ApplicationAuthenticationOpt : ValidateOptionsResult.Fail(failures); } - private static void ValidateApplicationAuthentication( + private void ValidateApplicationAuthentication( ApplicationAuthenticationOptions options, ICollection failures) { @@ -47,6 +50,11 @@ private static void ValidateApplicationAuthentication( "ProjectTemplate:Authentication:DefaultSignInScheme is required.", failures); + Require( + !options.Cookie.AllowInsecureHttp || _environment?.IsDevelopment() == true, + "ProjectTemplate:Authentication:Cookie:AllowInsecureHttp may only be enabled in the Development environment.", + failures); + if (!options.Enabled) { return; diff --git a/src/ProjectTemplate.Web/Authentication/Options/ApplicationCookieAuthenticationOptions.cs b/src/ProjectTemplate.Web/Authentication/Options/ApplicationCookieAuthenticationOptions.cs index d527ba5..696e43c 100644 --- a/src/ProjectTemplate.Web/Authentication/Options/ApplicationCookieAuthenticationOptions.cs +++ b/src/ProjectTemplate.Web/Authentication/Options/ApplicationCookieAuthenticationOptions.cs @@ -39,4 +39,13 @@ public sealed class ApplicationCookieAuthenticationOptions /// Gets or sets a value indicating whether the cookie expiration should be renewed during active use. /// public bool SlidingExpiration { get; set; } = true; + + /// + /// Gets or sets a value indicating whether local Development environments may issue the authentication cookie for + /// plain HTTP requests. + /// + /// + /// The default is . This override is rejected outside the Development environment. + /// + public bool AllowInsecureHttp { get; set; } } diff --git a/src/ProjectTemplate.Web/Program.cs b/src/ProjectTemplate.Web/Program.cs index 334360e..a2ee823 100644 --- a/src/ProjectTemplate.Web/Program.cs +++ b/src/ProjectTemplate.Web/Program.cs @@ -34,7 +34,7 @@ builder.Services.AddApplicationRequestLogging(builder.Configuration); builder.Services.AddApplicationOpenTelemetry(builder.Configuration, builder.Environment); builder.Services.AddApplicationProblemDetails(builder.Environment); - builder.Services.AddApplicationAuthentication(builder.Configuration); + builder.Services.AddApplicationAuthentication(builder.Configuration, builder.Environment); builder.Services.AddApplicationAuthorization(builder.Configuration); builder.Services.AddApplicationDataAccess(builder.Configuration); diff --git a/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs b/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs new file mode 100644 index 0000000..26de2bc --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/AuthenticationCookieSecurePolicyTests.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ProjectTemplate.Web.Authentication.Extensions; +using ProjectTemplate.Web.Authentication.Options; + +namespace ProjectTemplate.Web.Tests; + +public sealed class AuthenticationCookieSecurePolicyTests +{ + [Fact] + public void CookieSecurePolicy_DefaultsToAlways() + { + using ServiceProvider serviceProvider = CreateServiceProvider( + Environments.Production, + new Dictionary()); + + CookieAuthenticationOptions options = serviceProvider + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy); + } + + [Fact] + public void CookieSecurePolicy_DevelopmentOverride_UsesSameAsRequest() + { + using ServiceProvider serviceProvider = CreateServiceProvider( + Environments.Development, + new Dictionary + { + ["ProjectTemplate:Authentication:Cookie:AllowInsecureHttp"] = "true" + }); + + CookieAuthenticationOptions options = serviceProvider + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy); + } + + [Fact] + public void CookieSecurePolicy_InsecureOverrideOutsideDevelopment_FailsValidation() + { + using ServiceProvider serviceProvider = CreateServiceProvider( + Environments.Production, + new Dictionary + { + ["ProjectTemplate:Authentication:Cookie:AllowInsecureHttp"] = "true" + }); + + OptionsValidationException exception = Assert.Throws(() => + serviceProvider + .GetRequiredService>() + .Value); + + Assert.Contains( + "ProjectTemplate:Authentication:Cookie:AllowInsecureHttp may only be enabled in the Development environment", + exception.Message, + StringComparison.Ordinal); + } + + private static ServiceProvider CreateServiceProvider( + string environmentName, + IReadOnlyDictionary configurationValues) + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues) + .Build(); + TestHostEnvironment environment = new(environmentName); + ServiceCollection services = new(); + services.AddLogging(); + services.AddApplicationAuthentication(configuration, environment); + + return services.BuildServiceProvider(validateScopes: true); + } + + private sealed class TestHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = "ProjectTemplate.Web.Tests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +}