using System.Security.Cryptography; using System.Text; namespace w4c_workflows.Services; /// /// Issues and verifies short-lived signed render tokens that let any client load /// the workflow HTML preview in an <iframe>. An iframe cannot send /// the operator-key Authorization header, so the flow is: /// /// the frontend calls GET /api/workflows/{id}/html-token with the /// operator key to mint a token, then /// points the iframe at GET /api/workflows/{id}/html?token=.... /// /// 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. /// public sealed class RenderTokenService { private readonly byte[]? _key; private readonly ILogger _logger; public RenderTokenService(IConfiguration config, ILogger 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)); } /// Base64-url (RFC 4648 ยง5) encoding without padding. 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); } }