Skip to content

Commit 628e78a

Browse files
authored
Fix Refresh Flow 3 (#71)
* fix: validate session touch interval against idle timeout * fix: treat zero idle timeout as disabled * fix(auth): align refresh token resolution with credential delivery * fix(auth): partially prevent credential loss during failed refresh rotation * Additional Test
1 parent 036867e commit 628e78a

14 files changed

Lines changed: 548 additions & 80 deletions

File tree

‎src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout)
264264
if (IsExpired(at))
265265
return SessionState.Expired;
266266

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

270270
return SessionState.Active;
Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,69 @@
1-
using CodeBeam.UltimateAuth.Server.Abstractions;
1+
using CodeBeam.UltimateAuth.Core.Options;
2+
using CodeBeam.UltimateAuth.Server.Abstractions;
3+
using CodeBeam.UltimateAuth.Server.Auth;
24
using Microsoft.AspNetCore.Http;
35
using Microsoft.Extensions.Primitives;
46

57
namespace CodeBeam.UltimateAuth.Server.Flows;
68

79
internal sealed class RefreshTokenResolver : IRefreshTokenResolver
810
{
9-
private const string DefaultCookieName = "uar";
10-
private const string BearerPrefix = "Bearer ";
11-
private const string RefreshHeaderName = "X-Refresh-Token";
11+
private readonly IAuthFlowContextAccessor _authContext;
12+
13+
public RefreshTokenResolver(IAuthFlowContextAccessor authContext)
14+
{
15+
_authContext = authContext;
16+
}
1217

1318
public string? Resolve(HttpContext context)
1419
{
15-
if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) &&
16-
!string.IsNullOrWhiteSpace(cookieToken))
17-
{
18-
return cookieToken;
19-
}
20+
var flow = _authContext.Current;
21+
if (flow is null)
22+
return null;
2023

21-
if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader))
24+
// Explicit refresh credential takes priority.
25+
var explicitToken = ReadHeader(context, "X-Refresh-Token");
26+
27+
if (explicitToken is not null)
28+
return explicitToken;
29+
30+
var delivery = flow.Response.RefreshTokenDelivery;
31+
32+
if (delivery.Mode == TokenResponseMode.Cookie)
2233
{
23-
var value = authHeader.ToString();
24-
if (value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
34+
var cookieName = delivery.Cookie?.Name;
35+
36+
if (!string.IsNullOrWhiteSpace(cookieName) &&
37+
context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) &&
38+
!string.IsNullOrWhiteSpace(cookieToken))
2539
{
26-
var token = value.Substring(BearerPrefix.Length).Trim();
27-
if (!string.IsNullOrWhiteSpace(token))
28-
return token;
40+
return cookieToken;
2941
}
3042
}
3143

32-
if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) &&
33-
!string.IsNullOrWhiteSpace(headerToken))
44+
// Explicitly configured refresh-token response header.
45+
if (delivery.Mode == TokenResponseMode.Header && !string.IsNullOrWhiteSpace(delivery.Name))
3446
{
35-
return headerToken.ToString();
47+
return ReadHeader(context, delivery.Name);
3648
}
3749

3850
return null;
3951
}
52+
53+
private static string? ReadHeader(HttpContext context, string name)
54+
{
55+
if (!context.Request.Headers.TryGetValue(name, out var values))
56+
return null;
57+
58+
var value = values.ToString().Trim();
59+
60+
if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
61+
{
62+
value = value["Bearer ".Length..].Trim();
63+
}
64+
65+
return string.IsNullOrWhiteSpace(value)
66+
? null
67+
: value;
68+
}
4069
}

‎src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
2121
if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero)
2222
errors.Add("Session.IdleTimeout cannot be negative.");
2323

24+
if (options.Session.IdleTimeout is { } idle &&
25+
options.Session.TouchInterval is { } touch &&
26+
idle > TimeSpan.Zero && touch >= idle)
27+
{
28+
errors.Add("Session.TouchInterval must be less than Session.IdleTimeout.");
29+
}
30+
2431
return errors.Count == 0
2532
? ValidateOptionsResult.Success
2633
: ValidateOptionsResult.Fail(errors);

