Skip to content

Commit 2d3ffbe

Browse files
authored
Add 6 month default and max MCP token expiry (#1043)
1 parent 14f2a7c commit 2d3ffbe

6 files changed

Lines changed: 111 additions & 8 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using EssentialCSharp.Web.Services;
2+
using Microsoft.AspNetCore.Mvc.Testing;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace EssentialCSharp.Web.Tests;
6+
7+
[NotInParallel("McpTests")]
8+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
9+
public class McpApiTokenServiceTests(WebApplicationFactory factory)
10+
{
11+
[Test]
12+
public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault()
13+
{
14+
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-default-expiry");
15+
16+
using var scope = factory.Services.CreateScope();
17+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
18+
19+
(_, var entity) = await tokenService.CreateTokenAsync(userId, "default-expiry");
20+
21+
await Assert.That(entity.ExpiresAt).IsNotNull();
22+
await Assert.That(entity.ExpiresAt!.Value)
23+
.IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(entity.CreatedAt));
24+
}
25+
26+
[Test]
27+
public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry()
28+
{
29+
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-custom-expiry");
30+
31+
using var scope = factory.Services.CreateScope();
32+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
33+
DateTime requestedExpiry = DateTime.UtcNow.AddMonths(3);
34+
35+
(_, var entity) = await tokenService.CreateTokenAsync(userId, "custom-expiry", requestedExpiry);
36+
37+
await Assert.That(entity.ExpiresAt).IsNotNull();
38+
await Assert.That(entity.ExpiresAt!.Value).IsEqualTo(requestedExpiry);
39+
}
40+
41+
[Test]
42+
public async Task CreateTokenAsync_WithExpiryBeyondSixMonths_Throws()
43+
{
44+
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-max-expiry");
45+
46+
using var scope = factory.Services.CreateScope();
47+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
48+
DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(1);
49+
50+
await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry))
51+
.Throws<ArgumentOutOfRangeException>()
52+
.WithMessageContaining("6 months");
53+
}
54+
}

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@
5858
<span asp-validation-for="TokenName" class="text-danger"></span>
5959
</div>
6060
<div class="mb-3">
61-
<label asp-for="ExpiresOn" class="form-label">Expiry Date <span class="text-muted">(optional)</span></label>
62-
<input asp-for="ExpiresOn" type="date" class="form-control" />
61+
<label asp-for="ExpiresOn" class="form-label">Expiry Date</label>
62+
<input asp-for="ExpiresOn" type="date" class="form-control" max="@Model.MaxExpiresOn.ToString("yyyy-MM-dd")" />
6363
<span asp-validation-for="ExpiresOn" class="text-danger"></span>
64-
<div class="form-text">Leave blank for a non-expiring token. The token expires at end of day (23:59:59) UTC on the selected date.</div>
64+
<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>
6565
</div>
6666
<button type="submit" class="btn btn-primary">Create Token</button>
6767
</form>

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/McpAccess.cshtml.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public class McpAccessModel(
2121

2222
public List<McpApiToken> UserTokens { get; private set; } = [];
2323

24+
public DateOnly MaxExpiresOn { get; private set; }
25+
2426
[BindProperty]
2527
[StringLength(256, ErrorMessage = "Token name must be 256 characters or fewer.")]
2628
public string TokenName { get; set; } = "My Token";
@@ -31,6 +33,8 @@ public class McpAccessModel(
3133
public async Task<IActionResult> OnGetAsync()
3234
{
3335
DisableCaching();
36+
InitializeExpiryBounds();
37+
ApplyDefaultExpiryIfMissing();
3438
string? userId = userManager.GetUserId(User);
3539
if (userId is null) return Challenge();
3640
UserTokens = await tokenService.GetUserTokensAsync(userId);
@@ -40,14 +44,21 @@ public async Task<IActionResult> OnGetAsync()
4044
public async Task<IActionResult> OnPostCreateAsync()
4145
{
4246
DisableCaching();
47+
InitializeExpiryBounds();
48+
ApplyDefaultExpiryIfMissing();
4349
string? userId = userManager.GetUserId(User);
4450
if (userId is null) return Challenge();
4551

4652
if (string.IsNullOrWhiteSpace(TokenName))
4753
ModelState.AddModelError(nameof(TokenName), "Token name is required.");
4854

49-
if (ExpiresOn.HasValue && ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow))
50-
ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future.");
55+
if (ExpiresOn.HasValue)
56+
{
57+
if (ExpiresOn.Value < DateOnly.FromDateTime(DateTime.UtcNow))
58+
ModelState.AddModelError(nameof(ExpiresOn), "Expiry date must be today or in the future.");
59+
if (ExpiresOn.Value > MaxExpiresOn)
60+
ModelState.AddModelError(nameof(ExpiresOn), McpApiTokenService.MaxExpiryValidationMessage);
61+
}
5162

5263
if (!ModelState.IsValid)
5364
{
@@ -85,4 +96,14 @@ private void DisableCaching()
8596
Response.Headers.Pragma = "no-cache";
8697
Response.Headers.Expires = "0";
8798
}
99+
100+
private void InitializeExpiryBounds()
101+
{
102+
MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
103+
}
104+
105+
private void ApplyDefaultExpiryIfMissing()
106+
{
107+
ExpiresOn ??= MaxExpiresOn;
108+
}
88109
}

EssentialCSharp.Web/Controllers/McpTokenController.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ public async Task<IActionResult> CreateToken(
2727
return BadRequest(new { Error = "Token name must be 256 characters or fewer." });
2828

2929
DateTime? expiresAt = null;
30+
DateOnly maxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
3031
if (request?.ExpiresOn is DateOnly expiresOn)
3132
{
3233
if (expiresOn < DateOnly.FromDateTime(DateTime.UtcNow))
3334
return BadRequest(new { Error = "ExpiresOn must be today or in the future." });
35+
if (expiresOn > maxExpiresOn)
36+
return BadRequest(new { Error = McpApiTokenService.MaxExpiryValidationMessage });
3437
// Convert date-only boundary to end-of-day UTC instant before persisting
3538
expiresAt = expiresOn.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
3639
}

EssentialCSharp.Web/Services/McpApiTokenService.cs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ namespace EssentialCSharp.Web.Services;
99

1010
public class McpApiTokenService(EssentialCSharpWebContext db)
1111
{
12+
public const int DefaultLifetimeMonths = 6;
13+
public const string MaxExpiryValidationMessage = "MCP tokens can expire at most 6 months from today.";
14+
1215
public sealed record ResolvedMcpApiToken(Guid TokenId, string UserId);
1316

17+
public static DateOnly GetDefaultExpiryDate(DateTime? referenceTimeUtc = null)
18+
=> DateOnly.FromDateTime(referenceTimeUtc ?? DateTime.UtcNow).AddMonths(DefaultLifetimeMonths);
19+
20+
public static DateTime GetDefaultExpirationUtc(DateTime? referenceTimeUtc = null)
21+
=> GetDefaultExpiryDate(referenceTimeUtc).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
22+
1423
/// <summary>Returns SHA-256 hash of the raw token as a byte array (varbinary(32)).</summary>
1524
public static byte[] HashToken(string rawToken)
1625
=> SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
@@ -30,19 +39,34 @@ public static string GenerateRawToken()
3039
CancellationToken cancellationToken = default)
3140
{
3241
string raw = GenerateRawToken();
42+
DateTime createdAt = DateTime.UtcNow;
43+
DateTime effectiveExpiration = ResolveExpiration(expiresAt, createdAt);
44+
3345
var entity = new McpApiToken
3446
{
3547
UserId = userId,
3648
Name = name,
3749
TokenHash = HashToken(raw),
38-
CreatedAt = DateTime.UtcNow,
39-
ExpiresAt = expiresAt,
50+
CreatedAt = createdAt,
51+
ExpiresAt = effectiveExpiration,
4052
};
4153
db.McpApiTokens.Add(entity);
4254
await db.SaveChangesAsync(cancellationToken);
4355
return (raw, entity);
4456
}
4557

58+
private static DateTime ResolveExpiration(DateTime? requestedExpirationUtc, DateTime createdAtUtc)
59+
{
60+
DateTime maxExpiration = GetDefaultExpirationUtc(createdAtUtc);
61+
if (requestedExpirationUtc is null)
62+
return maxExpiration;
63+
64+
if (requestedExpirationUtc > maxExpiration)
65+
throw new ArgumentOutOfRangeException(nameof(requestedExpirationUtc), MaxExpiryValidationMessage);
66+
67+
return requestedExpirationUtc.Value;
68+
}
69+
4670
/// <summary>
4771
/// Revokes a token by ID. Validates ownership to prevent cross-user revocation.
4872
/// Returns false if token not found or user doesn't own it.

EssentialCSharp.Web/Views/McpSetup/Index.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
<h5>Step 2 — Generate a token</h5>
5353
<p>
5454
Go to <a href="/Identity/Account/Manage/McpAccess">MCP Access</a> under your account settings.
55-
Enter a name for the token (e.g. "VS Code"), choose an optional expiry date, and click
55+
Enter a name for the token (e.g. "VS Code"), keep the default 6-month expiry or choose an earlier date,
56+
and click
5657
<strong>Create Token</strong>. Copy the token — it won't be shown again.
5758
</p>
5859

0 commit comments

Comments
 (0)