w4c-workflows-api/Middleware/AuthMiddleware.cs

141 lines
5.3 KiB
C#
Raw Normal View History

using w4c_workflows.Services;
namespace w4c_workflows.Middleware;
/// <summary>
/// Two auth surfaces for the control plane:
/// - /api/keys* → the tenant's main JWT (shared Auth:JwtSigningKey). Lets a
/// logged-in tenant mint/rotate/revoke its operator keys.
/// - everything else → per-tenant operator key (Authorization: Bearer <key>).
/// Used by both the frontend and the per-tenant worker.
/// Resolved identity is exposed via HttpContext.Items: TenantId, Scopes,
/// AuthKind ("jwt" | "operator").
/// </summary>
public class AuthMiddleware
{
private static readonly PathString[] PublicPaths =
{
new("/health"),
new("/health/ready"),
new("/openapi"),
new("/scalar"), // interactive API explorer (Scalar) — no operator key needed
2026-09-02 17:33:19 +00:00
new("/api/scalar"), // Scalar reference exposed under /api/scalar/<svc> — no operator key needed
new("/h"), // webhook receiver (external callers have no operator key)
};
private readonly RequestDelegate _next;
private readonly string _signingKey;
private readonly ILogger<AuthMiddleware> _logger;
public AuthMiddleware(RequestDelegate next, IConfiguration config, ILogger<AuthMiddleware> logger)
{
_next = next;
_signingKey = config["Auth:JwtSigningKey"] ?? string.Empty;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path;
if (IsPublic(path))
{
await _next(context);
return;
}
// /api/keys* — main JWT surface.
if (path.StartsWithSegments("/api/keys"))
{
var principal = JwtValidator.Validate(context.Request.Headers.Authorization.ToString(), _signingKey);
var tenantId = principal?.FindFirst("tenant_id")?.Value;
if (principal == null || string.IsNullOrEmpty(tenantId))
{
await Unauthorized(context, "Valid main JWT required for key management");
return;
}
context.Items["TenantId"] = tenantId;
context.Items["AuthKind"] = "jwt";
await _next(context);
return;
}
// The rich HTML preview endpoint (/api/workflows/{id}/html) is reached
// from an <iframe>, which cannot send the operator-key Authorization
// header. It therefore accepts a short-lived signed token in ?token= —
// minted by /html-token with the operator key. Without a token it falls
// through to the normal operator-key check below (API use).
if (IsHtmlRenderPath(path))
{
var token = context.Request.Query["token"].ToString();
if (!string.IsNullOrEmpty(token))
{
var renderTokens = context.RequestServices.GetRequiredService<RenderTokenService>();
if (renderTokens.TryVerify(token, out var tenantId, out var workflowId))
{
context.Items["TenantId"] = tenantId;
context.Items["AuthKind"] = "render-token";
context.Items["RenderWorkflowId"] = workflowId;
await _next(context);
return;
}
// A token was supplied but is invalid/expired — fail rather than
// silently falling through to the header check.
await Unauthorized(context, "Invalid or expired render token");
return;
}
}
// Everything else — operator key surface.
var services = context.RequestServices;
var keys = services.GetRequiredService<ApiKeyService>();
var op = await keys.VerifyAsync(context.Request.Headers.Authorization.ToString(), context.RequestAborted);
if (op == null)
{
await Unauthorized(context, "Valid operator key required");
return;
}
context.Items["TenantId"] = op.TenantId;
context.Items["AuthKind"] = "operator";
context.Items["Scopes"] = op.Scopes;
context.Items["OperatorKeyId"] = op.KeyId;
_logger.LogDebug("Operator-key auth for tenant {TenantId}", op.TenantId);
await _next(context);
}
private static bool IsPublic(PathString path)
{
foreach (var publicPath in PublicPaths)
{
if (path.StartsWithSegments(publicPath))
return true;
}
return false;
}
/// <summary>
/// Matches the backend HTML render route: <c>/api/workflows/{guid}/html</c>.
/// Exactly five segments ("" + api/workflows/{id}/html) with an html tail.
/// </summary>
private static bool IsHtmlRenderPath(PathString path)
{
var value = path.Value;
if (string.IsNullOrEmpty(value)) return false;
var segments = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
return segments.Length == 4
&& segments[0] == "api"
&& segments[1] == "workflows"
&& segments[3] == "html";
}
private static Task Unauthorized(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
return context.Response.WriteAsync($"{{\"error\":\"{message}\"}}");
}
}