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
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout)
if (IsExpired(at))
return SessionState.Expired;

if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value)
if (idleTimeout is { } idle && idle > TimeSpan.Zero && at - LastSeenAt >= idle)
return SessionState.Expired;

return SessionState.Active;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,69 @@
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;

namespace CodeBeam.UltimateAuth.Server.Flows;

internal sealed class RefreshTokenResolver : IRefreshTokenResolver
{
private const string DefaultCookieName = "uar";
private const string BearerPrefix = "Bearer ";
private const string RefreshHeaderName = "X-Refresh-Token";
private readonly IAuthFlowContextAccessor _authContext;

public RefreshTokenResolver(IAuthFlowContextAccessor authContext)
{
_authContext = authContext;
}

public string? Resolve(HttpContext context)
{
if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
return cookieToken;
}
var flow = _authContext.Current;
if (flow is null)
return null;

if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader))
// Explicit refresh credential takes priority.
var explicitToken = ReadHeader(context, "X-Refresh-Token");

if (explicitToken is not null)
return explicitToken;

var delivery = flow.Response.RefreshTokenDelivery;

if (delivery.Mode == TokenResponseMode.Cookie)
{
var value = authHeader.ToString();
if (value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
var cookieName = delivery.Cookie?.Name;

if (!string.IsNullOrWhiteSpace(cookieName) &&
context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) &&
!string.IsNullOrWhiteSpace(cookieToken))
{
var token = value.Substring(BearerPrefix.Length).Trim();
if (!string.IsNullOrWhiteSpace(token))
return token;
return cookieToken;
}
}

if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) &&
!string.IsNullOrWhiteSpace(headerToken))
// Explicitly configured refresh-token response header.
if (delivery.Mode == TokenResponseMode.Header && !string.IsNullOrWhiteSpace(delivery.Name))
{
return headerToken.ToString();
return ReadHeader(context, delivery.Name);
}

return null;
}

private static string? ReadHeader(HttpContext context, string name)
{
if (!context.Request.Headers.TryGetValue(name, out var values))
return null;

var value = values.ToString().Trim();

if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
value = value["Bearer ".Length..].Trim();
}

return string.IsNullOrWhiteSpace(value)
? null
: value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero)
errors.Add("Session.IdleTimeout cannot be negative.");

if (options.Session.IdleTimeout is { } idle &&
options.Session.TouchInterval is { } touch &&
idle > TimeSpan.Zero && touch >= idle)
{
errors.Add("Session.TouchInterval must be less than Session.IdleTimeout.");
}

return errors.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(errors);
Expand Down
26 changes: 13 additions & 13 deletions src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ private async Task<RefreshFlowResult> HandleHybridAsync(AuthFlowContext flow, Re
if (!validation.IsValid)
return RefreshFlowResult.ReauthRequired();

var touchPolicy = new SessionTouchPolicy
{
TouchInterval = flow.Options.Session.TouchInterval,
Lifetime = flow.Options.Session.Lifetime,
SlidingExpiration = flow.Options.Session.SlidingExpiration,
IdleTimeout = flow.Options.Session.IdleTimeout
};

var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);

if (!refresh.IsSuccess || refresh.SessionId is null)
return RefreshFlowResult.ReauthRequired();

var rotation = await _tokenRotation.RotateAsync(
flow,
new RefreshTokenRotationContext
Expand All @@ -144,19 +157,6 @@ private async Task<RefreshFlowResult> HandleHybridAsync(AuthFlowContext flow, Re
if (!rotation.Result.IsSuccess)
return RefreshFlowResult.ReauthRequired();

var touchPolicy = new SessionTouchPolicy
{
TouchInterval = flow.Options.Session.TouchInterval,
Lifetime = flow.Options.Session.Lifetime,
SlidingExpiration = flow.Options.Session.SlidingExpiration,
IdleTimeout = flow.Options.Session.IdleTimeout
};

var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);

if (!refresh.IsSuccess || refresh.SessionId is null)
return RefreshFlowResult.ReauthRequired();

//await StoreRefreshTokenAsync(flow, rotation, request.Now, ct);

return RefreshFlowResult.Success(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ public async Task<RefreshTokenRotationExecution> RotateAsync(AuthFlowContext flo
return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() };
}

var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct);

// Only one concurrent request is allowed to consume
// the current refresh token.
var consumed = await store.ExecuteAsync(
Expand Down Expand Up @@ -130,8 +132,6 @@ public async Task<RefreshTokenRotationExecution> RotateAsync(AuthFlowContext flo
return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() };
}

// Only the winning request needs an access token.
var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct);
var refreshTokenInfo = new RefreshTokenInfo
{
Token = refreshToken.Token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public EfCoreRefreshTokenStore(TDbContext db, TenantExecutionContext tenant)

private DbSet<RefreshTokenProjection> DbSet => _db.Set<RefreshTokenProjection>();

// TODO: Review execution strategy retries and commit-unknown scenarios.
// Ensure failed transaction attempts do not retain stale tracked entities,
// and evaluate recovery when the database commit succeeds but the client
// does not receive the replacement refresh token.
public async Task ExecuteAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
var strategy = _db.Database.CreateExecutionStrategy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public InMemoryRefreshTokenStore(TenantKey tenant)
_tenant = tenant;
}

