Skip to content

Commit 58e07c9

Browse files
authored
Merge pull request #792 from Chris0Jeky/feature/mcp-http-transport-apikey-654
MCP-03: HTTP transport with API key authentication
2 parents 3c3d6e4 + 059fc0c commit 58e07c9

32 files changed

Lines changed: 3141 additions & 2 deletions
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Taskdeck.Api.Contracts;
4+
using Taskdeck.Application.Interfaces;
5+
using Taskdeck.Application.Services;
6+
using Taskdeck.Domain.Exceptions;
7+
8+
namespace Taskdeck.Api.Controllers;
9+
10+
/// <summary>
11+
/// Manages MCP API keys for HTTP transport authentication.
12+
/// Keys are scoped to the authenticated user and use the <c>tdsk_</c> prefix.
13+
/// </summary>
14+
[ApiController]
15+
[Route("api/[controller]")]
16+
[Authorize]
17+
public class ApiKeysController : AuthenticatedControllerBase
18+
{
19+
private readonly ApiKeyService _apiKeyService;
20+
21+
public ApiKeysController(ApiKeyService apiKeyService, IUserContext userContext)
22+
: base(userContext)
23+
{
24+
_apiKeyService = apiKeyService;
25+
}
26+
27+
/// <summary>
28+
/// Create a new API key. The plaintext key is returned once and cannot be retrieved again.
29+
/// </summary>
30+
[HttpPost]
31+
[ProducesResponseType(typeof(CreateApiKeyResponse), StatusCodes.Status201Created)]
32+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
33+
public async Task<IActionResult> Create([FromBody] CreateApiKeyRequest request, CancellationToken cancellationToken)
34+
{
35+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
36+
return errorResult!;
37+
38+
if (string.IsNullOrWhiteSpace(request.Name))
39+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Name is required"));
40+
41+
TimeSpan? expiresIn = request.ExpiresInDays.HasValue
42+
? TimeSpan.FromDays(request.ExpiresInDays.Value)
43+
: null;
44+
45+
try
46+
{
47+
var (plaintextKey, entity) = await _apiKeyService.CreateKeyAsync(
48+
userId, request.Name, expiresIn, cancellationToken);
49+
50+
return StatusCode(StatusCodes.Status201Created, new CreateApiKeyResponse(
51+
entity.Id,
52+
plaintextKey,
53+
entity.KeyPrefix_,
54+
entity.Name,
55+
entity.CreatedAt,
56+
entity.ExpiresAt));
57+
}
58+
catch (DomainException ex)
59+
{
60+
return BadRequest(new ApiErrorResponse(ex.ErrorCode, ex.Message));
61+
}
62+
}
63+
64+
/// <summary>List all API keys for the authenticated user.</summary>
65+
[HttpGet]
66+
[ProducesResponseType(typeof(ListApiKeysResponse), StatusCodes.Status200OK)]
67+
public async Task<IActionResult> List(CancellationToken cancellationToken)
68+
{
69+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
70+
return errorResult!;
71+
72+
var keys = await _apiKeyService.ListKeysAsync(userId, cancellationToken);
73+
74+
var items = keys.Select(k => new ApiKeyListItem(
75+
k.Id,
76+
k.KeyPrefix_,
77+
k.Name,
78+
k.CreatedAt,
79+
k.ExpiresAt,
80+
k.RevokedAt,
81+
k.LastUsedAt,
82+
k.IsActive));
83+
84+
return Ok(new ListApiKeysResponse(items.ToList()));
85+
}
86+
87+
/// <summary>Revoke an API key.</summary>
88+
[HttpDelete("{id:guid}")]
89+
[ProducesResponseType(StatusCodes.Status204NoContent)]
90+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
91+
public async Task<IActionResult> Revoke(Guid id, CancellationToken cancellationToken)
92+
{
93+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
94+
return errorResult!;
95+
96+
try
97+
{
98+
await _apiKeyService.RevokeKeyAsync(id, userId, cancellationToken);
99+
return NoContent();
100+
}
101+
catch (DomainException ex) when (ex.ErrorCode == ErrorCodes.NotFound)
102+
{
103+
return NotFound(new ApiErrorResponse(ex.ErrorCode, ex.Message));
104+
}
105+
catch (DomainException ex) when (ex.ErrorCode == ErrorCodes.Forbidden)
106+
{
107+
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(ex.ErrorCode, ex.Message));
108+
}
109+
catch (DomainException ex)
110+
{
111+
return BadRequest(new ApiErrorResponse(ex.ErrorCode, ex.Message));
112+
}
113+
}
114+
}
115+
116+
// ── Request / Response contracts ──────────────────────────────────────────────
117+
118+
public sealed record CreateApiKeyRequest(string Name, int? ExpiresInDays = null);
119+
120+
public sealed record CreateApiKeyResponse(
121+
Guid Id,
122+
string Key,
123+
string KeyPrefix,
124+
string Name,
125+
DateTimeOffset CreatedAt,
126+
DateTimeOffset? ExpiresAt);
127+
128+
public sealed record ApiKeyListItem(
129+
Guid Id,
130+
string KeyPrefix,
131+
string Name,
132+
DateTimeOffset CreatedAt,
133+
DateTimeOffset? ExpiresAt,
134+
DateTimeOffset? RevokedAt,
135+
DateTimeOffset? LastUsedAt,
136+
bool IsActive);
137+
138+
public sealed record ListApiKeysResponse(List<ApiKeyListItem> Keys);

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
5959
new BoardMetricsService(
6060
sp.GetRequiredService<IUnitOfWork>(),
6161
sp.GetRequiredService<IAuthorizationService>()));
62+
services.AddScoped<ApiKeyService>();
6263
services.AddScoped<IForecastingService>(sp =>
6364
new ForecastingService(
6465
sp.GetRequiredService<IUnitOfWork>(),

backend/src/Taskdeck.Api/Extensions/PipelineConfiguration.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Globalization;
22
using System.Net;
33
using Microsoft.AspNetCore.HttpOverrides;
4+
using Microsoft.AspNetCore.RateLimiting;
45
using Microsoft.EntityFrameworkCore;
56
using Taskdeck.Api.Hubs;
67
using Taskdeck.Api.Middleware;
@@ -82,6 +83,11 @@ public static WebApplication ConfigureTaskdeckPipeline(
8283
}
8384
});
8485