‎src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ private async Task<RefreshFlowResult> HandleHybridAsync(AuthFlowContext flow, Re
130130
if (!validation.IsValid)
131131
return RefreshFlowResult.ReauthRequired();
132132

133+
var touchPolicy = new SessionTouchPolicy
134+
{
135+
TouchInterval = flow.Options.Session.TouchInterval,
136+
Lifetime = flow.Options.Session.Lifetime,
137+
SlidingExpiration = flow.Options.Session.SlidingExpiration,
138+
IdleTimeout = flow.Options.Session.IdleTimeout
139+
};
140+
141+
var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);
142+
143+
if (!refresh.IsSuccess || refresh.SessionId is null)
144+
return RefreshFlowResult.ReauthRequired();
145+
133146
var rotation = await _tokenRotation.RotateAsync(
134147
flow,
135148
new RefreshTokenRotationContext
@@ -144,19 +157,6 @@ private async Task<RefreshFlowResult> HandleHybridAsync(AuthFlowContext flow, Re
144157
if (!rotation.Result.IsSuccess)
145158
return RefreshFlowResult.ReauthRequired();
146159

147-
var touchPolicy = new SessionTouchPolicy
148-
{
149-
TouchInterval = flow.Options.Session.TouchInterval,
150-
Lifetime = flow.Options.Session.Lifetime,
151-
SlidingExpiration = flow.Options.Session.SlidingExpiration,
152-
IdleTimeout = flow.Options.Session.IdleTimeout
153-
};
154-
155-
var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);
156-
157-
if (!refresh.IsSuccess || refresh.SessionId is null)
158-
return RefreshFlowResult.ReauthRequired();
159-
160160
//await StoreRefreshTokenAsync(flow, rotation, request.Now, ct);
161161

162162
return RefreshFlowResult.Success(

‎src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ public async Task<RefreshTokenRotationExecution> RotateAsync(AuthFlowContext flo
9898
return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() };
9999
}
100100

101+
var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct);
102+
101103
// Only one concurrent request is allowed to consume
102104
// the current refresh token.
103105
var consumed = await store.ExecuteAsync(
@@ -130,8 +132,6 @@ public async Task<RefreshTokenRotationExecution> RotateAsync(AuthFlowContext flo
130132
return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() };
131133
}
132134

133-
// Only the winning request needs an access token.
134-
var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct);
135135
var refreshTokenInfo = new RefreshTokenInfo
136136
{
137137
Token = refreshToken.Token,

‎src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ public EfCoreRefreshTokenStore(TDbContext db, TenantExecutionContext tenant)
2020

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

23+
// TODO: Review execution strategy retries and commit-unknown scenarios.
24+
// Ensure failed transaction attempts do not retain stale tracked entities,
25+
// and evaluate recovery when the database commit succeeds but the client
26+
// does not receive the replacement refresh token.
2327
public async Task ExecuteAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
2428
{
2529
var strategy = _db.Database.CreateExecutionStrategy();

‎src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ public InMemoryRefreshTokenStore(TenantKey tenant)
1818
_tenant = tenant;
1919
}
2020

21+
// TODO: Implement transactional rollback for InMemory refresh token operations.
22+
// SemaphoreSlim provides serialization but does not guarantee atomicity.
23+
// A failure or cancellation between consuming the current token and storing
24+
// its replacement may leave the refresh token chain unusable.
25+
// Ensure all mutations participate in the same transaction boundary.
2126
public async Task ExecuteAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
2227
{
2328
await _tx.WaitAsync(ct);

‎tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,73 @@ await attacker.PostAsync(
24692469
.Be(HttpStatusCode.Unauthorized);
24702470
}
24712471

2472+
[Fact]
2473+
public async Task Refresh_Hybrid_Maui_HeaderCredentials_ShouldSupportConsecutiveRefreshes()
2474+
{
2475+
_factory.Clock.Reset();
2476+
2477+
var user = await _factory.CreateLoginUserAsync();
2478+
2479+
using var client = CreateClient(
2480+
$"maui-refresh-{Guid.NewGuid():N}");
2481+
2482+
SetClientProfile(client, "Maui");
2483+
2484+
// 1. Login
2485+
using var login = await client.PostAsJsonAsync(
2486+
"/auth/login",
2487+
new
2488+
{
2489+
identifier = user.Identifier,
2490+
secret = user.Secret
2491+
});
2492+
2493+
login.StatusCode.Should().Be(HttpStatusCode.Found);
2494+
2495+
var sessionId = ReadCredential(login, "uas");
2496+
var refreshToken = ReadCredential(login, "uar");
2497+
2498+
sessionId.Should().NotBeNullOrWhiteSpace();
2499+
refreshToken.Should().NotBeNullOrWhiteSpace();
2500+
2501+
// 2. First refresh using the credentials delivered by login
2502+
using var firstRequest = new HttpRequestMessage(
2503+
HttpMethod.Post, "/auth/refresh");
2504+
2505+
firstRequest.Headers.TryAddWithoutValidation(
2506+
"X-UAuth-Session", sessionId);
2507+
2508+
firstRequest.Headers.TryAddWithoutValidation(
2509+
"X-Refresh-Token", refreshToken);
2510+
2511+
using var first = await client.SendAsync(firstRequest);
2512+
2513+
first.StatusCode.Should().Be(HttpStatusCode.NoContent);
2514+
2515+
var rotatedRefreshToken = ReadCredential(first, "uar");
2516+
2517+
rotatedRefreshToken.Should().NotBeNullOrWhiteSpace();
2518+
rotatedRefreshToken.Should().NotBe(refreshToken);
2519+
2520+
// Session ID may be reissued depending on the refresh policy.
2521+
var nextSessionId =
2522+
TryReadCredential(first, "uas") ?? sessionId;
2523+
2524+
// 3. Second refresh using the newly delivered refresh token
2525+
using var secondRequest = new HttpRequestMessage(
2526+
HttpMethod.Post, "/auth/refresh");
2527+
2528+
secondRequest.Headers.TryAddWithoutValidation(
2529+
"X-UAuth-Session", nextSessionId);
2530+
2531+
secondRequest.Headers.TryAddWithoutValidation(
2532+
"X-Refresh-Token", rotatedRefreshToken);
2533+
2534+
using var second = await client.SendAsync(secondRequest);
2535+
2536+
second.StatusCode.Should().Be(HttpStatusCode.NoContent);
2537+
}
2538+
24722539

24732540

24742541
private async Task<string> LoginAsync(IntegrationTestUser user, string profile = "BlazorServer")
@@ -2705,4 +2772,25 @@ private static void SetTenant(HttpClient client, string tenant)
27052772
"X-Tenant",
27062773
tenant);
27072774
}
2775+
2776+
private static string ReadCredential(HttpResponseMessage response, string headerName)
2777+
{
2778+
var value = TryReadCredential(response, headerName);
2779+
value.Should().NotBeNullOrWhiteSpace($"response must deliver the {headerName} credential");
2780+
2781+
return value!;
2782+
}
2783+
2784+
private static string? TryReadCredential(HttpResponseMessage response, string headerName)
2785+
{
2786+
if (!response.Headers.TryGetValues(headerName, out var values))
2787+
return null;
2788+
2789+
var raw = values.Single().Trim();
2790+
2791+
if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
2792+
raw = raw["Bearer ".Length..].Trim();
2793+
2794+
return string.IsNullOrWhiteSpace(raw) ? null : raw;
2795+
}
27082796
}

