Skip to content

Commit 9a39d05

Browse files
Add 6 month MCP token expiry defaults
Agent-Logs-Url: https://github.com/IntelliTect/EssentialCSharp.Web/sessions/29b86c94-db59-4104-8285-197ae2428813 Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com>
1 parent 87779d1 commit 9a39d05

6 files changed

Lines changed: 81 additions & 8 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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_WithExpiryBeyondSixMonths_Throws()
28+
{
29+
string userId = await McpTestHelper.CreateUserAsync(factory, "mcp-max-expiry");
30+
31+
using var scope = factory.Services.CreateScope();
32+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
33+
DateTime requestedExpiry = McpApiTokenService.GetDefaultExpirationUtc(DateTime.UtcNow).AddDays(1);
34+
35+
await Assert.That(() => tokenService.CreateTokenAsync(userId, "too-long", requestedExpiry))
36+
.Throws<ArgumentOutOfRangeException>()
37+
.WithMessageContaining("6 months");
38+
}
39+
}

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: 17 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,7 @@ public class McpAccessModel(
3133
public async Task<IActionResult> OnGetAsync()
3234
{
3335
DisableCaching();
36+
InitializeExpiryDefaults();
3437
string? userId = userManager.GetUserId(User);
3538
if (userId is null) return Challenge();
3639
UserTokens = await tokenService.GetUserTokensAsync(userId);
@@ -40,14 +43,20 @@ public async Task<IActionResult> OnGetAsync()
4043
public async Task<IActionResult> OnPostCreateAsync()
4144
{
4245
DisableCaching();
46+
InitializeExpiryDefaults();
4347
string? userId = userManager.GetUserId(User);
4448
if (userId is null) return Challenge();
4549

4650
if (string.IsNullOrWhiteSpace(TokenName))
4751
ModelState.AddModelError(nameof(TokenName), "Token name is required.");
4852

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

5261
if (!ModelState.IsValid)
5362
{
@@ -85,4 +94,10 @@ private void DisableCaching()
8594
Response.Headers.Pragma = "no-cache";
8695
Response.Headers.Expires = "0";
8796
}
97+
98+
private void InitializeExpiryDefaults()
99+
{
100+
MaxExpiresOn = McpApiTokenService.GetDefaultExpiryDate();
101+
ExpiresOn ??= MaxExpiresOn;
102+
}
88103
}

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: 17 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? utcNow = null)
18+
=> DateOnly.FromDateTime(utcNow ?? DateTime.UtcNow).AddMonths(DefaultLifetimeMonths);
19+
20+
public static DateTime GetDefaultExpirationUtc(DateTime? utcNow = null)
21+
=> GetDefaultExpiryDate(utcNow).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,13 +39,19 @@ public static string GenerateRawToken()
3039
CancellationToken cancellationToken = default)
3140
{
3241
string raw = GenerateRawToken();
42+
DateTime createdAt = DateTime.UtcNow;
43+
DateTime maxExpiration = GetDefaultExpirationUtc(createdAt);
44+
DateTime effectiveExpiration = expiresAt ?? maxExpiration;
45+
if (effectiveExpiration > maxExpiration)
46+
throw new ArgumentOutOfRangeException(nameof(expiresAt), MaxExpiryValidationMessage);
47+
3348
var entity = new McpApiToken
3449
{
3550
UserId = userId,
3651
Name = name,
3752
TokenHash = HashToken(raw),
38-
CreatedAt = DateTime.UtcNow,
39-
ExpiresAt = expiresAt,
53+
CreatedAt = createdAt,
54+
ExpiresAt = effectiveExpiration,
4055
};
4156
db.McpApiTokens.Add(entity);
4257
await db.SaveChangesAsync(cancellationToken);

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)