using System; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scheduler.WebService.Options; namespace StellaOps.Scheduler.WebService.EventWebhooks; public interface IWebhookRequestAuthenticator { Task AuthenticateAsync(HttpContext context, ReadOnlyMemory body, SchedulerWebhookOptions options, CancellationToken cancellationToken); } internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator { private readonly ILogger _logger; public WebhookRequestAuthenticator( ILogger logger) { _logger = logger; } public async Task AuthenticateAsync(HttpContext context, ReadOnlyMemory body, SchedulerWebhookOptions options, CancellationToken cancellationToken) { if (!options.Enabled) { return WebhookAuthenticationResult.Success(); } if (options.RequireClientCertificate) { var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false); if (certificate is null) { _logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name); return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required."); } } if (!string.IsNullOrWhiteSpace(options.HmacSecret)) { var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader; if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues)) { _logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName); return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header."); } var providedSignature = signatureValues.ToString(); if (string.IsNullOrWhiteSpace(providedSignature)) { return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty."); } if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature)) { _logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name); return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature."); } } return WebhookAuthenticationResult.Success(); } private static bool VerifySignature(ReadOnlySpan payload, string secret, string providedSignature) { byte[] secretBytes; try { secretBytes = Convert.FromBase64String(secret); } catch (FormatException) { try { secretBytes = Convert.FromHexString(secret); } catch (FormatException) { secretBytes = Encoding.UTF8.GetBytes(secret); } } using var hmac = new HMACSHA256(secretBytes); var hash = hmac.ComputeHash(payload.ToArray()); var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(computedSignature), Encoding.UTF8.GetBytes(providedSignature.Trim())); } } public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message) { public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null); public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message); public IResult ToResult() => Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode); }