Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/articles/authentication-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,37 @@ namespace ProjectTemplate.Web.Authentication.Extensions;
public static class AuthenticationServiceExtensions
{
/// <summary>
/// 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.
/// </summary>
/// <param name="services">The service collection to add authentication services to.</param>
/// <param name="configuration">The application configuration source.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance for chaining.</returns>
public static IServiceCollection AddApplicationAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
return AddApplicationAuthentication(services, configuration, environment: null);
}

/// <summary>
/// Adds application authentication services based on configuration and the current hosting environment.
/// </summary>
/// <param name="services">The service collection to add authentication services to.</param>
/// <param name="configuration">The application configuration source.</param>
/// <param name="environment">The current hosting environment.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance for chaining.</returns>
public static IServiceCollection AddApplicationAuthentication(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment? environment)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);

services.AddTransient<IClaimsTransformation, ApplicationClaimsTransformation>();
services.AddSingleton<IValidateOptions<ApplicationAuthenticationOptions>, ApplicationAuthenticationOptionsValidator>();
services.AddSingleton<IValidateOptions<ApplicationAuthenticationOptions>>(
new ApplicationAuthenticationOptionsValidator(environment));

services
.AddOptions<ApplicationAuthenticationOptions>()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ namespace ProjectTemplate.Web.Authentication.Options;
/// <summary>
/// Validates application authentication configuration during application startup.
/// </summary>
public sealed class ApplicationAuthenticationOptionsValidator : IValidateOptions<ApplicationAuthenticationOptions>
public sealed class ApplicationAuthenticationOptionsValidator(IHostEnvironment? environment)
: IValidateOptions<ApplicationAuthenticationOptions>
{
private readonly IHostEnvironment? _environment = environment;

/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ApplicationAuthenticationOptions options)
{
Expand All @@ -28,7 +31,7 @@ public ValidateOptionsResult Validate(string? name, ApplicationAuthenticationOpt
: ValidateOptionsResult.Fail(failures);
}

private static void ValidateApplicationAuthentication(
private void ValidateApplicationAuthentication(
ApplicationAuthenticationOptions options,
ICollection<string> failures)
{
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ public sealed class ApplicationCookieAuthenticationOptions
/// Gets or sets a value indicating whether the cookie expiration should be renewed during active use.
/// </summary>
public bool SlidingExpiration { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether local Development environments may issue the authentication cookie for
/// plain HTTP requests.
/// </summary>
/// <remarks>
/// The default is <see langword="false" />. This override is rejected outside the Development environment.
/// </remarks>
public bool AllowInsecureHttp { get; set; }
}
2 changes: 1 addition & 1 deletion src/ProjectTemplate.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string?>());

CookieAuthenticationOptions options = serviceProvider
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
.Get(CookieAuthenticationDefaults.AuthenticationScheme);

Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
}

[Fact]
public void CookieSecurePolicy_DevelopmentOverride_UsesSameAsRequest()
{
using ServiceProvider serviceProvider = CreateServiceProvider(
Environments.Development,
new Dictionary<string, string?>
{
["ProjectTemplate:Authentication:Cookie:AllowInsecureHttp"] = "true"
});

CookieAuthenticationOptions options = serviceProvider
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
.Get(CookieAuthenticationDefaults.AuthenticationScheme);

Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
}

[Fact]
public void CookieSecurePolicy_InsecureOverrideOutsideDevelopment_FailsValidation()
{
using ServiceProvider serviceProvider = CreateServiceProvider(
Environments.Production,
new Dictionary<string, string?>
{
["ProjectTemplate:Authentication:Cookie:AllowInsecureHttp"] = "true"
});

OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
serviceProvider
.GetRequiredService<IOptions<ApplicationAuthenticationOptions>>()
.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<string, string?> 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();
}
}