Skip to content
27 changes: 25 additions & 2 deletions src/Orbit.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ await RecordDirectAuthAuditAsync(
}

[HttpPost("refresh")]
[DistributedRateLimit("auth")]
[DistributedRateLimit("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh(
Expand All @@ -225,7 +225,7 @@ public async Task<IActionResult> Refresh(
}

[HttpPost("operations/refresh")]
[DistributedRateLimit("auth")]
[DistributedRateLimit("refresh")]
[AllowAnonymous]
public async Task<IActionResult> RefreshOperation(
[FromBody] RefreshSessionOperationRequest request,
Expand Down Expand Up @@ -306,6 +306,26 @@ await RecordDirectAuthAuditAsync(
policyReason: result.Error));
}

[Authorize]
[HttpPost("logout-all")]
[DistributedRateLimit("auth")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
var command = new LogoutAllSessionsCommand(HttpContext.GetUserId());
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
{
LogAllSessionsRevoked(logger, HttpContext.GetUserId(), HttpContext.GetRequestId());
return Ok(new { message = "Logged out of all sessions" });
}

LogSessionRevocationFailed(logger, result.Error, HttpContext.GetRequestId());
return result.ToErrorResult();
}

[Authorize]
[HttpPost("request-deletion")]
[DistributedRateLimit("auth")]
Expand Down Expand Up @@ -392,6 +412,9 @@ public async Task<IActionResult> ConfirmDeletion(
[LoggerMessage(EventId = 14, Level = LogLevel.Warning, Message = "Session revocation failed: {Error}. RequestId={RequestId}")]
private static partial void LogSessionRevocationFailed(ILogger logger, string? error, string requestId);

[LoggerMessage(EventId = 15, Level = LogLevel.Information, Message = "All sessions revoked for {UserId}. RequestId={RequestId}")]
private static partial void LogAllSessionsRevoked(ILogger logger, Guid userId, string requestId);

private static AgentExecuteOperationResponse BuildOperationResponse(
string operationId,
AgentOperationStatus status,
Expand Down
79 changes: 77 additions & 2 deletions src/Orbit.Api/RateLimiting/DistributedRateLimitAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Orbit.Api.Extensions;
using Orbit.Application.Auth.Validators;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

Expand All @@ -16,19 +19,25 @@
=> new DistributedRateLimitFilter(
policyName,
serviceProvider.GetRequiredService<IDistributedRateLimitService>(),
serviceProvider.GetRequiredService<IAuthSessionService>(),
serviceProvider.GetRequiredService<ILogger<DistributedRateLimitFilter>>());
}

public sealed partial class DistributedRateLimitFilter(
string policyName,
IDistributedRateLimitService distributedRateLimitService,
IAuthSessionService authSessionService,
ILogger<DistributedRateLimitFilter> logger) : IAsyncActionFilter
{
private const string FailOpenPolicy = "support";

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var partitionKey = ResolvePartitionKey(policyName, context.HttpContext, context.ActionArguments.Values);
var partitionKey = await ResolvePartitionKeyAsync(
policyName,
context.HttpContext,
context.ActionArguments.Values,
context.HttpContext.RequestAborted);
DistributedRateLimitDecision decision;

try
Expand Down Expand Up @@ -94,14 +103,24 @@
await next();
}

private static string ResolvePartitionKey(string policyName, HttpContext context, IEnumerable<object?> actionArguments)
private async Task<string> ResolvePartitionKeyAsync(
string policyName,
HttpContext context,
IEnumerable<object?> actionArguments,
CancellationToken cancellationToken)
{
if (context.User.Identity?.IsAuthenticated == true)
return $"user:{context.GetUserId()}";

if (TryResolveEmailPartitionKey(policyName, actionArguments, out var emailPartitionKey))
return emailPartitionKey;

if (TryExtractRefreshToken(policyName, actionArguments, out var refreshToken)
&& await authSessionService.HasSessionForTokenAsync(refreshToken, cancellationToken))
{
return BuildRefreshTokenPartitionKey(policyName, refreshToken);
}

return $"ip:{context.GetClientIpAddress() ?? "unknown"}";
}

Expand Down Expand Up @@ -147,11 +166,67 @@
return false;
}

private static readonly HashSet<string> RefreshTokenPartitionedPolicies =
new(StringComparer.OrdinalIgnoreCase) { "refresh" };

/// <summary>
/// For unauthenticated requests under the <c>refresh</c> policy, extracts the request's refresh token
/// when it matches the exact server-issued shape (<see cref="RefreshTokenRules.IsWellFormed"/>). Format
/// alone is not enough to earn a per-session bucket: the caller additionally confirms the token maps to
/// a real stored session before partitioning by it, so a malformed OR well-formed-but-forged token —
/// the "vary the body to mint a fresh, never-throttled bucket" bypass, which is as cheap for an attacker
/// as minting a real token — never yields a private bucket and instead falls back to per-IP throttling.
/// Returns false when the policy isn't refresh partitioned or no well-formed refresh token is present.
/// </summary>
public static bool TryExtractRefreshToken(
string policyName,
IEnumerable<object?> actionArguments,
out string refreshToken)
{
refreshToken = string.Empty;

if (!RefreshTokenPartitionedPolicies.Contains(policyName))
return false;

foreach (var argument in actionArguments)
{
if (argument is null)
continue;

var tokenProperty = argument.GetType().GetProperty(
"RefreshToken",
BindingFlags.Public | BindingFlags.Instance);

if (tokenProperty?.PropertyType != typeof(string))
continue;

if (tokenProperty.GetValue(argument) is not string rawToken || !RefreshTokenRules.IsWellFormed(rawToken))
continue;

refreshToken = rawToken;
return true;
}

return false;
}

/// <summary>
/// Builds the per-session rate-limit partition key for a refresh token already confirmed to map to a
/// real stored session. The token is SHA-256 hashed so the partition key (which is logged) never carries
/// the raw secret; the same token always maps to the same bucket, so a stolen or targeted token cannot
/// escape throttling by rotating source IPs.
/// </summary>
public static string BuildRefreshTokenPartitionKey(string policyName, string refreshToken) =>
$"{policyName.ToLowerInvariant()}:token:{HashRefreshToken(refreshToken)}";

private static string HashRefreshToken(string refreshToken) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));

