Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,30 @@ public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws()

using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
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<ArgumentOutOfRangeException>()
.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<McpApiTokenService>();
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));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@page
@model McpAccessModel
@using EssentialCSharp.Web.Models
@using EssentialCSharp.Web.Services
@{
ViewData["Title"] = "MCP Access";
ViewData["ActivePage"] = ManageNavPages.McpAccess;
Expand Down Expand Up @@ -61,7 +62,7 @@
<label asp-for="ExpiresOn" class="form-label">Expiry Date</label>
<input asp-for="ExpiresOn" type="date" class="form-control" max="@Model.MaxExpiresOn.ToString("yyyy-MM-dd")" />
<span asp-validation-for="ExpiresOn" class="text-danger"></span>
<div class="form-text">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.</div>
<div class="form-text">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.</div>
</div>
<button type="submit" class="btn btn-primary">Create Token</button>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public class McpAccessModel(
public async Task<IActionResult> OnGetAsync()
{
DisableCaching();
InitializeExpiryBounds();
DateTime nowUtc = DateTime.UtcNow;
InitializeExpiryBounds(nowUtc);
ApplyDefaultExpiryIfMissing();
string? userId = userManager.GetUserId(User);
if (userId is null) return Challenge();
Expand All @@ -44,7 +45,9 @@ public async Task<IActionResult> OnGetAsync()
public async Task<IActionResult> OnPostCreateAsync()
{
DisableCaching();
InitializeExpiryBounds();
DateTime nowUtc = DateTime.UtcNow;
DateOnly todayUtc = DateOnly.FromDateTime(nowUtc);
InitializeExpiryBounds(nowUtc);
ApplyDefaultExpiryIfMissing();
string? userId = userManager.GetUserId(User);
Comment on lines +48 to 52
if (userId is null) return Challenge();
Expand All @@ -54,7 +57,7 @@ public async Task<IActionResult> 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);
Expand All @@ -69,7 +72,11 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 9 additions & 3 deletions EssentialCSharp.Web/Controllers/McpTokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ public async Task<IActionResult> 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)
Comment on lines +29 to 33
{
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 });
Expand All @@ -39,7 +41,11 @@ public async Task<IActionResult> CreateToken(
}

var (rawToken, entity) = await tokenService.CreateTokenAsync(
userId, name, expiresAt, cancellationToken);
userId,
name,
expiresAt,
createdAtUtc: nowUtc,
cancellationToken: cancellationToken);

return Ok(new
{
Expand Down
5 changes: 3 additions & 2 deletions EssentialCSharp.Web/Services/McpApiTokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Expand Down
Loading