86+
// API key authentication for MCP HTTP transport (/mcp path).
87+
// Must run before UseAuthentication so MCP requests are handled by API key auth,
88+
// not JWT auth. Non-MCP requests pass through unaffected.
89+
app.UseMiddleware<ApiKeyMiddleware>();
90+
8591
app.UseAuthentication();
8692
// Reject tokens for deleted/deactivated users or tokens issued before invalidation.
8793
// Must run after UseAuthentication (so JWT is parsed) and before UseAuthorization.
@@ -95,6 +101,15 @@ public static WebApplication ConfigureTaskdeckPipeline(
95101
app.MapControllers();
96102
app.MapHub<BoardsHub>("/hubs/boards");
97103

104+
// MCP Streamable HTTP endpoint for external AI agent integration.
105+
// Authenticated via ApiKeyMiddleware (Bearer tdsk_... tokens).
106+
// Rate limiting applied per API key user identity.
107+
var mcpEndpoint = app.MapMcp();
108+
if (rateLimitingSettings.Enabled)
109+
{
110+
mcpEndpoint.RequireRateLimiting(RateLimiting.RateLimitingPolicyNames.McpPerApiKey);
111+
}
112+
98113
// SPA fallback: any route not matched by a controller or hub endpoint returns index.html,
99114
// enabling Vue Router's client-side navigation. API (/api/*) and hub (/hubs/*) routes
100115
// are matched above and never reach this fallback.

backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ await context.HttpContext.Response.WriteAsJsonAsync(
7272
var partitionKey = $"capture-user:{ResolveUserOrClientIdentifier(httpContext)}";
7373
return BuildFixedWindowPartition(partitionKey, settings.CaptureWritePerUser);
7474
});
75+
76+
options.AddPolicy(RateLimitingPolicyNames.McpPerApiKey, httpContext =>
77+
{
78+
// Partition by API key user or fall back to IP for unauthenticated attempts.
79+
var partitionKey = $"mcp-apikey:{ResolveUserOrClientIdentifier(httpContext)}";
80+
return BuildFixedWindowPartition(partitionKey, settings.McpPerApiKey);
81+
});
7582
}
7683