[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
Message = "Rate limit rejected. Policy={PolicyName} PartitionKey={PartitionKey} Count={CurrentCount}/{PermitLimit} RetryAfterSeconds={RetryAfterSeconds} {Method} {Path} RequestId={RequestId}")]
private static partial void LogRateLimitRejected(

Check warning on line 229 in src/Orbit.Api/RateLimiting/DistributedRateLimitAttribute.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 9 parameters, which is greater than the 7 authorized.
ILogger logger,
string policyName,
string partitionKey,
Expand Down
32 changes: 32 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,38 @@
}
}
},
"/api/Auth/logout-all": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/Auth/request-deletion": {
"post": {
"tags": [
Expand Down
16 changes: 16 additions & 0 deletions src/Orbit.Application/Auth/Commands/LogoutAllSessionsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using MediatR;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Auth.Commands;

public record LogoutAllSessionsCommand(Guid UserId) : IRequest<Result>;

public class LogoutAllSessionsCommandHandler(IAuthSessionService authSessionService)
: IRequestHandler<LogoutAllSessionsCommand, Result>
{
public Task<Result> Handle(LogoutAllSessionsCommand request, CancellationToken cancellationToken)
{
return authSessionService.RevokeAllSessionsAsync(request.UserId, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;
using Orbit.Application.Auth.Commands;

namespace Orbit.Application.Auth.Validators;

public class LogoutAllSessionsCommandValidator : AbstractValidator<LogoutAllSessionsCommand>
{
public LogoutAllSessionsCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ public class LogoutSessionCommandValidator : AbstractValidator<LogoutSessionComm
{
public LogoutSessionCommandValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty();
RefreshTokenRules.AddRefreshTokenRules(RuleFor(x => x.RefreshToken));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ public class RefreshSessionCommandValidator : AbstractValidator<RefreshSessionCo
{
public RefreshSessionCommandValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty();
RefreshTokenRules.AddRefreshTokenRules(RuleFor(x => x.RefreshToken));
}
}
20 changes: 20 additions & 0 deletions src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using FluentValidation;

namespace Orbit.Application.Auth.Validators;

public static class RefreshTokenRules
{
public const int TokenLength = 128;

public static bool IsWellFormed(string? token) =>
token is { Length: TokenLength }
&& token.All(static character => character is (>= '0' and <= '9') or (>= 'A' and <= 'F'));

public static void AddRefreshTokenRules<T>(IRuleBuilder<T, string> rule)
{
rule
.NotEmpty()
.Must(token => IsWellFormed(token))
.WithMessage("Refresh token format is invalid.");
}
}
1 change: 1 addition & 0 deletions src/Orbit.Domain/Common/DomainErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static class DomainErrors
{
public static readonly AppError UserIdRequired = new("USER_ID_REQUIRED", "User ID is required.");
public static readonly AppError TokenHashRequired = new("TOKEN_HASH_REQUIRED", "Token hash is required.");
public static readonly AppError SessionNotActive = new("SESSION_NOT_ACTIVE", "Session is no longer active.");

public static readonly AppError NameRequired = new("NAME_REQUIRED", "Name is required");
public static readonly AppError InvalidHandle = new("INVALID_HANDLE", "Handle must be 3-20 characters using only letters, numbers, or underscores.");
Expand Down
9 changes: 8 additions & 1 deletion src/Orbit.Domain/Entities/UserSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,18 @@ public bool CanUse(DateTime nowUtc) =>
RevokedAtUtc is null &&
(!ExpiresAtUtc.HasValue || ExpiresAtUtc.Value > nowUtc);

public void Rotate(string newTokenHash, DateTime? newExpiresAtUtc, DateTime usedAtUtc)
public Result Rotate(string newTokenHash, DateTime? newExpiresAtUtc, DateTime usedAtUtc)
{
if (string.IsNullOrWhiteSpace(newTokenHash))
return Result.Failure(DomainErrors.TokenHashRequired);

if (!CanUse(usedAtUtc))
return Result.Failure(DomainErrors.SessionNotActive);

TokenHash = newTokenHash;
ExpiresAtUtc = newExpiresAtUtc;
LastUsedAtUtc = usedAtUtc;
return Result.Success();
}

public void Revoke(DateTime revokedAtUtc)
Expand Down
9 changes: 9 additions & 0 deletions src/Orbit.Domain/Interfaces/IAuthSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,13 @@ public interface IAuthSessionService
Task<Result<SessionTokens>> CreateSessionAsync(Guid userId, string email, CancellationToken cancellationToken = default);
Task<Result<SessionTokens>> RefreshSessionAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<Result> RevokeSessionAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<Result> RevokeAllSessionsAsync(Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Returns whether a stored session exists for the given refresh token. The refresh rate limiter uses
/// this so only a token that maps to a real, server-issued session earns a per-session partition; a
/// forged or malformed token an attacker can mint for free never yields a private bucket and is instead
/// throttled per source IP, closing the token-varying rate-limit bypass.
/// </summary>
Task<bool> HasSessionForTokenAsync(string refreshToken, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,7 @@ private static AgentCapability[] AccountAndAuthCapabilities()
"AuthController.RefreshOperation",
"AuthController.Logout",
"AuthController.LogoutOperation",
"AuthController.LogoutAll",
"OAuthController.GetMetadata",
"OAuthController.Register",
"OAuthController.GetProtectedResourceMetadata",
Expand Down
Loading
Loading