first commit

This commit is contained in:
Vitalii Litvinchuk
2026-06-13 23:23:50 +03:00
commit 23958e8e2c
72 changed files with 6142 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
namespace SequenceAuth.Lib;
public interface ISequenceStore
{
Task<Option<SequenceData>> GetSequenceAsync(string sequenceId);
Task<StoreOutcome> SaveSequenceAsync(string sequenceId, SequenceData data);
Task<StoreOutcome> InvalidateUserSessionsAsync(string userId);
}
+38
View File
@@ -0,0 +1,38 @@
namespace SequenceAuth.Lib;
public enum SequenceState
{
Initialized,
Active,
Rotated,
Compromised
}
public enum ValidationOutcome
{
Success,
SequenceNotFound,
LimitExceeded,
CompromisedSequenceDetected,
InternalError
}
public enum OptionState
{
Some,
None
}
public record Option<T>(T? Value, OptionState State)
{
public static Option<T> Some(T value) => new(value, OptionState.Some);
public static Option<T> None() => new(default, OptionState.None);
}
public enum StoreOutcome
{
Success,
Failure
}
public record SequenceData(string UserId, int RequestsRemaining, SequenceState State, string NextSequenceId);
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- NuGet Package Metadata -->
<PackageId>SequenceAuth</PackageId>
<Version>1.0.0</Version>
<Authors>YourName</Authors>
<Description>A C# library for Sequence Token Rotation authorization using Redis.</Description>
<RepositoryUrl>https://github.com/your-repo/SequenceAuth</RepositoryUrl>
<PackageTags>authentication;redis;security</PackageTags>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<!-- Use FrameworkReference for ASP.NET Core types (like HttpContext) instead of the outdated NuGet package -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using System;
namespace SequenceAuth.Lib;
public static class SequenceAuthExtensions
{
public static IServiceCollection AddSequenceAuth(this IServiceCollection services, Action<SequenceAuthOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.AddScoped<SequenceManager>();
return services;
}
}
@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace SequenceAuth.Lib;
public class SequenceAuthMiddleware(RequestDelegate next, IOptions<SequenceAuthOptions> options)
{
private readonly SequenceAuthOptions _options = options?.Value ?? throw new InvalidOperationException("SequenceAuthOptions must be registered in DI using AddSequenceAuth.");
public async Task InvokeAsync(HttpContext context, SequenceManager sequenceManager)
{
await (context.Request.Method switch
{
"OPTIONS" => next(context),
_ => ProcessPath(context, sequenceManager)
});
}
private async Task ProcessPath(HttpContext context, SequenceManager sequenceManager)
{
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
var isIgnored = _options.IgnoredPaths.FirstOrDefault(x => path.StartsWith(x));
await (isIgnored switch
{
null => CheckHeader(context, sequenceManager),
_ => next(context)
});
}
private async Task CheckHeader(HttpContext context, SequenceManager sequenceManager)
{
var headerCount = context.Request.Headers[_options.AuthHeaderName].Count;
await (headerCount switch
{
0 => HandleMissingHeader(context),
> 0 => ProcessWithHeader(context, sequenceManager, context.Request.Headers[_options.AuthHeaderName].ToString()!),
_ => HandleMissingHeader(context)
});
}
private Task HandleMissingHeader(HttpContext context)
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
}
private async Task ProcessWithHeader(HttpContext context, SequenceManager sequenceManager, string sequenceId)
{
var result = await sequenceManager.ValidateAndRotateAsync(sequenceId);
await (result.Outcome switch
{
ValidationOutcome.Success => HandleSuccess(context, result.NextSequence, result.UserId, result.RequestsRemaining),
_ => HandleFailure(context, result.Outcome)
});
}
private async Task HandleSuccess(HttpContext context, Option<string> nextSequenceOpt, Option<string> userIdOpt, Option<int> requestsRemainingOpt)
{
_ = nextSequenceOpt.State switch
{
OptionState.Some => AddHeader(context, nextSequenceOpt.Value!),
OptionState.None => 0,
_ => 0
};
_ = userIdOpt.State switch
{
OptionState.Some => AddUserContext(context, userIdOpt.Value!),
OptionState.None => 0,
_ => 0
};
_ = requestsRemainingOpt.State switch
{
OptionState.Some => AddRemainingHeader(context, requestsRemainingOpt.Value),
OptionState.None => 0,
_ => 0
};
await next(context);
}
private int AddRemainingHeader(HttpContext context, int remaining)
{
context.Response.Headers.Append(_options.RequestsRemainingHeaderName, remaining.ToString());
return 1;
}
private int AddHeader(HttpContext context, string value)
{
context.Response.Headers.Append(_options.NextHeaderName, value);
return 1;
}
private int AddUserContext(HttpContext context, string userId)
{
context.Items[_options.UserIdItemKey] = userId;
return 1;
}
private Task HandleFailure(HttpContext context, ValidationOutcome outcome)
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
}
}
@@ -0,0 +1,11 @@
namespace SequenceAuth.Lib;
public class SequenceAuthOptions
{
public virtual string UserIdItemKey { get; set; } = "UserId";
public virtual string AuthHeaderName { get; set; } = "X-Auth-Seq";
public virtual string NextHeaderName { get; set; } = "X-Next-Seq";
public virtual string RequestsRemainingHeaderName { get; set; } = "X-Requests-Remaining";
public virtual HashSet<string> IgnoredPaths { get; set; } = ["/scalar", "/openapi", "/favicon.ico", "/swagger"];
}
+72
View File
@@ -0,0 +1,72 @@
namespace SequenceAuth.Lib;
public record SequenceValidationResult(ValidationOutcome Outcome, Option<string> NextSequence, Option<string> UserId, Option<int> RequestsRemaining);
public class SequenceManager(ISequenceStore store)
{
public async Task<SequenceValidationResult> ValidateAndRotateAsync(string currentSequenceId)
{
var sequenceOpt = await store.GetSequenceAsync(currentSequenceId);
return sequenceOpt.State switch
{
OptionState.None => new SequenceValidationResult(ValidationOutcome.SequenceNotFound, Option<string>.None(), Option<string>.None(), Option<int>.None()),
OptionState.Some => await ProcessSequenceAsync(currentSequenceId, sequenceOpt.Value!),
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
};
}
private async Task<SequenceValidationResult> ProcessSequenceAsync(string currentSequenceId, SequenceData data)
{
return data.State switch
{
SequenceState.Compromised => new SequenceValidationResult(ValidationOutcome.CompromisedSequenceDetected, Option<string>.None(), Option<string>.None(), Option<int>.None()),
SequenceState.Rotated => await HandleRotatedSequenceAsync(data.UserId),
SequenceState.Active or SequenceState.Initialized => await HandleActiveSequenceAsync(currentSequenceId, data),
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
};
}
private async Task<SequenceValidationResult> HandleRotatedSequenceAsync(string userId)
{
await store.InvalidateUserSessionsAsync(userId);
return new SequenceValidationResult(ValidationOutcome.CompromisedSequenceDetected, Option<string>.None(), Option<string>.None(), Option<int>.None());
}
private async Task<SequenceValidationResult> HandleActiveSequenceAsync(string currentSequenceId, SequenceData data)
{
var checkLimit = CheckLimit(data.RequestsRemaining);
return checkLimit switch
{
ValidationOutcome.LimitExceeded => new SequenceValidationResult(ValidationOutcome.LimitExceeded, Option<string>.None(), Option<string>.None(), Option<int>.None()),
ValidationOutcome.Success => await RotateSequenceAsync(currentSequenceId, data),
_ => new SequenceValidationResult(ValidationOutcome.InternalError, Option<string>.None(), Option<string>.None(), Option<int>.None())
};
}
private ValidationOutcome CheckLimit(int requestsRemaining)
{
return requestsRemaining switch
{
<= 0 => ValidationOutcome.LimitExceeded,
> 0 => ValidationOutcome.Success
};
}
private async Task<SequenceValidationResult> RotateSequenceAsync(string currentSequenceId, SequenceData data)
{
var futureSequenceId = Guid.NewGuid().ToString("N");
var oldDataRotated = data with { State = SequenceState.Rotated };
await store.SaveSequenceAsync(currentSequenceId, oldDataRotated);
var newData = new SequenceData(
UserId: data.UserId,
RequestsRemaining: data.RequestsRemaining - 1,
State: SequenceState.Active,
NextSequenceId: futureSequenceId);
await store.SaveSequenceAsync(futureSequenceId, newData);
return new SequenceValidationResult(ValidationOutcome.Success, Option<string>.Some(futureSequenceId), Option<string>.Some(data.UserId), Option<int>.Some(data.RequestsRemaining - 1));
}
}