Skip to content

Commit

Permalink
SameSite Cookies (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
YuriyDurov authored Jan 9, 2025
1 parent deb1b48 commit d054a24
Show file tree
Hide file tree
Showing 13 changed files with 171 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

<main>
<div class="top-row px-4">
Platform: @(OperatingSystem.IsBrowser() ? "Wasm" : "Server") |
BitzArt.Blazor.Cookies.SampleApp
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
@page "/cookies"
@rendermode InteractiveAuto
@inject ICookieService CookieService

<PageTitle>Blazor.Cookies | Client-side rendered page</PageTitle>
<PageTitle>Blazor.Cookies | Interactively rendered page</PageTitle>

<div style="margin-top: 1rem;">
Current Renderer: @RendererInfo.Name
</div>

<div style="margin-top: 1rem;">
<InputText @bind-Value="key" placeholder="key" />
<InputText @bind-Value="value" placeholder="value" />
<div style="margin-top: 1rem;">
<button class="btn btn-primary" @onclick="SetCookieAsync">Set Cookie</button>
<button class="btn btn-primary" @onclick="RemoveCookieAsync">Remove Cookie</button>
</div>
</div>

<div style="margin-top: 1rem;">
SameSite Mode:
<InputSelect TValue="SameSiteMode?" @bind-Value="sameSiteMode">
@foreach(var mode in _sameSiteModeValues)
{
<option value="@mode">@(mode!.Value.ToString())</option>
}
</InputSelect>
</div>

<div style="margin-top: 1rem;">
<button class="btn btn-primary" @onclick="SetCookieAsync">Set Cookie</button>
<button class="btn btn-primary" @onclick="RemoveCookieAsync">Remove Cookie</button>
</div>

<div style="margin-top: 1rem;">
Expand All @@ -22,6 +38,15 @@
</div>
@code {

private IEnumerable<SameSiteMode?> _sameSiteModeValues =
[
SameSiteMode.None,
SameSiteMode.Lax,
SameSiteMode.Strict
];

private SameSiteMode? sameSiteMode = null;

private IEnumerable<Cookie>? cookies;

private JsonSerializerOptions cookiesSerializerOptions = new()
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/"
@rendermode InteractiveAuto

<PageTitle>Blazor.Cookies | Home</PageTitle>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</head>

<body>
<Routes @rendermode="InteractiveAuto"/>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace BitzArt.Blazor.Cookies.Server;

internal static class SameSiteModeExtensions
{
/// <summary>
/// Converts BitzArt.Blazor.Cookies.SameSiteMode values to Microsoft.AspNetCore.Http.SameSiteMode values
/// </summary>
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)
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<ICookieService> logger)
{
Expand All @@ -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<IHttpResponseFeature>().Headers;
ResponseHeaders = _httpContext.Features.GetRequiredFeature<IHttpResponseFeature>().Headers;
}

// ======================================== GetAllAsync ========================================
Expand All @@ -42,17 +42,8 @@ public Task<IEnumerable<Cookie>> 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)
{
Expand All @@ -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;
Expand All @@ -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();

Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions src/BitzArt.Blazor.Cookies/Enums/SameSiteMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace BitzArt.Blazor.Cookies;

/// <summary>
/// <see href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">SameSiteMode</see>
/// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks
/// (<see href="https://developer.mozilla.org/en-US/docs/Glossary/CSRF">CSRF</see>).
/// </summary>
public enum SameSiteMode
{
/// <summary>
/// Indicates the client should disable same-site restrictions.
/// </summary>
None = 0,

/// <summary>
/// Indicates the client should send the cookie with "same-site" requests, and with
/// "cross-site" top-level navigations.
/// </summary>
Lax = 1,

/// <summary>
/// Indicates the client should only send the cookie with "same-site" requests.
/// </summary>
Strict = 2
}
46 changes: 20 additions & 26 deletions src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ public interface ICookieService
{
/// <summary>
/// Retrieves all cookies.
/// <para>
/// <b>Note:</b> 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.
/// </para>
/// </summary>
public Task<IEnumerable<Cookie>> GetAllAsync();

/// <summary>
/// Retrieves a cookie by its key.
/// <para>
/// <b>Note:</b> 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.
/// </para>
/// </summary>
/// <param name="key"> The key of the cookie to retrieve. </param>
/// <returns> The requested cookie, or null if it does not exist. </returns>
Expand All @@ -25,38 +37,20 @@ public interface ICookieService
/// <returns> A task that represents the asynchronous operation. </returns>
public Task RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <inheritdoc cref="SetAsync(Cookie, CancellationToken)"/>
/// <param name="key"> The name of the cookie to set. </param>
/// <param name="value"> The value of the cookie to set. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task SetAsync(string key, string value, CancellationToken cancellationToken = default);

/// <inheritdoc cref="SetAsync(Cookie, CancellationToken)"/>
/// <param name="key"> The name of the cookie to set. </param>
/// <param name="value"> The value of the cookie to set. </param>
/// <param name="expiration"> The cookie's expiration date. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default);

/// <inheritdoc cref="SetAsync(Cookie, CancellationToken)"/>
/// <param name="key"> The name of the cookie to set. </param>
/// <param name="value"> The value of the cookie to set. </param>
/// <param name="httpOnly"> Whether the cookie is inaccessible by client-side script. </param>
/// <param name="secure"> Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task SetAsync(string key, string value, bool httpOnly, bool secure, CancellationToken cancellationToken = default);

/// <inheritdoc cref="SetAsync(Cookie, CancellationToken)"/>
/// /// <param name="key"> The name of the cookie to set. </param>
/// <param name="value"> The value of the cookie to set. </param>
/// <param name="expiration"> The cookie's expiration date. </param>
/// <param name="httpOnly"> Whether the cookie is inaccessible by client-side script. </param>
/// <param name="httpOnly"> Whether the cookie should be inaccessible by client-side script. </param>
/// <param name="secure"> Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. </param>
/// <param name="sameSiteMode">
/// <see href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">SameSiteMode</see>
/// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks
/// (<see href="https://developer.mozilla.org/en-US/docs/Glossary/CSRF">CSRF</see>). <br />
/// <b>Note:</b> Null value will result in the browser using it's default behavior.
/// </param>
/// <param name="cancellationToken"> Cancellation token. </param>
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);

/// <summary>
/// Adds or updates a browser cookie. <br/> <br/>
Expand Down
14 changes: 13 additions & 1 deletion src/BitzArt.Blazor.Cookies/Models/Cookie.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@

/// <summary>
/// Browser cookie.
/// <para>
/// <b>Note:</b> 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.
/// </para>
/// </summary>
/// <param name="Key"> The name of the cookie. </param>
/// <param name="Value"> The value of the cookie. </param>
/// <param name="Expiration"> The expiration date of the cookie. </param>
/// <param name="HttpOnly"> Whether the cookie is inaccessible by client-side script. </param>
/// <param name="Secure"> Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. </param>
public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null, bool HttpOnly = false, bool Secure = false) { }
/// <param name="SameSiteMode">
/// <see href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">SameSiteMode</see>
/// controls whether or not a cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks
/// (<see href="https://developer.mozilla.org/en-US/docs/Glossary/CSRF">CSRF</see>). <br />
/// <b>Note:</b> Null value will result in the browser using it's default behavior.
/// </param>
public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null, bool HttpOnly = false, bool Secure = false, SameSiteMode? SameSiteMode = null);
19 changes: 5 additions & 14 deletions src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
Expand Down
27 changes: 27 additions & 0 deletions src/BitzArt.Blazor.Cookies/Utility/JsCommand.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading

0 comments on commit d054a24

Please sign in to comment.