7784
private static RateLimitPartition<string> BuildFixedWindowPartition(string partitionKey, RateLimitPolicySettings policy)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using Microsoft.EntityFrameworkCore;
4+
using Taskdeck.Api.Contracts;
5+
using Taskdeck.Domain.Entities;
6+
using Taskdeck.Domain.Exceptions;
7+
using Taskdeck.Infrastructure.Mcp;
8+
using Taskdeck.Infrastructure.Persistence;
9+
10+
namespace Taskdeck.Api.Middleware;
11+
12+
/// <summary>
13+
/// Middleware that authenticates MCP HTTP requests using API keys.
14+
/// Extracts a Bearer token from the Authorization header, hashes it with SHA-256,
15+
/// looks up the hash in the ApiKeys table, and sets the user ID in
16+
/// HttpContext.Items for <see cref="HttpUserContextProvider"/>.
17+
///
18+
/// Only active on the MCP endpoint path (/mcp). REST API endpoints continue
19+
/// to use JWT authentication.
20+
/// </summary>
21+
public sealed class ApiKeyMiddleware
22+
{
23+
private readonly RequestDelegate _next;
24+
private readonly ILogger<ApiKeyMiddleware> _logger;
25+
26+
/// <summary>The path prefix that triggers API key authentication.</summary>
27+
private const string McpPathPrefix = "/mcp";
28+
29+
public ApiKeyMiddleware(RequestDelegate next, ILogger<ApiKeyMiddleware> logger)
30+
{
31+
_next = next;
32+
_logger = logger;
33+
}
34+
35+
public async Task InvokeAsync(HttpContext context, TaskdeckDbContext dbContext)
36+
{
37+
// Only authenticate MCP endpoint requests
38+
if (!context.Request.Path.StartsWithSegments(McpPathPrefix, StringComparison.OrdinalIgnoreCase))
39+
{
40+
await _next(context);
41+
return;
42+
}
43+
44+
var authHeader = context.Request.Headers.Authorization.ToString();
45+
if (string.IsNullOrWhiteSpace(authHeader))
46+
{
47+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
48+
"Missing Authorization header. Provide a Bearer token with your API key.");
49+
return;
50+
}
51+
52+
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
53+
{
54+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
55+
"Invalid Authorization header format. Use: Bearer tdsk_...");
56+
return;
57+
}
58+
59+
var token = authHeader["Bearer ".Length..].Trim();
60+
61+
if (string.IsNullOrWhiteSpace(token) || !token.StartsWith(ApiKey.KeyPrefix))
62+
{
63+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
64+
"Invalid API key format. Keys must start with 'tdsk_'.");
65+
return;
66+
}
67+
68+
// Hash the provided key and look up in the database
69+
var keyHash = HashKey(token);
70+
71+
var apiKey = await dbContext.ApiKeys
72+
.AsNoTracking()
73+
.FirstOrDefaultAsync(k => k.KeyHash == keyHash, context.RequestAborted);
74+
75+
if (apiKey is null)
76+
{
77+
_logger.LogWarning("MCP API key authentication failed: key not found (prefix: {Prefix})",
78+
token.Length >= 8 ? token[..8] : "short");
79+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
80+
"Invalid API key.");
81+
return;
82+
}
83+
84+
if (!apiKey.IsActive)
85+
{
86+
var reason = apiKey.RevokedAt is not null ? "revoked" : "expired";
87+
_logger.LogWarning("MCP API key authentication failed: key is {Reason} (id: {KeyId})",
88+
reason, apiKey.Id);
89+
// Return generic message to avoid leaking key state (revoked vs expired)
90+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
91+
"Invalid API key.");
92+
return;
93+
}
94+
95+
// Verify the user account is active
96+
var user = await dbContext.Users
97+
.AsNoTracking()
98+
.FirstOrDefaultAsync(u => u.Id == apiKey.UserId, context.RequestAborted);
99+
100+
if (user is null || !user.IsActive)
101+
{
102+
_logger.LogWarning("MCP API key authentication failed: user inactive (userId: {UserId})", apiKey.UserId);
103+
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized,
104+
"User account is inactive or has been deleted.");
105+
return;
106+
}
107+
108+
// Set the authenticated user ID for HttpUserContextProvider
109+
context.Items[HttpUserContextProvider.UserIdItemKey] = apiKey.UserId;
110+
111+
// Update last-used timestamp before continuing the pipeline.
112+
// This is non-critical so failures are swallowed.
113+
await UpdateLastUsedAsync(dbContext, apiKey.Id);
114+
115+
await _next(context);
116+
}
117+
118+
private static string HashKey(string plaintextKey)
119+
{
120+
var bytes = Encoding.UTF8.GetBytes(plaintextKey);
121+
var hash = SHA256.HashData(bytes);
122+
return Convert.ToHexString(hash).ToLowerInvariant();
123+
}
124+
125+
private async Task UpdateLastUsedAsync(TaskdeckDbContext dbContext, Guid keyId)
126+
{
127+
try
128+
{
129+
// Direct update to avoid concurrency issues with the read-only query above.
130+
await dbContext.ApiKeys
131+
.Where(k => k.Id == keyId)
132+
.ExecuteUpdateAsync(setters => setters
133+
.SetProperty(k => k.LastUsedAt, DateTimeOffset.UtcNow)
134+
.SetProperty(k => k.UpdatedAt, DateTimeOffset.UtcNow));
135+
}
136+
catch (Exception ex)
137+
{
138+
// Non-critical: if usage tracking fails, authentication still succeeds.
139+
_logger.LogDebug(ex, "Failed to update API key last-used timestamp for key {KeyId}", keyId);
140+
}
141+
}
142+
143+
private static async Task WriteErrorResponse(HttpContext context, int statusCode, string message)
144+
{
145+
context.Response.StatusCode = statusCode;
146+
context.Response.ContentType = "application/json";
147+
await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
148+
ErrorCodes.Unauthorized,
149+
message));
150+
}
151+
}

