diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 04ac32cc..afad62a7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -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; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs index 3140b965..a9b1c5fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs @@ -1,4 +1,6 @@ -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; @@ -6,35 +8,62 @@ 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; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs index 9ab74357..b2d9970b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -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); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index 1e6f0d1f..4538578b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -130,6 +130,19 @@ private async Task 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 @@ -144,19 +157,6 @@ private async Task 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( diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 4d279ddc..56471b93 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -98,6 +98,8 @@ public async Task 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( @@ -130,8 +132,6 @@ public async Task 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, diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs index ae4d5a16..328f69eb 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -20,6 +20,10 @@ public EfCoreRefreshTokenStore(TDbContext db, TenantExecutionContext tenant) private DbSet DbSet => _db.Set(); + // 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 action, CancellationToken ct = default) { var strategy = _db.Database.CreateExecutionStrategy(); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index e5cb2903..294aaa4e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -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 action, CancellationToken ct = default) { await _tx.WaitAsync(ct); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index a7b343ba..93c56ff5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -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 LoginAsync(IntegrationTestUser user, string profile = "BlazorServer") @@ -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; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs index 0ecd1bdd..ce08b0a6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs @@ -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) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 2219f194..21bca5cb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -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); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index ce25294b..997462c2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -231,15 +231,26 @@ public async Task RefreshAsync_Hybrid_WhenSessionInvalid_ReturnsReauthWithoutRot } [Fact] - public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthWithoutTouch() + public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthAfterSuccessfulTouch() { var (sut, validator, touch, rotation) = CreateSut(); var validation = CreateActiveValidation(); validator - .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) .ReturnsAsync(validation); + touch + .Setup(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Success(SessionId, false)); + rotation .Setup(x => x.RotateAsync( It.IsAny(), @@ -258,7 +269,20 @@ public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthWithoutTouc CreateRequest(SessionId, "refresh-token")); AssertReauth(result); - touch.VerifyNoOtherCalls(); + + touch.Verify(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny()), + Times.Once); + + rotation.Verify(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); } [Fact] @@ -300,21 +324,16 @@ public async Task RefreshAsync_Hybrid_WhenAllStepsSucceed_ReturnsRotatedResultAn } [Fact] - public async Task RefreshAsync_Hybrid_WhenTouchFailsAfterRotation_ReturnsReauth() + public async Task RefreshAsync_Hybrid_WhenTouchFails_DoesNotRotateRefreshToken() { var (sut, validator, touch, rotation) = CreateSut(); var validation = CreateActiveValidation(); validator - .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(validation); - - rotation - .Setup(x => x.RotateAsync( - It.IsAny(), - It.IsAny(), + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), It.IsAny())) - .ReturnsAsync(CreateSuccessfulRotation()); + .ReturnsAsync(validation); touch .Setup(x => x.RefreshAsync( @@ -330,10 +349,20 @@ public async Task RefreshAsync_Hybrid_WhenTouchFailsAfterRotation_ReturnsReauth( CreateRequest(SessionId, "refresh-token")); AssertReauth(result); + + touch.Verify(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny()), + Times.Once); + rotation.Verify(x => x.RotateAsync( It.IsAny(), It.IsAny(), - It.IsAny()), Times.Once); + It.IsAny()), + Times.Never); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs index 01fd279b..5a5c3739 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs @@ -1,54 +1,121 @@ -using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Tests.Unit; public sealed class RefreshTokenResolverTests { - private readonly RefreshTokenResolver _sut = new(); + [Fact] + public void Resolve_WhenCustomCookieNameIsConfigured_ReturnsCookieToken() + { + var context = TestHttpContext.Create(); + context.Request.Headers.Cookie = "custom-refresh=refresh-token"; + + var result = Resolve( + context, + CookieDelivery("custom-refresh")); + + Assert.Equal("refresh-token", result); + } + + [Fact] + public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersRefreshHeader() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Authorization = "Bearer session-id"; + context.Request.Headers["X-Refresh-Token"] = "refresh-token"; + + var result = Resolve( + context, + CookieDelivery()); + + Assert.Equal("refresh-token", result); + } + + [Fact] + public void Resolve_WhenMauiRefreshHeaderIsConfigured_ReturnsRefreshToken() + { + var context = TestHttpContext.Create(); + + context.Request.Headers["uar"] = "Bearer refresh-token"; + + var result = Resolve( + context, + HeaderDelivery("uar")); + + Assert.Equal("refresh-token", result); + } [Fact] public void Resolve_WhenCookieContainsToken_ReturnsCookieToken() { - var context = CreateContext(); + var context = TestHttpContext.Create(); context.Request.Headers.Cookie = "uar=cookie-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); Assert.Equal("cookie-token", result); } [Fact] - public void Resolve_WhenAuthorizationContainsBearerToken_ReturnsBearerToken() + public void Resolve_WhenAuthorizationContainsBearerToken_ReturnsNull() { var context = CreateContext(); context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); - Assert.Equal("bearer-token", result); + Assert.Null(result); } - [Fact] - public void Resolve_WhenBearerSchemeUsesDifferentCasing_ReturnsBearerToken() + [Theory] + [InlineData("Bearer bearer-token")] + [InlineData("bEaReR bearer-token")] + public void Resolve_WhenAuthorizationIsNotConfiguredAsRefreshHeader_ReturnsNull(string authorization) { - var context = CreateContext(); - context.Request.Headers.Authorization = "bEaReR bearer-token"; + var context = TestHttpContext.Create(); + context.Request.Headers.Authorization = authorization; + + var result = Resolve(context, HeaderDelivery("uar")); + + Assert.Null(result); + } + + [Theory] + [InlineData("Bearer bearer-token")] + [InlineData("bEaReR bearer-token")] + public void Resolve_WhenAuthorizationIsConfiguredAsRefreshHeader_ReturnsToken(string authorization) + { + var context = TestHttpContext.Create(); + context.Request.Headers.Authorization = authorization; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("Authorization")); Assert.Equal("bearer-token", result); } - [Fact] - public void Resolve_WhenRefreshHeaderContainsToken_ReturnsHeaderToken() + [Theory] + [InlineData("header-token", "header-token")] + [InlineData("Bearer header-token", "header-token")] + [InlineData("bEaReR header-token", "header-token")] + public void Resolve_WhenRefreshHeaderContainsToken_ReturnsHeaderToken( + string header, + string expected) { var context = CreateContext(); - context.Request.Headers["X-Refresh-Token"] = "header-token"; + context.Request.Headers["X-Refresh-Token"] = header; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("X-Refresh-Token")); - Assert.Equal("header-token", result); + Assert.Equal(expected, result); } [Fact] @@ -56,7 +123,7 @@ public void Resolve_WhenNoTokenExists_ReturnsNull() { var context = CreateContext(); - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); Assert.Null(result); } @@ -68,21 +135,22 @@ public void Resolve_WhenCookieAndBearerExist_PrefersCookie() context.Request.Headers.Cookie = "uar=cookie-token"; context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); Assert.Equal("cookie-token", result); } [Fact] - public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersBearer() + public void Resolve_WhenCookieAndRefreshHeaderExist_PrefersRefreshHeader() { - var context = CreateContext(); - context.Request.Headers.Authorization = "Bearer bearer-token"; + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=cookie-token"; context.Request.Headers["X-Refresh-Token"] = "header-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); - Assert.Equal("bearer-token", result); + Assert.Equal("header-token", result); } [Theory] @@ -93,13 +161,13 @@ public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersBearer() [InlineData("Bearer")] [InlineData("Bearer ")] [InlineData("Bearer ")] - public void Resolve_WhenAuthorizationDoesNotContainValidBearerToken_ReturnsNull( - string authorization) + public void Resolve_WhenAuthorizationIsPresentWithoutRefreshCredential_ReturnsNull( + string authorization) { var context = CreateContext(); context.Request.Headers.Authorization = authorization; - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); Assert.Null(result); } @@ -111,23 +179,112 @@ public void Resolve_WhenBearerTokenIsEmpty_FallsBackToRefreshHeader() context.Request.Headers.Authorization = "Bearer "; context.Request.Headers["X-Refresh-Token"] = "header-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("X-Refresh-Token")); Assert.Equal("header-token", result); } [Fact] - public void Resolve_WhenCookieIsEmpty_FallsBackToBearerToken() + public void Resolve_WhenCookieIsEmpty_DoesNotUseAuthorizationAsRefreshToken() { - var context = CreateContext(); + var context = TestHttpContext.Create(); + context.Request.Headers.Cookie = "uar="; context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); - Assert.Equal("bearer-token", result); + Assert.Null(result); + } + + [Fact] + public void Resolve_WhenCustomCookieIsConfigured_DoesNotReadDefaultCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=wrong-token"; + + var result = Resolve(context, CookieDelivery("custom-refresh")); + + Assert.Null(result); } + [Fact] + public void Resolve_WhenExplicitRefreshHeaderIsEmpty_FallsBackToCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=cookie-token"; + context.Request.Headers["X-Refresh-Token"] = " "; + + var result = Resolve(context, CookieDelivery("uar")); + + Assert.Equal("cookie-token", result); + } + + [Fact] + public void Resolve_WhenBearerSessionAndRefreshCookieExist_ReturnsRefreshCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Authorization = "Bearer session-id"; + context.Request.Headers.Cookie = "uar=refresh-token"; + + var result = Resolve(context, CookieDelivery("uar")); + + Assert.Equal("refresh-token", result); + } + + private static DefaultHttpContext CreateContext() => new(); + + private static string? Resolve(HttpContext context, CredentialResponseOptions refreshDelivery) + { + var response = new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: refreshDelivery, + redirect: EffectiveRedirectResponse.Disabled + ); + + var flow = AuthFlowTestFactory.New(response: response, mode: UAuthMode.Hybrid); + + var httpAccessor = new HttpContextAccessor + { + HttpContext = context + }; + + var authAccessor = new AuthFlowContextAccessor(httpAccessor); + authAccessor.Set(flow); + + var resolver = new RefreshTokenResolver(authAccessor); + + return resolver.Resolve(context); + } + + private static CredentialResponseOptions CookieDelivery(string cookieName = "uar") + { + return new CredentialResponseOptions + { + Kind = GrantKind.RefreshToken, + Mode = TokenResponseMode.Cookie, + TokenFormat = TokenFormat.Opaque + }.WithCookie(new UAuthCookieOptions + { + Name = cookieName + }); + } + + private static CredentialResponseOptions HeaderDelivery(string headerName = "uar") + { + return new CredentialResponseOptions + { + Kind = GrantKind.RefreshToken, + Mode = TokenResponseMode.Header, + TokenFormat = TokenFormat.Opaque, + HeaderFormat = HeaderTokenFormat.Bearer, + Name = headerName + }; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 4c96b47c..5442498c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -540,7 +540,7 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); + Times.Once); } [Fact] @@ -559,6 +559,67 @@ public void RefreshTokenInfo_DoesNotExposeTokenHash() typeof(RefreshTokenInfo).GetProperty("TokenHash").Should().BeNull(); } + [Fact] + public async Task RotateAsync_WhenAccessTokenIssuanceThrows_DoesNotConsumeRefreshToken() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + + var validator = CreateValidator(validation); + var store = CreateExecutableStore(); + var storeFactory = CreateStoreFactory(tenant, store); + var claimsProvider = new Mock(); + var issuer = new Mock(); + + var refreshToken = CreateRefreshTokenIssuanceResult(); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync(refreshToken); + + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("AT issuance failed")); + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object, + claimsProvider.Object); + + var act = () => sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); + + await act.Should() + .ThrowAsync() + .WithMessage("AT issuance failed"); + + store.Verify(x => x.TryConsumeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + store.Verify(x => x.StoreAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + store.Verify(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny()), + Times.Never); + } + private static Mock CreateValidator(RefreshTokenValidationResult result) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 7296dd99..781d89f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -133,6 +133,39 @@ public void Pkce_authorization_code_lifetime_must_be_positive() Assert.Contains("Pkce.AuthorizationCodeLifetimeSeconds must be > 0", ex.Message); } + [Theory] + [InlineData(10, 10)] + [InlineData(15, 10)] + public void TouchInterval_GreaterThanOrEqualToIdleTimeout_ShouldFailValidation(int touchSeconds, int idleSeconds) + { + var services = new ServiceCollection(); + + services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection() + .Build()); + + services.AddOptions() + .Configure(o => + { + o.Session.TouchInterval = TimeSpan.FromSeconds(touchSeconds); + o.Session.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerSessionOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws(() => + _ = provider.GetRequiredService>().Value); + + Assert.Contains( + "Session.TouchInterval must be less than Session.IdleTimeout.", + ex.Message); + } + [Fact] public void MultiTenant_enabled_without_resolver_should_fail() {