Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ bin/
obj/
/packages/
riderModule.iml
*.DotSettings.user
*.DotSettings
/.idea/
/_ReSharper.Caches/.env.test
.env.test
.inngest/
119 changes: 119 additions & 0 deletions Inngest.Tests/InngestClientAuthBehaviorTests.cs
Original file line number Diff line number Diff line change
@@ -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)..];
}
}

5 changes: 3 additions & 2 deletions Inngest.Tests/StepToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion Inngest/IInngestEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public interface IInngestEvent
/// <summary>
/// Custom data payload for the event
/// </summary>
public object Data { get; set; }
public object? Data { get; set; }

/// <summary>
/// Timestamp in Unix milliseconds when the event occurred
Expand Down
23 changes: 13 additions & 10 deletions Inngest/InngestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -379,12 +382,12 @@ private async Task<bool> 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;
}

Expand Down Expand Up @@ -1467,7 +1470,7 @@ private async Task HandleIntrospectionRequest(HttpContext context)
}

// Always include the unauthenticated response fields
var responseObj = new Dictionary<string, object>
var responseObj = new Dictionary<string, object?>
{
["function_count"] = _functions.Count,
["has_event_key"] = !string.IsNullOrEmpty(_eventKey),
Expand Down Expand Up @@ -1542,4 +1545,4 @@ private static object BuildStepRetryConfig(RetryOptions retry)

return config;
}
}
}
6 changes: 3 additions & 3 deletions Inngest/InngestEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class InngestEvent : IInngestEvent
/// Must be an object, in order to encourage evolving data.
/// </summary>
[JsonPropertyName("data")]
public object Data { get; set; } = new();
public object? Data { get; set; }

/// <summary>
/// Timestamp in Unix milliseconds when the event occurred
Expand Down Expand Up @@ -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();
}

Expand All @@ -86,4 +86,4 @@ public InngestEvent WithIdempotencyKey(string key)
IdempotencyKey = key;
return this;
}
}
}
2 changes: 1 addition & 1 deletion Inngest/InngestFunctionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static FunctionDefinition WithStep(this FunctionDefinition function, stri
/// </summary>
/// <param name="function">The function definition</param>
/// <param name="stepId">The ID of the step</param>
/// <param name="duration">Duration of the sleep in seconds (informational only)</param>
/// <param name="durationSeconds">Duration of the sleep in seconds (informational only)</param>
/// <returns>The updated function definition</returns>
public static FunctionDefinition WithSleep(this FunctionDefinition function, string stepId, int durationSeconds)
{
Expand Down
2 changes: 1 addition & 1 deletion Inngest/Internal/JsonCanonicalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static string Canonicalize(string json)
/// <returns>Canonicalized JSON as UTF-8 bytes</returns>
public static byte[] Canonicalize(byte[] jsonBytes)
{
if (jsonBytes == null || jsonBytes.Length == 0)
if (jsonBytes.Length == 0)
return jsonBytes;

try
Expand Down
6 changes: 6 additions & 0 deletions Inngest/JsonConverters/StringToDictionaryConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ namespace Inngest.JsonConverters;
/// </summary>
public class StringToDictionaryConverter : JsonConverter<Dictionary<string, string>?>
{
/// <summary>
/// Reads JSON that may be an object, string, or null into a string dictionary.
/// </summary>
public override Dictionary<string, string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
Expand Down Expand Up @@ -54,6 +57,9 @@ public class StringToDictionaryConverter : JsonConverter<Dictionary<string, stri
throw new JsonException($"Cannot convert {reader.TokenType} to Dictionary<string, string>");
}

/// <summary>
/// Writes the dictionary as a JSON object, or null when the value is null.
/// </summary>
public override void Write(Utf8JsonWriter writer, Dictionary<string, string>? value, JsonSerializerOptions options)
{
if (value == null)
Expand Down
4 changes: 0 additions & 4 deletions InngestExample.sln.DotSettings.user

This file was deleted.

7 changes: 7 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "9.0.301",
"rollForward": "latestPatch"
}
}