diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 668987b..2a1d54b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - dotnet-version: ['8.0.x', '9.0.x'] + dotnet-version: ['9.0.x'] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index dd701f7..f11b48d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ bin/ obj/ /packages/ riderModule.iml +*.DotSettings.user +*.DotSettings +/.idea/ /_ReSharper.Caches/.env.test .env.test .inngest/ diff --git a/Inngest.Tests/InngestClientAuthBehaviorTests.cs b/Inngest.Tests/InngestClientAuthBehaviorTests.cs new file mode 100644 index 0000000..795ecc2 --- /dev/null +++ b/Inngest.Tests/InngestClientAuthBehaviorTests.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Inngest.Tests; + +public class InngestClientAuthBehaviorTests +{ + [Fact] + public async Task HandleRequestAsync_WhenInngestDevEnvFalse_RequiresSignatureForPut() + { + var previous = Environment.GetEnvironmentVariable("INNGEST_DEV"); + try + { + Environment.SetEnvironmentVariable("INNGEST_DEV", "false"); + + var client = new InngestClient(eventKey: "evt", signingKey: "signkey-test-abc123"); + + var context = new DefaultHttpContext(); + context.Request.Method = "PUT"; + context.Request.Headers.Host = "localhost:5000"; + context.Request.Scheme = "http"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"url\":\"http://localhost:5000/api/inngest\"}")); + context.Request.ContentLength = context.Request.Body.Length; + + await client.HandleRequestAsync(context); + + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + finally + { + Environment.SetEnvironmentVariable("INNGEST_DEV", previous); + } + } + + [Fact] + public async Task HandleRequestAsync_WhenInngestDevEnvTrue_SkipsSignatureForPut() + { + var previous = Environment.GetEnvironmentVariable("INNGEST_DEV"); + try + { + Environment.SetEnvironmentVariable("INNGEST_DEV", "true"); + + var client = new InngestClient(eventKey: "evt", signingKey: "signkey-test-abc123"); + + var context = new DefaultHttpContext(); + context.Request.Method = "PUT"; + context.Request.Headers.Host = "localhost:5000"; + context.Request.Scheme = "http"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"url\":\"http://localhost:5000/api/inngest\"}")); + context.Request.ContentLength = context.Request.Body.Length; + + await client.HandleRequestAsync(context); + + Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + finally + { + Environment.SetEnvironmentVariable("INNGEST_DEV", previous); + } + } + + [Fact] + public async Task HandleRequestAsync_WhenTimestampIsTooFarInFuture_RejectsRequest() + { + var previous = Environment.GetEnvironmentVariable("INNGEST_DEV"); + try + { + Environment.SetEnvironmentVariable("INNGEST_DEV", null); + + const string signingKey = "signkey-test-abc123"; + var client = new InngestClient(eventKey: "evt", signingKey: signingKey); + + var body = "{\"url\":\"http://localhost:5000/api/inngest\"}"; + var timestamp = DateTimeOffset.UtcNow.AddMinutes(6).ToUnixTimeSeconds(); + var signature = ComputeSignature(body, timestamp, signingKey); + + var context = new DefaultHttpContext(); + context.Request.Method = "PUT"; + context.Request.Headers.Host = "localhost:5000"; + context.Request.Scheme = "http"; + context.Request.Headers["X-Inngest-Signature"] = $"t={timestamp}&s={signature}"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + context.Request.ContentLength = context.Request.Body.Length; + + await client.HandleRequestAsync(context); + + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + finally + { + Environment.SetEnvironmentVariable("INNGEST_DEV", previous); + } + } + + private static string ComputeSignature(string body, long timestamp, string signingKey) + { + var normalizedKey = NormalizeSigningKey(signingKey); + var dataToSign = body + timestamp; + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(normalizedKey)); + var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataToSign)); + return Convert.ToHexString(hashBytes).ToLower(); + } + + private static string NormalizeSigningKey(string key) + { + if (string.IsNullOrEmpty(key)) + return key; + + // Matches SDK regex: ^signkey-\\w+- + var idx = key.IndexOf('-', "signkey-".Length); + if (!key.StartsWith("signkey-", StringComparison.Ordinal) || idx < 0) + return key; + + return key[(idx + 1)..]; + } +} + diff --git a/Inngest.Tests/StepToolsTests.cs b/Inngest.Tests/StepToolsTests.cs index ed47ec7..a4964ca 100644 --- a/Inngest.Tests/StepToolsTests.cs +++ b/Inngest.Tests/StepToolsTests.cs @@ -112,8 +112,9 @@ public async Task Run_WhenHandlerThrows_ThrowsStepInterruptExceptionWithError() Assert.Equal("error-step", exception.Operations[0].Id); Assert.Equal(StepOpCode.StepError, exception.Operations[0].Op); Assert.NotNull(exception.Operations[0].Error); - Assert.Equal("InvalidOperationException", exception.Operations[0].Error.Name); - Assert.Equal("Test error message", exception.Operations[0].Error.Message); + var error = exception.Operations[0].Error!; + Assert.Equal("InvalidOperationException", error.Name); + Assert.Equal("Test error message", error.Message); } [Fact] diff --git a/Inngest/IInngestEvent.cs b/Inngest/IInngestEvent.cs index 54e56fa..6101194 100644 --- a/Inngest/IInngestEvent.cs +++ b/Inngest/IInngestEvent.cs @@ -18,7 +18,7 @@ public interface IInngestEvent /// /// Custom data payload for the event /// - public object Data { get; set; } + public object? Data { get; set; } /// /// Timestamp in Unix milliseconds when the event occurred diff --git a/Inngest/InngestClient.cs b/Inngest/InngestClient.cs index 37fff44..70ab5d9 100644 --- a/Inngest/InngestClient.cs +++ b/Inngest/InngestClient.cs @@ -60,7 +60,10 @@ public InngestClient( _signingKey = signingKey ?? Environment.GetEnvironmentVariable("INNGEST_SIGNING_KEY") ?? ""; _signingKeyFallback = Environment.GetEnvironmentVariable("INNGEST_SIGNING_KEY_FALLBACK"); _environment = Environment.GetEnvironmentVariable("INNGEST_ENV") ?? "dev"; - _isDev = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("INNGEST_DEV")); + var devEnv = Environment.GetEnvironmentVariable("INNGEST_DEV"); + _isDev = !string.IsNullOrEmpty(devEnv) && + !devEnv.Equals("false", StringComparison.OrdinalIgnoreCase) && + !devEnv.Equals("0", StringComparison.OrdinalIgnoreCase); _appId = appId ?? Environment.GetEnvironmentVariable("INNGEST_APP_ID") ?? "inngest-dotnet"; // Check for disable cron in dev from environment variable @@ -71,7 +74,7 @@ public InngestClient( _logger = logger ?? NullLogger.Instance; // Set API endpoints based on dev mode and environment variables - string devServerUrl = Environment.GetEnvironmentVariable("INNGEST_DEV") ?? "http://localhost:8288"; + string devServerUrl = devEnv ?? "http://localhost:8288"; if (!Uri.TryCreate(devServerUrl, UriKind.Absolute, out _)) { devServerUrl = "http://localhost:8288"; @@ -310,10 +313,10 @@ public async Task HandleRequestAsync(HttpContext context) response.Headers["X-Inngest-Sdk"] = $"inngest-dotnet:v{_sdkVersion}"; response.Headers["X-Inngest-Req-Version"] = "1"; - // Verify the request if signatures are required - if (!_isDev && !await VerifySignature(context)) + // Verify signature only on requests that carry a body (PUT/POST). GET introspection is allowed to be unauthenticated. + if (!_isDev && (request.Method == "PUT" || request.Method == "POST") && !await VerifySignature(context)) { - response.StatusCode = StatusCodes.Status500InternalServerError; + response.StatusCode = StatusCodes.Status401Unauthorized; await response.WriteAsJsonAsync(new { error = "Invalid signature" }); return; } @@ -379,12 +382,12 @@ private async Task VerifySignature(HttpContext context) return false; } - // Verify timestamp is not too old (within last 5 minutes) + // Verify timestamp is not too old/new (within last 5 minutes) var timestampDateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp); var timeDelta = DateTimeOffset.UtcNow.Subtract(timestampDateTime); - if (timeDelta.TotalMinutes > 5) + if (Math.Abs(timeDelta.TotalMinutes) > 5) { - _logger.LogWarning("Signature verification failed: Timestamp too old ({TimeDelta:F1} minutes)", timeDelta.TotalMinutes); + _logger.LogWarning("Signature verification failed: Timestamp outside allowed window ({TimeDelta:F1} minutes)", timeDelta.TotalMinutes); return false; } @@ -1467,7 +1470,7 @@ private async Task HandleIntrospectionRequest(HttpContext context) } // Always include the unauthenticated response fields - var responseObj = new Dictionary + var responseObj = new Dictionary { ["function_count"] = _functions.Count, ["has_event_key"] = !string.IsNullOrEmpty(_eventKey), @@ -1542,4 +1545,4 @@ private static object BuildStepRetryConfig(RetryOptions retry) return config; } -} \ No newline at end of file +} diff --git a/Inngest/InngestEvent.cs b/Inngest/InngestEvent.cs index 00b3dbc..87f33f0 100644 --- a/Inngest/InngestEvent.cs +++ b/Inngest/InngestEvent.cs @@ -28,7 +28,7 @@ public class InngestEvent : IInngestEvent /// Must be an object, in order to encourage evolving data. /// [JsonPropertyName("data")] - public object Data { get; set; } = new(); + public object? Data { get; set; } /// /// Timestamp in Unix milliseconds when the event occurred @@ -61,7 +61,7 @@ public InngestEvent() { } public InngestEvent(string name, object data) { Name = name; - Data = data; + Data = data ?? throw new ArgumentNullException(nameof(data)); Id = Guid.NewGuid().ToString(); } @@ -86,4 +86,4 @@ public InngestEvent WithIdempotencyKey(string key) IdempotencyKey = key; return this; } -} \ No newline at end of file +} diff --git a/Inngest/InngestFunctionExtensions.cs b/Inngest/InngestFunctionExtensions.cs index 3e03f3c..4d4c499 100644 --- a/Inngest/InngestFunctionExtensions.cs +++ b/Inngest/InngestFunctionExtensions.cs @@ -28,7 +28,7 @@ public static FunctionDefinition WithStep(this FunctionDefinition function, stri /// /// The function definition /// The ID of the step - /// Duration of the sleep in seconds (informational only) + /// Duration of the sleep in seconds (informational only) /// The updated function definition public static FunctionDefinition WithSleep(this FunctionDefinition function, string stepId, int durationSeconds) { diff --git a/Inngest/Internal/JsonCanonicalizer.cs b/Inngest/Internal/JsonCanonicalizer.cs index 505d080..250ddae 100644 --- a/Inngest/Internal/JsonCanonicalizer.cs +++ b/Inngest/Internal/JsonCanonicalizer.cs @@ -44,7 +44,7 @@ public static string Canonicalize(string json) /// Canonicalized JSON as UTF-8 bytes public static byte[] Canonicalize(byte[] jsonBytes) { - if (jsonBytes == null || jsonBytes.Length == 0) + if (jsonBytes.Length == 0) return jsonBytes; try diff --git a/Inngest/JsonConverters/StringToDictionaryConverter.cs b/Inngest/JsonConverters/StringToDictionaryConverter.cs index 9a7f0c7..e56c3a2 100644 --- a/Inngest/JsonConverters/StringToDictionaryConverter.cs +++ b/Inngest/JsonConverters/StringToDictionaryConverter.cs @@ -8,6 +8,9 @@ namespace Inngest.JsonConverters; /// public class StringToDictionaryConverter : JsonConverter?> { + /// + /// Reads JSON that may be an object, string, or null into a string dictionary. + /// public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) @@ -54,6 +57,9 @@ public class StringToDictionaryConverter : JsonConverter"); } + /// + /// Writes the dictionary as a JSON object, or null when the value is null. + /// public override void Write(Utf8JsonWriter writer, Dictionary? value, JsonSerializerOptions options) { if (value == null) diff --git a/InngestExample.sln.DotSettings.user b/InngestExample.sln.DotSettings.user deleted file mode 100644 index 80923ed..0000000 --- a/InngestExample.sln.DotSettings.user +++ /dev/null @@ -1,4 +0,0 @@ - - ForceIncluded - ForceIncluded - ForceIncluded \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..ddf9976 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.301", + "rollForward": "latestPatch" + } +} +