129 lines
4.3 KiB
C#
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><iframe></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);
|
|
}
|
|
}
|