diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4a16ef67..846804d5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -44,6 +44,7 @@
+
diff --git a/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs b/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs
new file mode 100644
index 00000000..ee99fcc6
--- /dev/null
+++ b/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs
@@ -0,0 +1,17 @@
+using Microsoft.AspNetCore.Http;
+
+namespace SimpleModule.Core.Security;
+
+public interface ISignedUrlGenerator
+{
+ string Sign(
+ string path,
+ IDictionary? query = null,
+ DateTimeOffset? expiresAt = null,
+ string? purpose = null
+ );
+
+ bool TryValidate(HttpRequest request, out SignedUrlClaims? claims);
+
+ bool TryValidate(HttpRequest request, string? expectedPurpose, out SignedUrlClaims? claims);
+}
diff --git a/framework/SimpleModule.Core/Security/SignedUrlClaims.cs b/framework/SimpleModule.Core/Security/SignedUrlClaims.cs
new file mode 100644
index 00000000..a3470244
--- /dev/null
+++ b/framework/SimpleModule.Core/Security/SignedUrlClaims.cs
@@ -0,0 +1,3 @@
+namespace SimpleModule.Core.Security;
+
+public sealed record SignedUrlClaims(string Path, string? Purpose, DateTimeOffset? ExpiresAt);
diff --git a/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs b/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs
new file mode 100644
index 00000000..92d97094
--- /dev/null
+++ b/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SimpleModule.Core.Security;
+
+public static class SignedUrlEndpointExtensions
+{
+ private const string SignedUrlClaimsItemKey = "SimpleModule.SignedUrl.Claims";
+
+ public static TBuilder RequireSignedUrl(this TBuilder builder, string? purpose = null)
+ where TBuilder : IEndpointConventionBuilder
+ {
+ builder.AllowAnonymous();
+ builder.AddEndpointFilter(
+ async (context, next) =>
+ {
+ var generator =
+ context.HttpContext.RequestServices.GetRequiredService();
+
+ if (!generator.TryValidate(context.HttpContext.Request, purpose, out var claims))
+ {
+ return Results.StatusCode(StatusCodes.Status403Forbidden);
+ }
+
+ context.HttpContext.Items[SignedUrlClaimsItemKey] = claims;
+ return await next(context);
+ }
+ );
+ return builder;
+ }
+
+ public static SignedUrlClaims? GetSignedUrlClaims(this HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ return context.Items.TryGetValue(SignedUrlClaimsItemKey, out var value)
+ ? value as SignedUrlClaims
+ : null;
+ }
+}
diff --git a/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs b/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs
new file mode 100644
index 00000000..be44b8c3
--- /dev/null
+++ b/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs
@@ -0,0 +1,215 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace SimpleModule.Core.Security;
+
+public sealed class SignedUrlGenerator : ISignedUrlGenerator
+{
+ private const string SignatureQueryKey = "signature";
+ private const string ExpiresQueryKey = "expires";
+ private const string PurposeQueryKey = "purpose";
+ private const string ProtectorPurpose = "SimpleModule.SignedUrl.v1";
+
+ private readonly IDataProtector _protector;
+ private readonly TimeProvider _clock;
+
+ public SignedUrlGenerator(IDataProtectionProvider provider, TimeProvider clock)
+ {
+ ArgumentNullException.ThrowIfNull(provider);
+ ArgumentNullException.ThrowIfNull(clock);
+ _protector = provider.CreateProtector(ProtectorPurpose);
+ _clock = clock;
+ }
+
+ public string Sign(
+ string path,
+ IDictionary? query = null,
+ DateTimeOffset? expiresAt = null,
+ string? purpose = null
+ )
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ if (path.Contains('?', StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "Path must not contain a query string; pass query parameters via the 'query' argument.",
+ nameof(path)
+ );
+ }
+
+ var pairs = BuildPairs(query, expiresAt, purpose);
+ var canonical = BuildCanonical(path, pairs);
+ var signature = WebEncoders.Base64UrlEncode(
+ _protector.Protect(Encoding.UTF8.GetBytes(canonical))
+ );
+
+ pairs.Add(new KeyValuePair(SignatureQueryKey, signature));
+ return QueryHelpers.AddQueryString(path, pairs);
+ }
+
+ public bool TryValidate(HttpRequest request, out SignedUrlClaims? claims) =>
+ TryValidate(request, expectedPurpose: null, out claims);
+
+ public bool TryValidate(
+ HttpRequest request,
+ string? expectedPurpose,
+ out SignedUrlClaims? claims
+ )
+ {
+ claims = null;
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (!request.Query.TryGetValue(SignatureQueryKey, out var providedSignature))
+ {
+ return false;
+ }
+
+ var pairs = new List>();
+ string? purpose = null;
+ DateTimeOffset? expiresAt = null;
+ foreach (var (key, values) in request.Query)
+ {
+ if (string.Equals(key, SignatureQueryKey, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var value = (string?)values;
+ pairs.Add(new KeyValuePair(key, value));
+
+ if (string.Equals(key, PurposeQueryKey, StringComparison.Ordinal))
+ {
+ purpose = value;
+ }
+ else if (
+ string.Equals(key, ExpiresQueryKey, StringComparison.Ordinal)
+ && long.TryParse(
+ value,
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out var unix
+ )
+ )
+ {
+ expiresAt = DateTimeOffset.FromUnixTimeSeconds(unix);
+ }
+ }
+
+ // A URL signed with a purpose must be validated against that exact purpose;
+ // otherwise a token issued for one flow could be replayed against another.
+ if (!string.Equals(expectedPurpose, purpose, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (expiresAt is not null && _clock.GetUtcNow() > expiresAt.Value)
+ {
+ return false;
+ }
+
+ var path = request.Path.Value ?? string.Empty;
+ var canonical = BuildCanonical(path, pairs);
+ var canonicalBytes = Encoding.UTF8.GetBytes(canonical);
+
+ byte[] decrypted;
+ try
+ {
+ var sigBytes = WebEncoders.Base64UrlDecode((string)providedSignature!);
+ decrypted = _protector.Unprotect(sigBytes);
+ }
+ catch (CryptographicException)
+ {
+ return false;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+
+ if (!CryptographicOperations.FixedTimeEquals(decrypted, canonicalBytes))
+ {
+ return false;
+ }
+
+ claims = new SignedUrlClaims(path, purpose, expiresAt);
+ return true;
+ }
+
+ private static List> BuildPairs(
+ IDictionary? query,
+ DateTimeOffset? expiresAt,
+ string? purpose
+ )
+ {
+ var pairs = new List>();
+ if (query is not null)
+ {
+ foreach (var (key, value) in query)
+ {
+ if (
+ string.Equals(key, SignatureQueryKey, StringComparison.Ordinal)
+ || string.Equals(key, ExpiresQueryKey, StringComparison.Ordinal)
+ || string.Equals(key, PurposeQueryKey, StringComparison.Ordinal)
+ )
+ {
+ throw new ArgumentException(
+ $"Query parameter '{key}' is reserved for signed URL metadata.",
+ nameof(query)
+ );
+ }
+ pairs.Add(new KeyValuePair(key, value));
+ }
+ }
+ if (expiresAt is not null)
+ {
+ pairs.Add(
+ new KeyValuePair(
+ ExpiresQueryKey,
+ expiresAt.Value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
+ )
+ );
+ }
+ if (purpose is not null)
+ {
+ pairs.Add(new KeyValuePair(PurposeQueryKey, purpose));
+ }
+ return pairs;
+ }
+
+ private static string BuildCanonical(
+ string path,
+ IEnumerable> pairs
+ )
+ {
+ var sorted = pairs
+ .OrderBy(p => p.Key, StringComparer.Ordinal)
+ .ThenBy(p => p.Value, StringComparer.Ordinal)
+ .ToArray();
+
+ var builder = new StringBuilder(path);
+ if (sorted.Length == 0)
+ {
+ return builder.ToString();
+ }
+ builder.Append('?');
+ for (var i = 0; i < sorted.Length; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append('&');
+ }
+ builder.Append(Uri.EscapeDataString(sorted[i].Key));
+ builder.Append('=');
+ builder.Append(Uri.EscapeDataString(sorted[i].Value ?? string.Empty));
+ }
+ return builder.ToString();
+ }
+}
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
index 00a4782e..4111872f 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
@@ -130,6 +130,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddScoped();
+ builder.Services.TryAddSingleton(TimeProvider.System);
+ builder.Services.AddSingleton();
+
if (options.EnableHealthChecks)
{
builder
diff --git a/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs b/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs
new file mode 100644
index 00000000..56f5062e
--- /dev/null
+++ b/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs
@@ -0,0 +1,254 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Extensions.Time.Testing;
+using SimpleModule.Core.Security;
+
+namespace SimpleModule.Core.Tests.Security;
+
+public class SignedUrlGeneratorTests
+{
+ private static SignedUrlGenerator CreateGenerator(FakeTimeProvider? clock = null) =>
+ new(new EphemeralDataProtectionProvider(), clock ?? new FakeTimeProvider());
+
+ private static HttpRequest BuildRequest(string signedUrl)
+ {
+ var queryStart = signedUrl.IndexOf('?', StringComparison.Ordinal);
+ var path = queryStart < 0 ? signedUrl : signedUrl[..queryStart];
+ var queryString = queryStart < 0 ? string.Empty : signedUrl[queryStart..];
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = path;
+ context.Request.QueryString = new QueryString(queryString);
+ return context.Request;
+ }
+
+ [Fact]
+ public void Sign_AppendsSignatureToQuery()
+ {
+ var generator = CreateGenerator();
+
+ var url = generator.Sign("/files/123");
+
+ url.Should().StartWith("/files/123?");
+ url.Should().Contain("signature=");
+ }
+
+ [Fact]
+ public void Sign_AndValidate_RoundTripsClaims()
+ {
+ var generator = CreateGenerator();
+ var expires = DateTimeOffset.UtcNow.AddMinutes(10);
+
+ var url = generator.Sign(
+ "/files/abc",
+ new Dictionary { ["userId"] = "42" },
+ expires,
+ purpose: "download"
+ );
+
+ var request = BuildRequest(url);
+ var ok = generator.TryValidate(request, expectedPurpose: "download", out var claims);
+
+ ok.Should().BeTrue();
+ claims.Should().NotBeNull();
+ claims!.Path.Should().Be("/files/abc");
+ claims.Purpose.Should().Be("download");
+ claims.ExpiresAt!.Value.ToUnixTimeSeconds().Should().Be(expires.ToUnixTimeSeconds());
+ }
+
+ [Fact]
+ public void Validate_TamperedQuery_Fails()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign(
+ "/files/abc",
+ new Dictionary { ["userId"] = "42" }
+ );
+ var tampered = url.Replace("userId=42", "userId=43", StringComparison.Ordinal);
+
+ var ok = generator.TryValidate(BuildRequest(tampered), out var claims);
+
+ ok.Should().BeFalse();
+ claims.Should().BeNull();
+ }
+
+ [Fact]
+ public void Validate_TamperedSignature_Fails()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/files/abc");
+
+ var sigIndex = url.IndexOf("signature=", StringComparison.Ordinal);
+ var head = url[..(sigIndex + "signature=".Length)];
+ var tampered = head + WebEncoders.Base64UrlEncode([0xAA, 0xBB, 0xCC, 0xDD]);
+
+ var ok = generator.TryValidate(BuildRequest(tampered), out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_MissingSignature_Fails()
+ {
+ var generator = CreateGenerator();
+
+ var ok = generator.TryValidate(BuildRequest("/files/abc?userId=42"), out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_ExpiredUrl_Fails()
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var generator = CreateGenerator(clock);
+ var url = generator.Sign("/files/abc", expiresAt: clock.GetUtcNow().AddMinutes(5));
+
+ clock.Advance(TimeSpan.FromMinutes(6));
+
+ var ok = generator.TryValidate(BuildRequest(url), out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_NoExpiry_AcceptedIndefinitely()
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var generator = CreateGenerator(clock);
+ var url = generator.Sign("/files/abc");
+
+ clock.Advance(TimeSpan.FromDays(365));
+
+ var ok = generator.TryValidate(BuildRequest(url), out _);
+
+ ok.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Validate_PurposeMismatch_Fails()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/files/abc", purpose: "download");
+
+ var ok = generator.TryValidate(BuildRequest(url), expectedPurpose: "unsubscribe", out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_PurposeMatch_Succeeds()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/files/abc", purpose: "download");
+
+ var ok = generator.TryValidate(
+ BuildRequest(url),
+ expectedPurpose: "download",
+ out var claims
+ );
+
+ ok.Should().BeTrue();
+ claims!.Purpose.Should().Be("download");
+ }
+
+ [Fact]
+ public void Validate_PurposeReuseAcrossEndpoints_Isolated()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/unsubscribe/123", purpose: "unsubscribe");
+
+ var tamperedRequest = BuildRequest(
+ url.Replace("/unsubscribe/123", "/delete-account/123", StringComparison.Ordinal)
+ );
+
+ var ok = generator.TryValidate(tamperedRequest, expectedPurpose: "unsubscribe", out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_QueryParameterOrder_DoesNotMatter()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign(
+ "/files/abc",
+ new Dictionary
+ {
+ ["a"] = "1",
+ ["b"] = "2",
+ ["c"] = "3",
+ }
+ );
+
+ var path = url[..url.IndexOf('?', StringComparison.Ordinal)];
+ var queryDict = QueryHelpers.ParseQuery(url[url.IndexOf('?', StringComparison.Ordinal)..]);
+ var reordered = QueryHelpers.AddQueryString(
+ path,
+ queryDict.Reverse().ToDictionary(kv => kv.Key, kv => (string?)kv.Value.ToString())
+ );
+
+ var ok = generator.TryValidate(BuildRequest(reordered), out _);
+
+ ok.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Sign_ReservedQueryKey_Throws()
+ {
+ var generator = CreateGenerator();
+
+ var act = () =>
+ generator.Sign("/files/abc", new Dictionary { ["signature"] = "x" });
+
+ act.Should().Throw().WithMessage("*reserved*");
+ }
+
+ [Fact]
+ public void Sign_PathContainingQueryString_Throws()
+ {
+ var generator = CreateGenerator();
+
+ var act = () => generator.Sign("/files/abc?already=here");
+
+ act.Should().Throw().WithMessage("*query string*");
+ }
+
+ [Fact]
+ public void Validate_PurposeBoundUrl_WithoutExpectedPurpose_Fails()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/files/abc", purpose: "download");
+
+ var ok = generator.TryValidate(BuildRequest(url), out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Validate_NoPurposeUrl_WithExpectedPurpose_Fails()
+ {
+ var generator = CreateGenerator();
+ var url = generator.Sign("/files/abc");
+
+ var ok = generator.TryValidate(BuildRequest(url), expectedPurpose: "download", out _);
+
+ ok.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Sign_DifferentKeyRings_ProduceDifferentSignatures()
+ {
+ var a = CreateGenerator();
+ var b = CreateGenerator();
+
+ var urlA = a.Sign("/files/abc");
+
+ var ok = b.TryValidate(BuildRequest(urlA), out _);
+
+ ok.Should().BeFalse();
+ }
+}
diff --git a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
index c836cf7d..0f79ac08 100644
--- a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
+++ b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj
@@ -12,6 +12,7 @@
+