VoterKeyResolver: the one reader and writer of the bifrost_voter device cookie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 22:48:04 +02:00
commit af3b712b26
3 changed files with 61 additions and 0 deletions

View file

@ -24,4 +24,8 @@ public sealed class ConceptVoteOptions
// https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator
// refuses anything else outside Development and Testing, because "*" would allow every origin.
public string[] AllowedOrigins { get; init; } = [];
// The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the
// Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl).
public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill";
}

View file

@ -0,0 +1,55 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
namespace Bifrost.Web.Features.ConceptVotes;
// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For
// decides the voter key and class. The cookie holds a protected random id, never a count, class or account.
public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection)
{
public const string CookieName = "bifrost_voter";
public const string CookiePath = "/mill";
public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365);
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
// The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose
// (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported.
public string? TryRead(HttpContext context)
{
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token))
{
return null;
}
try
{
return protector.Unprotect(token);
}
catch (CryptographicException)
{
return null;
}
}
// Sets a fresh device cookie on this response and returns its device id. Called only from a cast.
public string Mint(HttpContext context)
{
var deviceId = Guid.NewGuid().ToString("N");
context.Response.Cookies.Append(
CookieName,
protector.Protect(deviceId),
new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = CookiePath,
MaxAge = CookieLifetime,
IsEssential = true
});
return deviceId;
}
}

View file

@ -62,6 +62,7 @@ builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
options.Conventions.AllowAnonymousToFolder("/Mill");
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
@ -103,6 +104,7 @@ builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<VoterKeyResolver>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();