152 lines
5.9 KiB
C#
152 lines
5.9 KiB
C#
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("/api/about"), // self-info: name/version/multiTenant, no operator key needed
|
|
new("/openapi"),
|
|
new("/scalar"), // interactive API explorer (Scalar) — no operator key needed
|
|
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 string? _jwtIssuer;
|
|
private readonly string? _jwtAudience;
|
|
private readonly bool _enforceScopes;
|
|
private readonly ILogger<AuthMiddleware> _logger;
|
|
|
|
public AuthMiddleware(RequestDelegate next, IConfiguration config, ILogger<AuthMiddleware> logger)
|
|
{
|
|
_next = next;
|
|
_signingKey = config["Auth:JwtSigningKey"] ?? string.Empty;
|
|
_jwtIssuer = config["Auth:JwtIssuer"];
|
|
_jwtAudience = config["Auth:JwtAudience"];
|
|
// Fail-closed switch for operator keys that carry no scope set. Off by
|
|
// default so legacy/seed keys keep working until they are re-minted.
|
|
_enforceScopes = config.GetValue("Auth:EnforceScopes", false);
|
|
_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, _jwtIssuer, _jwtAudience);
|
|
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["EnforceScopes"] = _enforceScopes;
|
|
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}\"}}");
|
|
}
|
|
}
|