// TODO: Implement transactional rollback for InMemory refresh token operations.
// SemaphoreSlim provides serialization but does not guarantee atomicity.
// A failure or cancellation between consuming the current token and storing
// its replacement may leave the refresh token chain unusable.
// Ensure all mutations participate in the same transaction boundary.
public async Task ExecuteAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
await _tx.WaitAsync(ct);
Expand Down
88 changes: 88 additions & 0 deletions tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,73 @@ await attacker.PostAsync(
.Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task Refresh_Hybrid_Maui_HeaderCredentials_ShouldSupportConsecutiveRefreshes()
{
_factory.Clock.Reset();

var user = await _factory.CreateLoginUserAsync();

using var client = CreateClient(
$"maui-refresh-{Guid.NewGuid():N}");

SetClientProfile(client, "Maui");

// 1. Login
using var login = await client.PostAsJsonAsync(
"/auth/login",
new
{
identifier = user.Identifier,
secret = user.Secret
});

login.StatusCode.Should().Be(HttpStatusCode.Found);

var sessionId = ReadCredential(login, "uas");
var refreshToken = ReadCredential(login, "uar");

sessionId.Should().NotBeNullOrWhiteSpace();
refreshToken.Should().NotBeNullOrWhiteSpace();

// 2. First refresh using the credentials delivered by login
using var firstRequest = new HttpRequestMessage(
HttpMethod.Post, "/auth/refresh");

firstRequest.Headers.TryAddWithoutValidation(
"X-UAuth-Session", sessionId);

firstRequest.Headers.TryAddWithoutValidation(
"X-Refresh-Token", refreshToken);

using var first = await client.SendAsync(firstRequest);

first.StatusCode.Should().Be(HttpStatusCode.NoContent);

var rotatedRefreshToken = ReadCredential(first, "uar");

rotatedRefreshToken.Should().NotBeNullOrWhiteSpace();
rotatedRefreshToken.Should().NotBe(refreshToken);

// Session ID may be reissued depending on the refresh policy.
var nextSessionId =
TryReadCredential(first, "uas") ?? sessionId;

// 3. Second refresh using the newly delivered refresh token
using var secondRequest = new HttpRequestMessage(
HttpMethod.Post, "/auth/refresh");

secondRequest.Headers.TryAddWithoutValidation(
"X-UAuth-Session", nextSessionId);

secondRequest.Headers.TryAddWithoutValidation(
"X-Refresh-Token", rotatedRefreshToken);

using var second = await client.SendAsync(secondRequest);

second.StatusCode.Should().Be(HttpStatusCode.NoContent);
}



private async Task<string> LoginAsync(IntegrationTestUser user, string profile = "BlazorServer")
Expand Down Expand Up @@ -2705,4 +2772,25 @@ private static void SetTenant(HttpClient client, string tenant)
"X-Tenant",
tenant);
}

private static string ReadCredential(HttpResponseMessage response, string headerName)
{
var value = TryReadCredential(response, headerName);
value.Should().NotBeNullOrWhiteSpace($"response must deliver the {headerName} credential");

return value!;
}

private static string? TryReadCredential(HttpResponseMessage response, string headerName)
{
if (!response.Headers.TryGetValues(headerName, out var values))
return null;

var raw = values.Single().Trim();

if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
raw = raw["Bearer ".Length..].Trim();

return string.IsNullOrWhiteSpace(raw) ? null : raw;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,30 @@ public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph()
next.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

[Theory]
[InlineData("BlazorServer")]
[InlineData("BlazorWasm")]
public async Task ZeroIdleTimeout_RefreshShouldRemainValidBeforeSessionExpiry(string profile)
{
await using var factory = AuthServerFactory.Create(o =>
{
o.Session.Lifetime = TimeSpan.FromHours(1);
o.Session.MaxLifetime = null;
o.Session.IdleTimeout = TimeSpan.Zero;
o.Session.TouchInterval = TimeSpan.FromMinutes(5);
o.Session.SlidingExpiration = true;
o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);
});

using var client = CreateClient(factory, profile);
await Login(factory, client);

factory.Clock.Advance(TimeSpan.FromMinutes(30));

using var response = await Refresh(client, profile);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}


private static AuthServerFactory CreateFactory(bool sliding, TimeSpan? maxLifetime = null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,35 @@ public void Revoking_already_revoked_chain_is_idempotent()

Assert.Same(revoked1, revoked2);
}

[Theory]
[InlineData(null, 60, SessionState.Active)]
[InlineData(0, 60, SessionState.Active)]
[InlineData(10, 9, SessionState.Active)]
[InlineData(10, 10, SessionState.Expired)]
[InlineData(10, 11, SessionState.Expired)]
public void GetState_ShouldRespectIdleTimeout(int? idleSeconds, int elapsedSeconds, SessionState expectedState)
{
var now = DateTimeOffset.UtcNow;

var chain = UAuthSessionChain.Create(
SessionChainId.New(),
SessionRootId.New(),
tenant: TenantKey.Single,
userKey: UserKey.FromString("user-1"),
now,
null,
TestDevice.Default(),
ClaimsSnapshot.Empty,
securityVersion: 0
);

var idleTimeout = idleSeconds.HasValue
? TimeSpan.FromSeconds(idleSeconds.Value)
: (TimeSpan?)null;

var state = chain.GetState(now.AddSeconds(elapsedSeconds), idleTimeout);

Assert.Equal(expectedState, state);
}
}
Loading
Loading