diff --git a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Layout/MainLayout.razor b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Layout/MainLayout.razor index 8d5f21f..43fdbef 100644 --- a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Layout/MainLayout.razor +++ b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Layout/MainLayout.razor @@ -7,7 +7,6 @@
- Platform: @(OperatingSystem.IsBrowser() ? "Wasm" : "Server") | BitzArt.Blazor.Cookies.SampleApp
diff --git a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Cookies.razor b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Cookies.razor index e25a0c9..902bd50 100644 --- a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Cookies.razor +++ b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Cookies.razor @@ -1,15 +1,31 @@ @page "/cookies" +@rendermode InteractiveAuto @inject ICookieService CookieService -Blazor.Cookies | Client-side rendered page +Blazor.Cookies | Interactively rendered page + +
+ Current Renderer: @RendererInfo.Name +
-
- - -
+
+ +
+ SameSite Mode: + + @foreach(var mode in _sameSiteModeValues) + { + + } + +
+ +
+ +
@@ -22,6 +38,15 @@
@code { + private IEnumerable _sameSiteModeValues = + [ + SameSiteMode.None, + SameSiteMode.Lax, + SameSiteMode.Strict + ]; + + private SameSiteMode? sameSiteMode = null; + private IEnumerable? cookies; private JsonSerializerOptions cookiesSerializerOptions = new() @@ -43,7 +68,7 @@ { var expiration = DateTimeOffset.UtcNow.AddDays(1); - if (key is not null && value is not null) await CookieService.SetAsync(key, value, expiration); + if (key is not null && value is not null) await CookieService.SetAsync(key, value, expiration, sameSiteMode: sameSiteMode); await LoadCookiesAsync(); } diff --git a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Home.razor b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Home.razor index 2e0e88d..dd43de6 100644 --- a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Home.razor +++ b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp.Client/Components/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/" +@rendermode InteractiveAuto Blazor.Cookies | Home diff --git a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp/Components/App.razor b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp/Components/App.razor index 9ab26b6..075ae63 100644 --- a/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp/Components/App.razor +++ b/sample/BitzArt.Blazor.Cookies.SampleApp/BitzArt.Blazor.Cookies.SampleApp/Components/App.razor @@ -13,7 +13,7 @@ - + diff --git a/src/BitzArt.Blazor.Cookies.Server/Extensions/AddBlazorCookiesExtension.cs b/src/BitzArt.Blazor.Cookies.Server/Extensions/AddBlazorCookiesExtension.cs index 9c4c86a..095c42b 100644 --- a/src/BitzArt.Blazor.Cookies.Server/Extensions/AddBlazorCookiesExtension.cs +++ b/src/BitzArt.Blazor.Cookies.Server/Extensions/AddBlazorCookiesExtension.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using BitzArt.Blazor.Cookies.Server; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/src/BitzArt.Blazor.Cookies.Server/Extensions/SameSiteModeExtensions.cs b/src/BitzArt.Blazor.Cookies.Server/Extensions/SameSiteModeExtensions.cs new file mode 100644 index 0000000..c4f993c --- /dev/null +++ b/src/BitzArt.Blazor.Cookies.Server/Extensions/SameSiteModeExtensions.cs @@ -0,0 +1,18 @@ +namespace BitzArt.Blazor.Cookies.Server; + +internal static class SameSiteModeExtensions +{ + /// + /// Converts BitzArt.Blazor.Cookies.SameSiteMode values to Microsoft.AspNetCore.Http.SameSiteMode values + /// + public static Microsoft.AspNetCore.Http.SameSiteMode ToHttp(this BitzArt.Blazor.Cookies.SameSiteMode? sameSiteMode) + => sameSiteMode switch + { + SameSiteMode.None => Microsoft.AspNetCore.Http.SameSiteMode.None, + SameSiteMode.Lax => Microsoft.AspNetCore.Http.SameSiteMode.Lax, + SameSiteMode.Strict => Microsoft.AspNetCore.Http.SameSiteMode.Strict, + null => Microsoft.AspNetCore.Http.SameSiteMode.Unspecified, + + _ => throw new ArgumentOutOfRangeException(nameof(sameSiteMode), sameSiteMode, null) + }; +} diff --git a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs index 676b929..3b081b3 100644 --- a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs +++ b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; -namespace BitzArt.Blazor.Cookies; +namespace BitzArt.Blazor.Cookies.Server; internal class HttpContextCookieService : ICookieService { @@ -11,7 +11,7 @@ internal class HttpContextCookieService : ICookieService private readonly ILogger _logger; - private IHeaderDictionary _responseHeaders { get; set; } + private IHeaderDictionary ResponseHeaders { get; set; } public HttpContextCookieService(IHttpContextAccessor httpContextAccessor, ILogger logger) { @@ -21,7 +21,7 @@ public HttpContextCookieService(IHttpContextAccessor httpContextAccessor, ILogge _requestCookies = _httpContext.Request.Cookies .Select(x => new Cookie(x.Key, x.Value)).ToDictionary(cookie => cookie.Key); - _responseHeaders = _httpContext.Features.GetRequiredFeature().Headers; + ResponseHeaders = _httpContext.Features.GetRequiredFeature().Headers; } // ======================================== GetAllAsync ======================================== @@ -42,17 +42,8 @@ public Task> GetAllAsync() // ======================================== SetAsync ======================================== - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration: null, cancellationToken); - - public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration, httpOnly: false, secure: false, cancellationToken); - - public Task SetAsync(string key, string value, bool httpOnly, bool secure, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration: null, httpOnly, secure, cancellationToken); - - public Task SetAsync(string key, string value, DateTimeOffset? expiration, bool httpOnly, bool secure, CancellationToken cancellationToken = default) - => SetAsync(new Cookie(key, value, expiration, httpOnly, secure), cancellationToken); + public Task SetAsync(string key, string value, DateTimeOffset? expiration = null, bool httpOnly = false, bool secure = false, SameSiteMode? sameSiteMode = null, CancellationToken cancellationToken = default) + => SetAsync(new Cookie(key, value, expiration, httpOnly, secure, sameSiteMode), cancellationToken); public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) { @@ -67,7 +58,8 @@ public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = defaul Expires = cookie.Expiration, Path = "/", HttpOnly = cookie.HttpOnly, - Secure = cookie.Secure + Secure = cookie.Secure, + SameSite = cookie.SameSiteMode.ToHttp() }); return Task.CompletedTask; @@ -77,7 +69,7 @@ private bool RemovePending(string key) { _logger.LogDebug("Checking for pending cookie: '{key}'", key); - var cookieValues = _responseHeaders + var cookieValues = ResponseHeaders .SetCookie .ToList(); @@ -89,7 +81,7 @@ private bool RemovePending(string key) _logger.LogDebug("Pending cookie [{key}] found, removing...", key); cookieValues.RemoveAt(i); - _responseHeaders.SetCookie = new([.. cookieValues]); + ResponseHeaders.SetCookie = new([.. cookieValues]); _logger.LogDebug("Pending cookie [{key}] removed.", key); return true; diff --git a/src/BitzArt.Blazor.Cookies/Enums/SameSiteMode.cs b/src/BitzArt.Blazor.Cookies/Enums/SameSiteMode.cs new file mode 100644 index 0000000..f4d8573 --- /dev/null +++ b/src/BitzArt.Blazor.Cookies/Enums/SameSiteMode.cs @@ -0,0 +1,25 @@ +namespace BitzArt.Blazor.Cookies; + +/// +/// SameSiteMode +/// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks +/// (CSRF). +/// +public enum SameSiteMode +{ + /// + /// Indicates the client should disable same-site restrictions. + /// + None = 0, + + /// + /// Indicates the client should send the cookie with "same-site" requests, and with + /// "cross-site" top-level navigations. + /// + Lax = 1, + + /// + /// Indicates the client should only send the cookie with "same-site" requests. + /// + Strict = 2 +} diff --git a/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs b/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs index 2794de7..f42b8c0 100644 --- a/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs +++ b/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs @@ -7,11 +7,23 @@ public interface ICookieService { /// /// Retrieves all cookies. + /// + /// Note: When retrieving a cookie, certain properties of the resulting cookie object may be unavailable. + /// This is because browsers do not expose these attributes of cookies to neither client-side or server-side code. + /// Only the cookie's key and value are accessible, with the browser keeping other attributes + /// (such as `HttpOnly`, `Secure`, and `SameSite`) hidden for security and privacy reasons. + /// /// public Task> GetAllAsync(); /// /// Retrieves a cookie by its key. + /// + /// Note: When retrieving a cookie, certain properties of the resulting cookie object may be unavailable. + /// This is because browsers do not expose these attributes of cookies to neither client-side or server-side code. + /// Only the cookie's key and value are accessible, with the browser keeping other attributes + /// (such as `HttpOnly`, `Secure`, and `SameSite`) hidden for security and privacy reasons. + /// /// /// The key of the cookie to retrieve. /// The requested cookie, or null if it does not exist. @@ -25,38 +37,20 @@ public interface ICookieService /// A task that represents the asynchronous operation. public Task RemoveAsync(string key, CancellationToken cancellationToken = default); - /// - /// The name of the cookie to set. - /// The value of the cookie to set. - /// Cancellation token. - /// A task that represents the asynchronous operation. - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default); - - /// - /// The name of the cookie to set. - /// The value of the cookie to set. - /// The cookie's expiration date. - /// Cancellation token. - /// A task that represents the asynchronous operation. - public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default); - - /// - /// The name of the cookie to set. - /// The value of the cookie to set. - /// Whether the cookie is inaccessible by client-side script. - /// Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. - /// Cancellation token. - /// A task that represents the asynchronous operation. - public Task SetAsync(string key, string value, bool httpOnly, bool secure, CancellationToken cancellationToken = default); - /// /// /// The name of the cookie to set. /// The value of the cookie to set. /// The cookie's expiration date. - /// Whether the cookie is inaccessible by client-side script. + /// Whether the cookie should be inaccessible by client-side script. /// Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. + /// + /// SameSiteMode + /// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks + /// (CSRF).
+ /// Note: Null value will result in the browser using it's default behavior. + /// /// Cancellation token. - public Task SetAsync(string key, string value, DateTimeOffset? expiration, bool httpOnly, bool secure, CancellationToken cancellationToken = default); + public Task SetAsync(string key, string value, DateTimeOffset? expiration = null, bool httpOnly = false, bool secure = false, SameSiteMode? sameSiteMode = null, CancellationToken cancellationToken = default); /// /// Adds or updates a browser cookie.

diff --git a/src/BitzArt.Blazor.Cookies/Models/Cookie.cs b/src/BitzArt.Blazor.Cookies/Models/Cookie.cs index a650b81..177ba74 100644 --- a/src/BitzArt.Blazor.Cookies/Models/Cookie.cs +++ b/src/BitzArt.Blazor.Cookies/Models/Cookie.cs @@ -2,10 +2,22 @@ /// /// Browser cookie. +/// +/// Note: When retrieving a cookie, certain properties of the resulting cookie object may be unavailable. +/// This is because browsers do not expose these attributes of cookies to neither client-side or server-side code. +/// Only the cookie's key and value are accessible, with the browser keeping other attributes +/// (such as `HttpOnly`, `Secure`, and `SameSite`) hidden for security and privacy reasons. +/// /// /// The name of the cookie. /// The value of the cookie. /// The expiration date of the cookie. /// Whether the cookie is inaccessible by client-side script. /// Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. -public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null, bool HttpOnly = false, bool Secure = false) { } +/// +/// SameSiteMode +/// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks +/// (CSRF).
+/// Note: Null value will result in the browser using it's default behavior. +/// +public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null, bool HttpOnly = false, bool Secure = false, SameSiteMode? SameSiteMode = null); diff --git a/src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs b/src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs index e5700ea..12d73cc 100644 --- a/src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs +++ b/src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs @@ -30,28 +30,19 @@ private Cookie GetCookie(string raw) // ======================================== SetAsync ======================================== - public Task SetAsync(string key, string value, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration: null, cancellationToken); - - public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration, httpOnly: false, secure: false, cancellationToken); - - public Task SetAsync(string key, string value, bool httpOnly, bool secure, CancellationToken cancellationToken = default) - => SetAsync(key, value, expiration: null, httpOnly, secure, cancellationToken); - - public Task SetAsync(string key, string value, DateTimeOffset? expiration, bool httpOnly, bool secure, CancellationToken cancellationToken = default) - => SetAsync(new Cookie(key, value, expiration, httpOnly, secure), cancellationToken); + public Task SetAsync(string key, string value, DateTimeOffset? expiration = null, bool httpOnly = false, bool secure = false, SameSiteMode? sameSiteMode = null, CancellationToken cancellationToken = default) + => SetAsync(new Cookie(key, value, expiration, httpOnly, secure, sameSiteMode), cancellationToken); public async Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) { if (cookie.HttpOnly) throw new InvalidOperationException(HttpOnlyFlagErrorMessage); if (cookie.Secure) throw new InvalidOperationException(SecureFlagErrorMessage); - await SetAsync(cookie.Key, cookie.Value, cookie.Expiration, cancellationToken); - if (string.IsNullOrWhiteSpace(cookie.Key)) throw new Exception("Key is required when setting a cookie."); - await js.InvokeVoidAsync("eval", $"document.cookie = \"{cookie.Key}={cookie.Value}; expires={cookie.Expiration:R}; path=/\""); + var cmd = JsCommand.SetCookie(cookie); + + await js.InvokeVoidAsync("eval", cmd); } private const string HttpOnlyFlagErrorMessage = $"HttpOnly cookies are not supported in this rendering environment. {CookieFlagsExplainMessage}"; diff --git a/src/BitzArt.Blazor.Cookies/Utility/JsCommand.cs b/src/BitzArt.Blazor.Cookies/Utility/JsCommand.cs new file mode 100644 index 0000000..ba474bc --- /dev/null +++ b/src/BitzArt.Blazor.Cookies/Utility/JsCommand.cs @@ -0,0 +1,27 @@ +using System.Text; + +namespace BitzArt.Blazor.Cookies; + +internal static class JsCommand +{ + public static string SetCookie(Cookie cookie) + { + var builder = new StringBuilder(); + + builder.Append("document.cookie = \""); + + builder.Append($"{cookie.Key}={cookie.Value}; "); + builder.Append($"expires={cookie.Expiration:R}; "); + builder.Append("path=/"); + + if (cookie.SameSiteMode.HasValue) + { + builder.Append("; "); + builder.Append($"SameSite={cookie.SameSiteMode.Value.ToString()}"); + } + + builder.Append('\"'); + + return builder.ToString(); + } +} diff --git a/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs index 2bc0b60..7da7b21 100644 --- a/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs +++ b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs @@ -36,7 +36,7 @@ public async Task RemoveCookie_AfterSetCookie_ShouldRemovePending() // Assert Assert.Empty(httpContext.Response.Headers); - Assert.True(httpContext.Response.Headers.SetCookie.Count == 0); + Assert.Equal(0, httpContext.Response.Headers.SetCookie.Count); } [Fact] @@ -95,6 +95,24 @@ public async Task SetCookie_WithHttpOnlyAndSecureFlags_ShouldSetHttpOnlyAndSecur Assert.Contains("secure", values.First()); } + [Theory] + [InlineData(SameSiteMode.None)] + [InlineData(SameSiteMode.Lax)] + [InlineData(SameSiteMode.Strict)] + public async Task SetCookie_WithSameSiteMode_ShouldSetSameSiteMode(SameSiteMode mode) + { + // Arrange + (var httpContext, _, var service) = CreateTestServices(); + + // Act + await service.SetAsync("key", "value", sameSiteMode: mode); + + // Assert + var values = httpContext.Features.GetRequiredFeature().Headers.SetCookie; + Assert.Single(values); + Assert.Contains($"samesite={mode.ToString().ToLower()}", values.First()); + } + private static TestServices CreateTestServices() { var httpContext = new DefaultHttpContext();