diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index ce14a9e6..fba91eff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Defaults; @@ -11,6 +10,7 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Core.Runtime; using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Extensions; using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; using CodeBeam.UltimateAuth.Policies.Registry; @@ -30,13 +30,15 @@ using CodeBeam.UltimateAuth.Server.Stores; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using CodeBeam.UltimateAuth.Credentials.Extensions; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -321,6 +323,23 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol options.FormFieldName = "__RequestVerificationToken"; }); + services.AddOptions().PostConfigure>((antiforgery, serverOptions) => + { + var server = serverOptions.Value; + + var sameSite = server.Antiforgery.CookieSameSite + ?? (server.HubDeploymentMode == UAuthHubDeploymentMode.External + ? SameSiteMode.None + : SameSiteMode.Strict); + + antiforgery.Cookie.SameSite = sameSite; + + if (sameSite == SameSiteMode.None) + { + antiforgery.Cookie.SecurePolicy = CookieSecurePolicy.Always; + } + }); + services.AddScoped(); services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs index f7f35eb3..739e4c81 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthAntiforgeryOptions.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; /// /// Configures CSRF protection for UltimateAuth endpoints. @@ -13,8 +15,15 @@ public sealed class UAuthAntiforgeryOptions /// public string TokenEndpoint { get; set; } = "/antiforgery/token"; + /// + /// Overrides the antiforgery cookie SameSite policy. + /// When null, External Hub deployments use None, other deployments retain ASP.NET Core's Strict default. + /// + public SameSiteMode? CookieSameSite { get; set; } + public UAuthAntiforgeryOptions Clone() => new() { - TokenEndpoint = TokenEndpoint + TokenEndpoint = TokenEndpoint, + CookieSameSite = CookieSameSite }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs new file mode 100644 index 00000000..0d17113c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/AntiforgeryCookiePolicyTests.cs @@ -0,0 +1,94 @@ + +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class AntiforgeryCookiePolicyTests +{ + [Theory] + [InlineData(UAuthHubDeploymentMode.Embedded, SameSiteMode.Strict)] + [InlineData(UAuthHubDeploymentMode.Integrated, SameSiteMode.Strict)] + [InlineData(UAuthHubDeploymentMode.External, SameSiteMode.None)] + public async Task AntiforgeryCookie_UsesDeploymentPolicy( + UAuthHubDeploymentMode deployment, + SameSiteMode expected) + { + await using var factory = AuthServerFactory.Create( + options => options.HubDeploymentMode = deployment); + + var antiforgery = factory.Services + .GetRequiredService>() + .Value; + + antiforgery.Cookie.SameSite.Should().Be(expected); + + if (expected == SameSiteMode.None) + { + antiforgery.Cookie.SecurePolicy + .Should().Be(CookieSecurePolicy.Always); + } + } + + [Fact] + public async Task ExternalHub_AntiforgeryTokenCookie_IsSecureAndCrossSite() + { + await using var factory = AuthServerFactory.Create( + options => options.HubDeploymentMode = + UAuthHubDeploymentMode.External); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false + }); + + using var response = await client.GetAsync( + "/auth/antiforgery/token", + TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + + response.Headers.TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + var cookie = cookies!.Single(x => + x.StartsWith( + ".AspNetCore.Antiforgery.", + StringComparison.OrdinalIgnoreCase)); + + cookie.Contains( + "samesite=none", + StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + + cookie.Split(';', StringSplitOptions.TrimEntries) + .Any(x => x.Equals( + "secure", + StringComparison.OrdinalIgnoreCase)) + .Should().BeTrue(); + } + + [Fact] + public async Task ExplicitAntiforgeryCookiePolicy_OverridesDeploymentDefault() + { + await using var factory = AuthServerFactory.Create(options => + { + options.HubDeploymentMode = UAuthHubDeploymentMode.External; + options.Antiforgery.CookieSameSite = SameSiteMode.Lax; + }); + + var antiforgery = factory.Services + .GetRequiredService>() + .Value; + + antiforgery.Cookie.SameSite.Should().Be(SameSiteMode.Lax); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs index 52d4e724..e11c38a3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Security/CsrfAttackTests.cs @@ -33,25 +33,29 @@ public async Task Login_WithoutCsrfToken_MustNotCreateSession() public async Task Login_WithForgedCsrfToken_MustNotCreateSession() { await using var factory = new AuthServerFactory(); - var attacker = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken); + var ct = TestContext.Current.CancellationToken; + + var user = await factory.CreateLoginUserAsync(ct: ct); using var victimBrowser = CreateBrowser(factory); - using var request = new HttpRequestMessage( - HttpMethod.Post, "/auth/login") + // Obtain a real antiforgery cookie and request token. + var tokens = await GetTokens(victimBrowser); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/login") { Content = JsonContent.Create(new { - identifier = attacker.Identifier, - secret = attacker.Secret + identifier = user.Identifier, + secret = user.Secret }) }; - request.Headers.TryAddWithoutValidation( - "RequestVerificationToken", - "attacker-forged-token"); + // Keep the legitimate cookie, but forge the request token. + request.Headers.TryAddWithoutValidation(tokens.HeaderName, "attacker-forged-token"); + request.Headers.TryAddWithoutValidation("Cookie", tokens.Cookie); - using var response = await victimBrowser.SendAsync(request, TestContext.Current.CancellationToken); + using var response = await victimBrowser.SendAsync(request, ct); await AssertCsrfRejected(response); AssertNoSessionCookie(response); @@ -167,28 +171,26 @@ public async Task RevokeOthers_CsrfAttack_MustNotRevokeSessions() // The attack must not revoke the other session. await AssertAuthenticated(otherBrowser); - // ATTACK 2: Forged antiforgery token. - using (var forgedRequest = new HttpRequestMessage( - HttpMethod.Post, - "/auth/me/sessions/revoke-others")) + // ATTACK 2: Valid antiforgery cookie with a forged request token. + var forgedTokens = await GetTokens(currentBrowser); + + using (var forgedRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/me/sessions/revoke-others")) { - forgedRequest.Headers.TryAddWithoutValidation( - "RequestVerificationToken", - "forged-token"); + forgedRequest.Headers.TryAddWithoutValidation(forgedTokens.HeaderName, "attacker-forged-token"); - using var forged = await currentBrowser.SendAsync( - forgedRequest, TestContext.Current.CancellationToken); + // Preserve the authenticated session cookie and + // include the legitimate antiforgery cookie. + forgedRequest.Headers.TryAddWithoutValidation("Cookie", $"{currentCookie}; {forgedTokens.Cookie}"); + using var forged = await currentBrowser.SendAsync(forgedRequest, TestContext.Current.CancellationToken); await AssertCsrfRejected(forged); } - // The second attack must not revoke the other session. + // A rejected CSRF request must not revoke the other session. await AssertAuthenticated(otherBrowser); // CONTROL: Valid CSRF token must allow the operation. - using (var valid = await CsrfTestClient.PostAsync( - currentBrowser, - "/auth/me/sessions/revoke-others")) + using (var valid = await CsrfTestClient.PostAsync(currentBrowser, "/auth/me/sessions/revoke-others")) { valid.IsSuccessStatusCode.Should().BeTrue( $"valid revoke failed: " + @@ -199,15 +201,12 @@ public async Task RevokeOthers_CsrfAttack_MustNotRevokeSessions() await AssertAuthenticated(currentBrowser); // The other session must now be revoked. - using var revokedCheck = await CsrfTestClient.PostJsonAsync( - otherBrowser, - "/auth/me/identifiers/get", - new { }); + using var revokedCheck = await CsrfTestClient.PostJsonAsync(otherBrowser, "/auth/me/identifiers/get", new { }); - revokedCheck.StatusCode.Should().Be( - HttpStatusCode.Unauthorized); + revokedCheck.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + private static async Task LoginAndGetSessionCookie( HttpClient browser, IntegrationTestUser user)