w4c-workflows-api/Services/RenderTokenService.cs

129 lines
4.3 KiB
C#

using System.Security.Cryptography;
using System.Text;
namespace w4c_workflows.Services;
/// <summary>
/// Issues and verifies short-lived signed render tokens that let any client load
/// the workflow HTML preview in an <c>&lt;iframe&gt;</c>. An iframe cannot send
/// the operator-key <c>Authorization</c> header, so the flow is:
/// <list type="bullet">
/// <item>the frontend calls <c>GET /api/workflows/{id}/html-token</c> with the
/// operator key to mint a token, then</item>
/// <item>points the iframe at <c>GET /api/workflows/{id}/html?token=...</c>.</item>
/// </list>
/// The token is HMAC-SHA256 signed, binds the tenant + workflow id, and expires,
/// so a leaked URL is short-lived and cannot be replayed against another workflow.
/// </summary>
public sealed class RenderTokenService
{
private readonly byte[]? _key;
private readonly ILogger<RenderTokenService> _logger;
public RenderTokenService(IConfiguration config, ILogger<RenderTokenService> logger)
{
_logger = logger;
var signingKey = config["Auth:JwtSigningKey"];
if (string.IsNullOrWhiteSpace(signingKey))
{
// No signing key → render tokens are disabled. The /html-token
// endpoint will return 503; the /html endpoint without a token
// falls through to normal operator-key auth. This prevents
// trivially forgeable tokens from being issued.
_logger.LogError(
"Auth:JwtSigningKey is unset — render tokens are DISABLED. " +
"Set the key to enable iframe HTML previews.");
_key = null;
}
else
{
_key = Encoding.UTF8.GetBytes(signingKey);
}
}
public string Issue(string tenantId, Guid workflowId, TimeSpan ttl)
{
if (_key == null)
throw new InvalidOperationException(
"Cannot issue render tokens: Auth:JwtSigningKey is not configured.");
var exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
var payload = $"{tenantId}|{workflowId:N}|{exp}";
var payloadB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(payload));
var sig = Sign(payloadB64);
return $"{payloadB64}.{sig}";
}
public bool TryVerify(string token, out string tenantId, out Guid workflowId)
{
tenantId = string.Empty;
workflowId = Guid.Empty;
if (_key == null)
return false; // tokens disabled — always reject
if (string.IsNullOrWhiteSpace(token))
return false;
var parts = token.Split('.');
if (parts.Length != 2)
return false;
var payloadB64 = parts[0];
var sig = parts[1];
if (!FixedTimeEquals(sig, Sign(payloadB64)))
return false;
byte[] payloadBytes;
try
{
payloadBytes = Base64Url.Decode(payloadB64);
}
catch (FormatException)
{
return false;
}
var payload = Encoding.UTF8.GetString(payloadBytes);
var seg = payload.Split('|');
if (seg.Length != 3)
return false;
if (!long.TryParse(seg[2], out var exp))
return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp)
return false;
tenantId = seg[0];
return Guid.TryParseExact(seg[1], "N", out workflowId);
}
private string Sign(string payloadB64)
{
using var hmac = new HMACSHA256(_key!); // non-null: callers guard with _key == null checks
return Base64Url.Encode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadB64)));
}
private static bool FixedTimeEquals(string a, string b)
=> CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
}
/// <summary>Base64-url (RFC 4648 §5) encoding without padding.</summary>
public static class Base64Url
{
public static string Encode(byte[] data)
=> Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public static byte[] Decode(string s)
{
var b64 = s.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
return Convert.FromBase64String(b64);
}
}