diff --git a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs new file mode 100644 index 00000000..06065bab --- /dev/null +++ b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs @@ -0,0 +1,54 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +[NotInParallel("McpTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class McpApiTokenServiceTests(WebApplicationFactory factory) +{ + [Test] + public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault() + { + string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-default-expiry"); + + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + + (_, var entity) = await tokenService.CreateTokenAsync(userId, "default-expiry"); + + await Assert.That(entity.ExpiresAt).IsNotNull(); + await Assert.That(entity.ExpiresAt!.Value) + .IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(entity.CreatedAt)); + } + + [Test] + public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry() + { + string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-custom-expiry"); + + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + DateTime requestedExpiry = DateTime.UtcNow.AddMonths(3); + + (_, var entity) = await tokenService.CreateTokenAsync(userId, "custom-expiry", requestedExpiry); + + await Assert.That(entity.ExpiresAt).IsNotNull(); + await Assert.That(entity.ExpiresAt!.Value).IsEqualTo(requestedExpiry); + } + + [Test] + public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws() + { + string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-max-expiry"); + + using var scope = factory.Services.CreateScope(); + var tokenService = scope.ServiceProvider.GetRequiredService(); + DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(1); + + await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry)) + .Throws() + .WithMessageContaining("6 months"); + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml index 9759917a..d6316448 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml @@ -58,10 +58,10 @@
- - + + -
Leave blank for a non-expiring token. The token expires at end of day (23:59:59) UTC on the selected date.
+
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.
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 55c58b43..cd5b9495 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs @@ -21,6 +21,8 @@ public class McpAccessModel( public List UserTokens { get; private set; } = []; + public DateOnly MaxExpiresOn { get; private set; } + [BindProperty] [StringLength(256, ErrorMessage = "Token name must be 256 characters or fewer.")] public string TokenName { get; set; } = "My Token"; @@ -31,6 +33,8 @@ public class McpAccessModel( public async Task OnGetAsync() { DisableCaching(); + InitializeExpiryBounds(); + ApplyDefaultExpiryIfMissing(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); UserTokens = await tokenService.GetUserTokensAsync(userId); @@ -40,14 +44,21 @@ public async Task OnGetAsync() public async Task OnPostCreateAsync() { DisableCaching(); + InitializeExpiryBounds(); + ApplyDefaultExpiryIfMissing(); string? userId = userManager.GetUserId(User); if (userId is null) return Challenge(); if (string.IsNullOrWhiteSpace(TokenName)) ModelState.AddModelError(nameof(TokenName), "Token name is required."); - if (ExpiresOn.HasValue && ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) - ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future."); + if (ExpiresOn.HasValue) + { + if (ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow)) + ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future."); + if (ExpiresOn.Value > MaxExpiresOn) + ModelState.AddModelError(nameof(ExpiresOn), McpApiTokenService.MaxExpiryValidationMessage); + } if (!ModelState.IsValid) { @@ -85,4 +96,14 @@ private void DisableCaching() Response.Headers.Pragma = "no-cache"; Response.Headers.Expires = "0"; } + + private void InitializeExpiryBounds() + { + MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(); + } + + private void ApplyDefaultExpiryIfMissing() + { + ExpiresOn ??= MaxExpiresOn; + } } diff --git a/EssentialCSharp.Web/Controllers/McpTokenController.cs b/EssentialCSharp.Web/Controllers/McpTokenController.cs index 7409bb3f..a8383f3d 100644 --- a/EssentialCSharp.Web/Controllers/McpTokenController.cs +++ b/EssentialCSharp.Web/Controllers/McpTokenController.cs @@ -27,10 +27,13 @@ public async Task CreateToken( return BadRequest(new { Error = "Token name must be 256 characters or fewer." }); DateTime? expiresAt = null; + DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate(); if (request?.ExpiresOn is DateOnly expiresOn) { if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow)) return BadRequest(new { Error = "ExpiresOn must be today or in the future." }); + if (expiresOn > maxExpiresOn) + return BadRequest(new { Error = McpApiTokenService.MaxExpiryValidationMessage }); // Convert date-only boundary to end-of-day UTC instant before persisting expiresAt = expiresOn.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); } diff --git a/EssentialCSharp.Web/Services/McpApiTokenService.cs b/EssentialCSharp.Web/Services/McpApiTokenService.cs index 93cef143..c88417b3 100644 --- a/EssentialCSharp.Web/Services/McpApiTokenService.cs +++ b/EssentialCSharp.Web/Services/McpApiTokenService.cs @@ -9,8 +9,17 @@ 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 sealed record ResolvedMcpApiToken(Guid TokenId, string UserId); + public static DateOnly GetDefaultExpiryDate(DateTime? referenceTimeUtc = null) + => DateOnly.FromDateTime(referenceTimeUtc ?? DateTime.UtcNow).AddMonths(DefaultLifetimeMonths); + + public static DateTime GetDefaultExpirationUtc(DateTime? referenceTimeUtc = null) + => GetDefaultExpiryDate(referenceTimeUtc).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + /// Returns SHA-256 hash of the raw token as a byte array (varbinary(32)). public static byte[] HashToken(string rawToken) => SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); @@ -30,19 +39,34 @@ public static string GenerateRawToken() CancellationToken cancellationToken = default) { string raw = GenerateRawToken(); + DateTime createdAt = DateTime.UtcNow; + DateTime effectiveExpiration = ResolveExpiration(expiresAt, createdAt); + var entity = new McpApiToken { UserId = userId, Name = name, TokenHash = HashToken(raw), - CreatedAt = DateTime.UtcNow, - ExpiresAt = expiresAt, + CreatedAt = createdAt, + ExpiresAt = effectiveExpiration, }; db.McpApiTokens.Add(entity); await db.SaveChangesAsync(cancellationToken); return (raw, entity); } + private static DateTime ResolveExpiration(DateTime? requestedExpirationUtc, DateTime createdAtUtc) + { + DateTime maxExpiration = GetDefaultExpirationUtc(createdAtUtc); + if (requestedExpirationUtc is null) + return maxExpiration; + + if (requestedExpirationUtc > maxExpiration) + throw new ArgumentOutOfRangeException(nameof(requestedExpirationUtc), MaxExpiryValidationMessage); + + return requestedExpirationUtc.Value; + } + /// /// Revokes a token by ID. Validates ownership to prevent cross-user revocation. /// Returns false if token not found or user doesn't own it. diff --git a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml index f2472b9e..8f027d09 100644 --- a/EssentialCSharp.Web/Views/McpSetup/Index.cshtml +++ b/EssentialCSharp.Web/Views/McpSetup/Index.cshtml @@ -52,7 +52,8 @@
Step 2 — Generate a token

Go to MCP Access under your account settings. - Enter a name for the token (e.g. "VS Code"), choose an optional expiry date, and click + Enter a name for the token (e.g. "VS Code"), keep the default 6-month expiry or choose an earlier date, + and click Create Token. Copy the token — it won't be shown again.