Skip to content

Commit

Permalink
Add HttpOnly and Secure flags (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
YuriyDurov authored Jan 7, 2025
1 parent a84b357 commit fd659f6
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,40 @@ public HttpContextCookieService(IHttpContextAccessor httpContextAccessor, ILogge
_responseHeaders = _httpContext.Features.GetRequiredFeature<IHttpResponseFeature>().Headers;
}

// ======================================== GetAllAsync ========================================

public Task<IEnumerable<Cookie>> GetAllAsync()
{
return Task.FromResult(_requestCookies.Select(x => x.Value).ToList().AsEnumerable());
}

// ======================================== GetAsync ========================================

public Task<Cookie?> GetAsync(string key)
{
if (_requestCookies.TryGetValue(key, out var cookie)) return Task.FromResult<Cookie?>(cookie);

return Task.FromResult<Cookie?>(null);
}

public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
{
if (RemovePending(key)) _logger.LogDebug("Pending cookie [{key}] removed.", key);
// ======================================== SetAsync ========================================

if (_requestCookies.Remove(key))
{
_logger.LogDebug("Removing client browser cookie [{key}] by marking it as expired.", key);
_httpContext.Response.Cookies.Delete(key);
}

return Task.CompletedTask;
}
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(new Cookie(key, value, expiration), cancellationToken);
=> 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(Cookie cookie, CancellationToken cancellationToken = default)
{
if (cookie.Secure && !cookie.HttpOnly) throw new InvalidOperationException("Unable to set a cookie: Secure cookies must also be HttpOnly.");

_logger.LogDebug("Setting cookie: '{key}'='{value}'", cookie.Key, cookie.Value);

RemovePending(cookie.Key);
Expand All @@ -62,6 +66,8 @@ public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = defaul
{
Expires = cookie.Expiration,
Path = "/",
HttpOnly = cookie.HttpOnly,
Secure = cookie.Secure
});

return Task.CompletedTask;
Expand Down Expand Up @@ -92,4 +98,19 @@ private bool RemovePending(string key)
_logger.LogDebug("No pending cookie found.");
return false;
}

// ======================================== RemoveAsync ========================================

public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
{
if (RemovePending(key)) _logger.LogDebug("Pending cookie [{key}] removed.", key);

if (_requestCookies.Remove(key))
{
_logger.LogDebug("Removing client browser cookie [{key}] by marking it as expired.", key);
_httpContext.Response.Cookies.Delete(key);
}

return Task.CompletedTask;
}
}
34 changes: 29 additions & 5 deletions src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,45 @@ 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)
=> SetAsync(new Cookie(key, value, expiration), cancellationToken);
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="secure"> Whether to transmit the cookie using Secure Sockets Layer (SSL)--that is, over HTTPS only. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
public Task SetAsync(string key, string value, DateTimeOffset? expiration, bool httpOnly, bool secure, CancellationToken cancellationToken = default);

/// <summary>
/// Adds or updates a browser cookie. <br/> <br/>
/// <b>Note: </b>
/// When in <see href="https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes">Static SSR render mode</see>,
/// the new value will be sent to the client machine
/// after the page has completed rendering,
/// and will not appear in the cookies collection until the next request.
/// the new value will only be sent to the client machine after the page has completed rendering,
/// and thus will not appear in the cookies collection until the next request.
/// </summary>
/// <param name="cookie"> The cookie to set. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
/// <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>
public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null) { }
/// <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) { }
37 changes: 31 additions & 6 deletions src/BitzArt.Blazor.Cookies/Services/JsInteropCookieService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ namespace BitzArt.Blazor.Cookies;

internal class JsInteropCookieService(IJSRuntime js) : ICookieService
{
// ======================================== GetAllAsync ========================================

public async Task<IEnumerable<Cookie>> GetAllAsync()
{
var raw = await js.InvokeAsync<string>("eval", "document.cookie");
Expand All @@ -12,10 +14,12 @@ public async Task<IEnumerable<Cookie>> GetAllAsync()
return raw.Split("; ").Select(GetCookie);
}

// ======================================== GetAsync ========================================

private Cookie GetCookie(string raw)
{
var parts = raw.Split("=", 2);
return new Cookie(parts[0], parts[1]);
return new Cookie(parts[0], parts[1], null, HttpOnly: false, Secure: false);
}

public async Task<Cookie?> GetAsync(string key)
Expand All @@ -24,17 +28,38 @@ private Cookie GetCookie(string raw)
return cookies.FirstOrDefault(x => x.Key == key);
}

// ======================================== 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 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);
}

