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
56 changes: 56 additions & 0 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 Down Expand Up @@ -102,6 +105,9 @@ private static string ResolvePartitionKey(string policyName, HttpContext context
if (TryResolveEmailPartitionKey(policyName, actionArguments, out var emailPartitionKey))
return emailPartitionKey;

if (TryResolveRefreshTokenPartitionKey(policyName, actionArguments, out var refreshPartitionKey))
return refreshPartitionKey;

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

Expand Down Expand Up @@ -147,6 +153,56 @@ public static bool TryResolveEmailPartitionKey(
return false;
}

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

/// <summary>
/// For unauthenticated requests under the <c>refresh</c> policy, partitions by a SHA-256 hash of the
/// request's refresh token instead of the caller IP. A refresh token uniquely identifies one user
/// session, so hashing it yields a stable per-session bucket that a stolen or targeted token cannot
/// escape by rotating source IPs — closing the cross-IP brute-force/replay gap that IP partitioning
/// leaves open. The token is hashed so the partition key (which is logged) never carries the raw
/// secret. The token is accepted only if it matches the exact server-issued shape
/// (<see cref="RefreshTokenRules.IsWellFormed"/>); a malformed token (the trivial "vary the body to
/// mint a fresh bucket" bypass) resolves to false so the caller falls back to IP partitioning and the
/// request is still throttled per source IP. Also returns false when the policy isn't refresh
/// partitioned or no refresh token is present.
/// </summary>
public static bool TryResolveRefreshTokenPartitionKey(
string policyName,
IEnumerable<object?> actionArguments,
out string partitionKey)
{
partitionKey = 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;

partitionKey = $"{policyName.ToLowerInvariant()}:token:{HashRefreshToken(rawToken)}";
return true;
}

return false;
}

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

[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
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
1 change: 1 addition & 0 deletions src/Orbit.Domain/Interfaces/IAuthSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ 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);
}
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
34 changes: 33 additions & 1 deletion src/Orbit.Infrastructure/Services/AuthSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@ public async Task<Result<SessionTokens>> RefreshSessionAsync(string refreshToken
return Result.Failure<SessionTokens>(ErrorMessages.InvalidSession);

var newRefreshToken = GenerateRefreshToken();
session.Rotate(
var rotateResult = session.Rotate(
HashToken(newRefreshToken),
GetRefreshExpiry(nowUtc),
nowUtc);

if (rotateResult.IsFailure)
return Result.Failure<SessionTokens>(ErrorMessages.InvalidSession);

try
{
await unitOfWork.SaveChangesAsync(cancellationToken);
Expand Down Expand Up @@ -98,6 +101,35 @@ public async Task<Result> RevokeSessionAsync(string refreshToken, CancellationTo
return Result.Success();
}

public async Task<Result> RevokeAllSessionsAsync(Guid userId, CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure(DomainErrors.UserIdRequired);

var nowUtc = DateTime.UtcNow;
var activeSessions = await userSessionRepository.FindTrackedAsync(
s => s.UserId == userId && s.RevokedAtUtc == null,
cancellationToken);

if (activeSessions.Count == 0)
return Result.Success();

foreach (var session in activeSessions)
session.Revoke(nowUtc);

try
{
await unitOfWork.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
unitOfWork.DiscardChanges();
return Result.Failure(ErrorMessages.InvalidSession);
}

return Result.Success();
}

private static string GenerateRefreshToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(64));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider
new Dictionary<string, RateLimitPolicy>(StringComparer.OrdinalIgnoreCase)
{
["auth"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1),
["refresh"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1),
["waitlist"] = new(TimeSpan.FromMinutes(10), PermitLimit: 5, SegmentCount: 1),
["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4),
["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using FluentAssertions;
using NSubstitute;
using Orbit.Application.Auth.Commands;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Tests.Commands.Auth;

public class LogoutAllSessionsCommandHandlerTests
{
private readonly IAuthSessionService _authSessionService = Substitute.For<IAuthSessionService>();
private readonly LogoutAllSessionsCommandHandler _handler;

public LogoutAllSessionsCommandHandlerTests()
{
_handler = new LogoutAllSessionsCommandHandler(_authSessionService);
}

[Fact]
public async Task Handle_RevokesEverySessionForTheUser()
{
var userId = Guid.NewGuid();
_authSessionService.RevokeAllSessionsAsync(userId, Arg.Any<CancellationToken>())
.Returns(Result.Success());

var result = await _handler.Handle(new LogoutAllSessionsCommand(userId), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
await _authSessionService.Received(1).RevokeAllSessionsAsync(userId, Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_PropagatesFailure()
{
var userId = Guid.NewGuid();
_authSessionService.RevokeAllSessionsAsync(userId, Arg.Any<CancellationToken>())
.Returns(Result.Failure("User ID is required.", "USER_ID_REQUIRED"));

var result = await _handler.Handle(new LogoutAllSessionsCommand(userId), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.ErrorCode.Should().Be("USER_ID_REQUIRED");
}
}
Loading
Loading