‎tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,30 @@ public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph()
415415
next.StatusCode.Should().Be(HttpStatusCode.NoContent);
416416
}
417417

418+
[Theory]
419+
[InlineData("BlazorServer")]
420+
[InlineData("BlazorWasm")]
421+
public async Task ZeroIdleTimeout_RefreshShouldRemainValidBeforeSessionExpiry(string profile)
422+
{
423+
await using var factory = AuthServerFactory.Create(o =>
424+
{
425+
o.Session.Lifetime = TimeSpan.FromHours(1);
426+
o.Session.MaxLifetime = null;
427+
o.Session.IdleTimeout = TimeSpan.Zero;
428+
o.Session.TouchInterval = TimeSpan.FromMinutes(5);
429+
o.Session.SlidingExpiration = true;
430+
o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);
431+
});
432+
433+
using var client = CreateClient(factory, profile);
434+
await Login(factory, client);
435+
436+
factory.Clock.Advance(TimeSpan.FromMinutes(30));
437+
438+
using var response = await Refresh(client, profile);
439+
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
440+
}
441+
418442

419443
private static AuthServerFactory CreateFactory(bool sliding, TimeSpan? maxLifetime = null)
420444
{

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,35 @@ public void Revoking_already_revoked_chain_is_idempotent()
147147

148148
Assert.Same(revoked1, revoked2);
149149
}
150+
151+
[Theory]
152+
[InlineData(null, 60, SessionState.Active)]
153+
[InlineData(0, 60, SessionState.Active)]
154+
[InlineData(10, 9, SessionState.Active)]
155+
[InlineData(10, 10, SessionState.Expired)]
156+
[InlineData(10, 11, SessionState.Expired)]
157+
public void GetState_ShouldRespectIdleTimeout(int? idleSeconds, int elapsedSeconds, SessionState expectedState)
158+
{
159+
var now = DateTimeOffset.UtcNow;
160+
161+
var chain = UAuthSessionChain.Create(
162+
SessionChainId.New(),
163+
SessionRootId.New(),
164+
tenant: TenantKey.Single,
165+
userKey: UserKey.FromString("user-1"),
166+
now,
167+
null,
168+
TestDevice.Default(),
169+
ClaimsSnapshot.Empty,
170+
securityVersion: 0
171+
);
172+
173+
var idleTimeout = idleSeconds.HasValue
174+
? TimeSpan.FromSeconds(idleSeconds.Value)
175+
: (TimeSpan?)null;
176+
177+
var state = chain.GetState(now.AddSeconds(elapsedSeconds), idleTimeout);
178+
179+
Assert.Equal(expectedState, state);
180+
}
150181
}

0 commit comments

Comments
 (0)