diff --git a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs index 06065bab..f60ee7dd 100644 --- a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs +++ b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs @@ -45,10 +45,30 @@ public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws() using var scope = factory.Services.CreateScope(); var tokenService = scope.ServiceProvider.GetRequiredService(); - DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(1); + DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(2); await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry)) .Throws() - .WithMessageContaining("6 months"); + .WithMessageContaining(McpApiTokenService.MaxExpiryValidationMessage); + } + + [Test] + public async Task CreateTokenAsync_WithExplicitCreatedAt_UsesReferenceTimeForDefaultExpiry() + { + string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-explicit-created-at"); + + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + DateTime createdAtUtc = new(2026, 4, 30, 23, 59, 59, DateTimeKind.Utc); + + (_, var entity) = await tokenService.CreateTokenAsync( + userId, + "explicit-created-at", + createdAtUtc: createdAtUtc); + + await Assert.That(entity.CreatedAt).IsEqualTo(createdAtUtc); + await Assert.That(entity.ExpiresAt).IsNotNull(); + await Assert.That(entity.ExpiresAt!.Value) + .IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(createdAtUtc)); } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml index d6316448..0b245d60 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -1,6 +1,7 @@ @page @model McpAccessModel @using EssentialCSharp.Web.Models +@using EssentialCSharp.Web.Services @{ ViewData["Title"] = "MCP Access"; ViewData["ActivePage"] = ManageNavPages.McpAccess; @@ -61,7 +62,7 @@ -
MCP tokens default to 6 months and cannot exceed 6 months from today. The token expires at end of day (23:59:59) UTC on the selected date.
+
MCP tokens expire after @McpApiTokenService.DefaultLifetimeMonths months by default, with a maximum lifetime of @McpApiTokenService.DefaultLifetimeMonths months from today. The token expires at end of day (UTC) on the selected date.
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs index cd5b9495..e6789774 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -33,7 +33,8 @@ public class McpAccessModel( public async Task OnGetAsync() { DisableCaching(); - InitializeExpiryBounds(); + DateTime nowUtc = DateTime.UtcNow; + InitializeExpiryBounds(nowUtc); ApplyDefaultExpiryIfMissing(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); @@ -44,7 +45,9 @@ public async Task OnGetAsync() public async Task OnPostCreateAsync() { DisableCaching(); - InitializeExpiryBounds(); + DateTime nowUtc = DateTime.UtcNow; + DateOnly todayUtc = DateOnly.FromDateTime(nowUtc); + InitializeExpiryBounds(nowUtc); ApplyDefaultExpiryIfMissing(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); @@ -54,7 +57,7 @@ public async Task OnPostCreateAsync() if (ExpiresOn.HasValue) { - if (ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + if (ExpiresOn.Value < todayUtc) ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future."); if (ExpiresOn.Value > MaxExpiresOn) ModelState.AddModelError(nameof(ExpiresOn), McpApiTokenService.MaxExpiryValidationMessage); @@ -69,7 +72,11 @@ public async Task OnPostCreateAsync() // Convert date-only boundary to end-of-day UTC instant before persisting DateTime? expiresAt = ExpiresOn?.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); - var (rawToken, entity) = await tokenService.CreateTokenAsync(userId, TokenName.Trim(), expiresAt); + var (rawToken, entity) = await tokenService.CreateTokenAsync( + userId, + TokenName.Trim(), + expiresAt, + createdAtUtc: nowUtc); GeneratedToken = rawToken; GeneratedTokenEntity = entity; UserTokens = await tokenService.GetUserTokensAsync(userId); @@ -97,9 +104,9 @@ private void DisableCaching() Response.Headers.Expires = "0"; } - private void InitializeExpiryBounds() + private void InitializeExpiryBounds(DateTime nowUtc) { - MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(); + MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(nowUtc); } private void ApplyDefaultExpiryIfMissing() diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs index a8383f3d..f047c563 100644 --- a/EssentialCSharp.Web/Controllers/McpTokenController.cs +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -26,11 +26,13 @@ public async Task CreateToken( if (name.Length > 256) return BadRequest(new { Error = "Token name must be 256 characters or fewer." }); + DateTime nowUtc = DateTime.UtcNow; + DateOnly todayUtc = DateOnly.FromDateTime(nowUtc); DateTime? expiresAt = null; - DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(); + DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(nowUtc); if (request?.ExpiresOn is DateOnly expiresOn) { - if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow)) + if (expiresOn < todayUtc) return BadRequest(new { Error = "ExpiresOn must be today or in the future." }); if (expiresOn > maxExpiresOn) return BadRequest(new { Error = McpApiTokenService.MaxExpiryValidationMessage }); @@ -39,7 +41,11 @@ public async Task CreateToken( } var (rawToken, entity) = await tokenService.CreateTokenAsync( - userId, name, expiresAt, cancellationToken); + userId, + name, + expiresAt, + createdAtUtc: nowUtc, + cancellationToken: cancellationToken); return Ok(new { diff --git a/EssentialCSharp.Web/Services/McpApiTokenService.cs b/EssentialCSharp.Web/Services/McpApiTokenService.cs index c88417b3..7e6a2466 100644 --- a/EssentialCSharp.Web/Services/McpApiTokenService.cs +++ b/EssentialCSharp.Web/Services/McpApiTokenService.cs @@ -10,7 +10,7 @@ namespace EssentialCSharp.Web.Services; public class McpApiTokenService(EssentialCSharpWebContext db) { public const int DefaultLifetimeMonths = 6; - public const string MaxExpiryValidationMessage = "MCP tokens can expire at most 6 months from today."; + public static readonly string MaxExpiryValidationMessage = $"MCP tokens can expire at most {DefaultLifetimeMonths} months from today."; public sealed record ResolvedMcpApiToken(Guid TokenId, string UserId); @@ -36,10 +36,11 @@ public static string GenerateRawToken() string userId, string name, DateTime? expiresAt = null, + DateTime? createdAtUtc = null, CancellationToken cancellationToken = default) { string raw = GenerateRawToken(); - DateTime createdAt = DateTime.UtcNow; + DateTime createdAt = createdAtUtc ?? DateTime.UtcNow; DateTime effectiveExpiration = ResolveExpiration(expiresAt, createdAt); var entity = new McpApiToken