public async Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(key)) throw new Exception("Key is required when setting a cookie.");
await js.InvokeVoidAsync("eval", $"document.cookie = \"{key}={value}; expires={expiration:R}; path=/\"");
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=/\"");
}

private const string HttpOnlyFlagErrorMessage = $"HttpOnly cookies are not supported in this rendering environment. {CookieFlagsExplainMessage}";
private const string SecureFlagErrorMessage = $"Secure cookies are not supported in this rendering environment. {CookieFlagsExplainMessage}";
private const string CookieFlagsExplainMessage = "Setting HttpOnly or Secure cookies is only possible when using Static SSR render mode.";

// ======================================== RemoveAsync ========================================

public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(key)) throw new Exception("Key is required when removing a cookie.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public async Task SetCookie_WhenProperCookie_ShouldSetCookie()
(var httpContext, _, var service) = CreateTestServices();

// Act
await service.SetAsync("key", "value", null);
await service.SetAsync("key", "value");

// Assert
Assert.Single(httpContext.Response.Headers);
Expand All @@ -29,7 +29,7 @@ public async Task RemoveCookie_AfterSetCookie_ShouldRemovePending()
// Arrange
(var httpContext, _, var service) = CreateTestServices();

await service.SetAsync("key", "value", null);
await service.SetAsync("key", "value");

// Act
await service.RemoveAsync("key");
Expand All @@ -46,14 +46,55 @@ public async Task SetCookie_WhenDuplicate_ShouldOnlySetCookieOnce()
(var httpContext, _, var service) = CreateTestServices();

// Act
await service.SetAsync("key", "value1", null);
await service.SetAsync("key", "value2", null);
await service.SetAsync("key", "value1");
await service.SetAsync("key", "value2");

// Assert
var values = httpContext.Features.GetRequiredFeature<IHttpResponseFeature>().Headers.SetCookie;
Assert.Single(values);
}

[Fact]
public async Task SetCookie_WithHttpOnlyFlag_ShouldSetHttpOnly()
{
// Arrange
(var httpContext, _, var service) = CreateTestServices();

// Act
await service.SetAsync("key", "value", httpOnly: true, secure: false);

// Assert
var values = httpContext.Features.GetRequiredFeature<IHttpResponseFeature>().Headers.SetCookie;
Assert.Single(values);
Assert.Contains("httponly", values.First());
}

[Fact]
public async Task SetCookie_WithSecureFlagButNotHttpOnlyFlag_ShouldThrow()
{
// Arrange
(var httpContext, _, var service) = CreateTestServices();

// Act + Assert
await Assert.ThrowsAnyAsync<Exception>(async () => await service.SetAsync("key", "value", httpOnly: false, secure: true));
}

[Fact]
public async Task SetCookie_WithHttpOnlyAndSecureFlags_ShouldSetHttpOnlyAndSecure()
{
// Arrange
(var httpContext, _, var service) = CreateTestServices();

// Act
await service.SetAsync("key", "value", httpOnly: true, secure: true);

// Assert
var values = httpContext.Features.GetRequiredFeature<IHttpResponseFeature>().Headers.SetCookie;
Assert.Single(values);
Assert.Contains("httponly", values.First());
Assert.Contains("secure", values.First());
}

private static TestServices CreateTestServices()
{
var httpContext = new DefaultHttpContext();
Expand Down
24 changes: 24 additions & 0 deletions tests/BitzArt.Blazor.Cookies.Tests/JsInteropCookieServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,28 @@ public async Task GetAsync_WithCookiePresent_ShouldReturnValue(string cookieName
// Assert
Assert.Equal(cookieValue, result?.Value);
}

[Fact]
public async Task SetAsync_WithHttpOnlyTrue_ShouldThrow()
{
// Arrange
var jsRuntime = new Mock<IJSRuntime>();

var sut = new JsInteropCookieService(jsRuntime.Object);

// Act + Assert
await Assert.ThrowsAnyAsync<Exception>(async () => await sut.SetAsync("cookie-name", "cookie-value", httpOnly: true, secure: false));
}

[Fact]
public async Task SetAsync_WithSecureTrue_ShouldThrow()
{
// Arrange
var jsRuntime = new Mock<IJSRuntime>();

var sut = new JsInteropCookieService(jsRuntime.Object);

// Act + Assert
await Assert.ThrowsAnyAsync<Exception>(async () => await sut.SetAsync("cookie-name", "cookie-value", httpOnly: false, secure: true));
}
}

0 comments on commit fd659f6

Please sign in to comment.