backend/src/Taskdeck.Api/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@
172172
builder.Services.AddHttpContextAccessor();
173173
builder.Services.AddScoped<Taskdeck.Application.Interfaces.IUserContext, Taskdeck.Infrastructure.Identity.UserContext>();
174174

175+
// Register MCP HTTP transport (Streamable HTTP alongside REST on the same Kestrel instance).
176+
// The HttpUserContextProvider resolves user identity from the API key set by ApiKeyMiddleware.
177+
builder.Services.AddScoped<IUserContextProvider, Taskdeck.Infrastructure.Mcp.HttpUserContextProvider>();
178+
builder.Services.AddMcpServer()
179+
.WithHttpTransport()
180+
.WithResources<BoardResources>()
181+
.WithResources<CaptureResources>()
182+
.WithResources<ProposalResources>()
183+
.WithTools<ReadTools>()
184+
.WithTools<WriteTools>()
185+
.WithTools<ProposalTools>();
186+
175187
// Add JWT Authentication (with optional GitHub OAuth)
176188
builder.Services.AddTaskdeckAuthentication(jwtSettings, gitHubOAuthSettings);
177189

backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ public static class RateLimitingPolicyNames
55
public const string AuthPerIp = "AuthPerIp";
66
public const string HotPathPerUser = "HotPathPerUser";
77
public const string CaptureWritePerUser = "CaptureWritePerUser";
8+
public const string McpPerApiKey = "McpPerApiKey";
89
}

backend/src/Taskdeck.Api/Taskdeck.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2929
</PackageReference>
3030
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
31+
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
3132
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.1" />
3233
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.1" />
3334
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.1" />
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface IApiKeyRepository : IRepository<ApiKey>
6+
{
7+
/// <summary>Look up an API key by its SHA-256 hash.</summary>
8+
Task<ApiKey?> GetByKeyHashAsync(string keyHash, CancellationToken cancellationToken = default);
9+
10+
/// <summary>List all API keys belonging to a user (including revoked).</summary>
11+
Task<IEnumerable<ApiKey>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
12+
}

0 commit comments

Comments
 (0)