diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 54e3346e..814fd843 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -29,16 +29,17 @@ builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshDetails = true; + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.Behavior.AllowMultipleUsernames = true; + o.UserProfile.EnableMultiProfile = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.Lifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); - o.Login.MaxFailedAttempts = 2; - o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.Behavior.AllowMultipleUsernames = true; - o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthInMemory() .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 6acf2d76..6613d3f2 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -43,21 +43,29 @@ builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshDetails = true; + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.Behavior.AllowMultipleUsernames = true; + + // No-op and touch with max lifetime example //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.Lifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); - o.Login.MaxFailedAttempts = 2; - o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.Behavior.AllowMultipleUsernames = true; + + // Infinite sliding expiration example + //o.Session.MaxLifetime = null; + //o.Session.Lifetime = TimeSpan.FromSeconds(20); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(10); }) .AddUltimateAuthInMemory(); builder.Services.AddUltimateAuthClientBlazor(o => { - //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index a8841e99..90c603f5 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -27,6 +27,8 @@ o.Reauth.Behavior = ReauthBehavior.RaiseEvent; o.Login.AllowCredentialPost = true; o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path + + o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); }); builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index b9351524..ba3ef2c7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -139,6 +139,27 @@ public SessionState GetState(DateTimeOffset at) return SessionState.Active; } + public UAuthSession ExtendExpiration(DateTimeOffset expiresAt) + { + if (expiresAt <= ExpiresAt) + return this; + + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + expiresAt, + RevokedAt, + SecurityVersionAtCreation, + Device, + Claims, + Metadata, + Version + 1 + ); + } + public UAuthSession WithChain(SessionChainId chainId) { if (!ChainId.IsUnassigned) diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 94069813..1d921461 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; @@ -69,6 +70,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); SessionSecurityContext? sessionSecurityContext = null; + var isAuthenticated = user?.IsAuthenticated ?? false; + var userKey = user?.UserId; if (!sessionCtx.IsAnonymous) { @@ -84,6 +87,19 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp ct); sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); + + if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid) + { + isAuthenticated = validation.IsValid; + + if (!isAuthenticated) + userKey = null; + } + } + else if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid) + { + isAuthenticated = false; + userKey = null; } if (!tenant.IsValid || tenant.IsUnresolved) @@ -98,8 +114,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp effectiveMode, deviceContext, tenant, - user?.IsAuthenticated ?? false, - user?.UserId, + isAuthenticated, + userKey, sessionSecurityContext, options, response, diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs index e72e8ad3..a5f76e53 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs @@ -3,4 +3,7 @@ public sealed class SessionTouchPolicy { public TimeSpan? TouchInterval { get; init; } + public TimeSpan Lifetime { get; init; } + public bool SlidingExpiration { get; init; } + public TimeSpan? IdleTimeout { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index 2b29016e..96dae0a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -14,35 +15,73 @@ public SessionTouchService(ISessionStoreFactory kernelFactory) // It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh. // That's why the service access store direcly: There is no security flow here, only validate and touch session. - public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) + public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, + DateTimeOffset now, CancellationToken ct = default) { - if (!validation.IsValid || validation.ChainId is null) + if (!validation.IsValid || validation.SessionId is null || validation.ChainId is null) + { return SessionRefreshResult.ReauthRequired(); - - if (!policy.TouchInterval.HasValue) - return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); + } var kernel = _kernelFactory.Create(validation.Tenant); + bool isValid = false; bool didTouch = false; - await kernel.ExecuteAsync(async _ => + await kernel.ExecuteAsync(async innerCt => { - var chain = await kernel.GetChainAsync(validation.ChainId.Value); + var chain = await kernel.GetChainAsync(validation.ChainId.Value, innerCt); + var session = await kernel.GetSessionAsync(validation.SessionId.Value, innerCt); + + if (chain is null || session is null) + return; - if (chain is null || chain.IsRevoked) + if (chain.GetState(now, policy.IdleTimeout) != SessionState.Active || session.GetState(now) != SessionState.Active) return; - if (now - chain.LastSeenAt < policy.TouchInterval.Value) + if (chain.ActiveSessionId != session.SessionId || + session.ChainId != chain.ChainId || + session.Tenant != validation.Tenant || + chain.Tenant != validation.Tenant || + session.UserKey != chain.UserKey) return; - var expectedVersion = chain.Version; - var touched = chain.Touch(now); + if (validation.UserKey != session.UserKey || validation.ChainId != session.ChainId) + return; + + isValid = true; + + bool shouldTouch = sessionTouchMode == SessionTouchMode.Force || !policy.TouchInterval.HasValue || now - chain.LastSeenAt >= policy.TouchInterval.Value; + + if (!shouldTouch) + return; + + var touchedChain = chain.Touch(now); + + if (policy.SlidingExpiration) + { + var proposedExpiry = now.Add(policy.Lifetime); + + var newExpiry = chain.AbsoluteExpiresAt is { } absolute + && proposedExpiry > absolute + ? absolute + : proposedExpiry; + + var extendedSession = session.ExtendExpiration(newExpiry); + + if (!ReferenceEquals(extendedSession, session)) + { + await kernel.SaveSessionAsync(extendedSession, session.Version, innerCt); + } + } + + await kernel.SaveChainAsync(touchedChain, chain.Version, innerCt); - await kernel.SaveChainAsync(touched, expectedVersion); didTouch = true; }, ct); - return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); + return isValid + ? SessionRefreshResult.Success(validation.SessionId.Value, didTouch) + : SessionRefreshResult.ReauthRequired(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index e2ac81a0..06d2c99d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -33,13 +33,15 @@ public async Task IssueSessionAsync(SessionIssuanceContext contex if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId)) throw new InvalidCastException("Can't create opaque id."); - var expiresAt = now.Add(sessionOptions.Lifetime); + var absoluteExpiresAt = sessionOptions.MaxLifetime is { } max + ? now.Add(max) + : (DateTimeOffset?)null; - if (sessionOptions.MaxLifetime is not null) + var sessionExpiresAt = now.Add(sessionOptions.Lifetime); + + if (absoluteExpiresAt is { } absolute && sessionExpiresAt > absolute) { - var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value); - if (absoluteExpiry < expiresAt) - expiresAt = absoluteExpiry; + sessionExpiresAt = absolute; } var kernel = _storeFactory.Create(context.Tenant); @@ -76,7 +78,7 @@ await kernel.ExecuteAsync(async _ => context.Tenant, context.UserKey, now, - expiresAt, + absoluteExpiresAt, context.Device, ClaimsSnapshot.Empty, root.SecurityVersion @@ -110,7 +112,7 @@ await kernel.ExecuteAsync(async _ => context.Tenant, context.UserKey, now, - expiresAt, + absoluteExpiresAt, context.Device, ClaimsSnapshot.Empty, root.SecurityVersion @@ -141,7 +143,7 @@ await kernel.ExecuteAsync(async _ => userKey: context.UserKey, chainId: chain.ChainId, now: now, - expiresAt: expiresAt, + expiresAt: sessionExpiresAt, securityVersion: root.SecurityVersion, device: context.Device, claims: context.Claims, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index abb1a703..b7bca0c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -38,8 +38,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) var c = o.Cookie; var r = o.AuthResponse; - // Session behavior - s.SlidingExpiration = true; + //s.SlidingExpiration = true; // Default value. // Default: long-lived idle session (UX friendly) s.IdleTimeout ??= TimeSpan.FromDays(7); @@ -75,7 +74,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) var c = o.Cookie; var r = o.AuthResponse; - s.SlidingExpiration = true; + //s.SlidingExpiration = true; // Default value. s.TouchInterval = null; t.IssueJwt = true; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs index 5b1fe8bc..e04e77ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -43,30 +43,21 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { if (options.Session.Lifetime <= TimeSpan.Zero) { - return ValidateOptionsResult.Fail( - "Session.Lifetime must be greater than zero."); + return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero."); } - if (options.Session.MaxLifetime is not null && - options.Session.MaxLifetime <= TimeSpan.Zero) + if (options.Session.MaxLifetime is not null && options.Session.MaxLifetime <= TimeSpan.Zero) { - return ValidateOptionsResult.Fail( - "Session.MaxLifetime must be greater than zero when specified."); + return ValidateOptionsResult.Fail("Session.MaxLifetime must be greater than zero when specified."); } } - // Only add cross-option validation beyond this point, individual options should validate in their own validators. if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime) { return ValidateOptionsResult.Fail("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime."); } - if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime) - { - return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime."); - } - return ValidateOptionsResult.Success; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index e6af7b10..1e6f0d1f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -69,7 +69,10 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.Options.Session.TouchInterval + 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); @@ -143,7 +146,10 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.Options.Session.TouchInterval + 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); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index f37c7a97..c46cceaa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -2,8 +2,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Services; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index 1a356583..a7b343ba 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -121,8 +121,7 @@ public async Task Refresh_PureOpaque_AfterTouch_ShouldNotTouchAgainBeforeNextInt var initial = await GetCurrentChainAsync(_client); - _factory.Clock.Advance( - TimeSpan.FromHours(1)); + _factory.Clock.Advance(TimeSpan.FromMinutes(6)); var first = await RefreshAsync(); @@ -1601,11 +1600,9 @@ public async Task Refresh_Hybrid_WhenSessionExpired_ShouldReturnUnauthorized() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromMinutes(30); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1625,9 +1622,7 @@ await LoginAsync( user, "BlazorWasm"); - // // Session expired, refresh token is still valid. - // factory.Clock.Advance( TimeSpan.FromMinutes(31)); @@ -1650,14 +1645,10 @@ public async Task Refresh_Hybrid_WhenChainIdleTimeoutExceeded_ShouldReturnUnauth { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1699,14 +1690,10 @@ public async Task Refresh_Hybrid_JustBeforeChainIdleTimeout_ShouldSucceed() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1749,14 +1736,10 @@ public async Task Refresh_Hybrid_AtChainIdleTimeout_ShouldReturnUnauthorized() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs new file mode 100644 index 00000000..0ecd1bdd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs @@ -0,0 +1,562 @@ +using System.Net; +using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class SessionSlidingExpirationTests +{ + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_RegularRefresh_ShouldSurviveOriginalLifetime(string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (initialSession, initialChain) = await ReadCurrent(factory, client); + var previousExpiry = initialSession.ExpiresAt; + for (int i = 0; i < 3; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var response = await Refresh(client, profile); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (session, chain) = await ReadCurrent(factory, client); + chain.ChainId.Should().Be(initialChain.ChainId); + + session.ExpiresAt.Should().BeAfter(previousExpiry); + previousExpiry = session.ExpiresAt; + } + factory.Clock.UtcNow.Should().BeAfter(initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task SlidingDisabled_RefreshShouldNotExtendExpiration(string profile) + { + await using var factory = CreateFactory(sliding: false); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (initial, _) = await ReadCurrent(factory, client); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using (var response = await Refresh(client, profile)) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (after, _) = await ReadCurrent(factory, client); + after.ExpiresAt.Should().Be(initial.ExpiresAt); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var expired = await Refresh(client, profile); + expired.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task MaxLifetime_RefreshMustNotExtendAbsoluteChainExpiry(string profile) + { + await using var factory = CreateFactory(sliding: true, maxLifetime: TimeSpan.FromDays(9)); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (_, initialChain) = await ReadCurrent(factory, client); + var absolute = initialChain.AbsoluteExpiresAt; + absolute.Should().Be(factory.Clock.UtcNow.AddDays(9)); + for (int i = 0; i < 4; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var response = await Refresh(client, profile); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (session, chain) = await ReadCurrent(factory, client); + chain.AbsoluteExpiresAt.Should().Be(absolute); + session.ExpiresAt.Should().BeOnOrBefore(absolute!.Value); + } + factory.Clock.Advance(TimeSpan.FromDays(1)); + using var boundary = await Refresh(client, profile); + boundary.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PureOpaque_NoOpBeforeInterval_ThenTouchMustExtendSession() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorServer"); + await Login(factory, client); + var (initial, chainBefore) = await ReadCurrent(factory, client); + factory.Clock.Advance(TimeSpan.FromMinutes(1)); + using (var response = await Refresh(client, "BlazorServer")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (noOp, chainNoOp) = await ReadCurrent(factory, client); + noOp.ExpiresAt.Should().Be(initial.ExpiresAt); + chainNoOp.TouchCount.Should().Be(chainBefore.TouchCount); + factory.Clock.Advance(TimeSpan.FromDays(1)); + using (var response = await Refresh(client, "BlazorServer")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (touched, chainTouched) = await ReadCurrent(factory, client); + chainTouched.TouchCount.Should().BeGreaterThan(chainBefore.TouchCount); + touched.ExpiresAt.Should().BeAfter(initial.ExpiresAt); + } + + [Fact] + public async Task Hybrid_RotatedToken_ShouldNotLeaveSessionExpiryBehind() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorWasm"); + await Login(factory, client); + var (initial, chainBefore) = await ReadCurrent(factory, client); + var oldRefreshCookie = Cookie(client, "uar"); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using (var response = await Refresh(client, "BlazorWasm")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cookie(client, "uar").Should().NotBe(oldRefreshCookie); + var (after, chainAfter) = await ReadCurrent(factory, client); + chainAfter.ChainId.Should().Be(chainBefore.ChainId); + after.ExpiresAt.Should().BeAfter(initial.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task IdentifierGet_ExpiredIdleSession_ShouldReturnUnauthorized(string profile) + { + using var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(5); + o.Session.MaxLifetime = null; + o.Session.IdleTimeout = TimeSpan.FromSeconds(10); + o.Session.TouchInterval = TimeSpan.FromSeconds(5); + }); + + var user = await factory.CreateLoginUserAsync(); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add( + "X-UDID", $"idle-regression-{Guid.NewGuid():N}"); + client.DefaultRequestHeaders.Add( + "X-UAuth-ClientProfile", profile); + + using var login = await client.PostAsJsonAsync( + "/auth/login", + new { identifier = user.Identifier, secret = user.Secret }); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + login.Headers.TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + client.DefaultRequestHeaders.Add( + "Cookie", + string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); + + // Same session must be accepted before IdleTimeout. + using var before = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + + before.StatusCode.Should().Be(HttpStatusCode.OK); + + // Expire idle time, not the session's normal lifetime. + factory.Clock.Advance(TimeSpan.FromSeconds(11)); + + // Keep the original cookie deliberately. + using var after = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + + after.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "an expired session must not remain authenticated " + + "merely because its cookie is still present"); + } + + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task InfiniteSliding_ThirtyConsecutiveRefreshes_ShouldKeepSessionAlive( + string profile) + { + await using var factory = CreateFactory( + sliding: true, + maxLifetime: null); + + using var client = CreateClient(factory, profile); + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + initialChain.AbsoluteExpiresAt.Should().BeNull(); + + var previousExpiry = initialSession.ExpiresAt; + var previousTouchCount = initialChain.TouchCount; + + for (int i = 0; i < 30; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"refresh #{i + 1} should succeed"); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.AbsoluteExpiresAt.Should().BeNull(); + + session.ExpiresAt.Should().BeAfter( + previousExpiry, + $"refresh #{i + 1} must extend expiration"); + + session.ExpiresAt.Should().Be( + factory.Clock.UtcNow.AddDays(4)); + + chain.TouchCount.Should().Be( + previousTouchCount + 1); + + previousExpiry = session.ExpiresAt; + previousTouchCount = chain.TouchCount; + } + + factory.Clock.UtcNow.Should().BeAfter( + initialSession.ExpiresAt); + + // 30 refreshes, each separated by 2 days. + factory.Clock.UtcNow.Should().Be( + initialChain.CreatedAt.AddDays(60)); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_JustBeforeSessionExpiry_ShouldExtendSession( + string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + var remaining = + initialSession.ExpiresAt + - factory.Clock.UtcNow + - TimeSpan.FromSeconds(1); + + remaining.Should().BePositive(); + + factory.Clock.Advance(remaining); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + session.ExpiresAt.Should().BeAfter(initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_ExactlyAtSessionExpiry_ShouldRejectRefresh( + string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, _) = + await ReadCurrent(factory, client); + + factory.Clock.Advance( + initialSession.ExpiresAt - factory.Clock.UtcNow); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_WithIdleTimeout_RegularRefreshes_ShouldRemainActive( + string profile) + { + await using var factory = CreateIdleSlidingFactory(); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + var previousExpiry = initialSession.ExpiresAt; + + for (int i = 0; i < 12; i++) + { + // IdleTimeout = 30 minutes. + // Refresh every 20 minutes. + factory.Clock.Advance(TimeSpan.FromMinutes(20)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"refresh #{i + 1} occurs before idle timeout"); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.LastSeenAt.Should().Be(factory.Clock.UtcNow); + session.ExpiresAt.Should().BeAfter(previousExpiry); + + previousExpiry = session.ExpiresAt; + } + + // 12 * 20 minutes = 4 hours. + // Original session lifetime = 1 hour. + factory.Clock.UtcNow.Should().BeAfter( + initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_WithIdleTimeout_ExactlyAtBoundary_ShouldRejectRefresh( + string profile) + { + await using var factory = CreateIdleSlidingFactory(); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + factory.Clock.Advance(TimeSpan.FromMinutes(30)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var (session, chain) = + await ReadCurrentDirectly(factory, initialChain); + + chain.TouchCount.Should().Be(initialChain.TouchCount); + chain.LastSeenAt.Should().Be(initialChain.LastSeenAt); + session.ExpiresAt.Should().Be(initialSession.ExpiresAt); + } + + [Fact] + public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorWasm"); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + // Both requests use the same original refresh cookie. + // Do not apply response cookies until both requests finish. + factory.Clock.Advance(TimeSpan.FromDays(2)); + + using var firstClient = CloneClientWithCookies( + factory, client, "BlazorWasm"); + + using var secondClient = CloneClientWithCookies( + factory, client, "BlazorWasm"); + + using var first = await firstClient.PostAsync( + "/auth/refresh", null); + + using var second = await secondClient.PostAsync( + "/auth/refresh", null); + + // A rotated refresh credential must not be reusable. + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + second.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Apply the successful rotation to the original client. + ApplyCookies(client, first); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.ActiveSessionId.Should().NotBeNull(); + session.ChainId.Should().Be(chain.ChainId); + session.ExpiresAt.Should().BeAfter(initialSession.ExpiresAt); + + // The successful rotated credential remains usable. + using var next = await Refresh(client, "BlazorWasm"); + + next.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + + private static AuthServerFactory CreateFactory(bool sliding, TimeSpan? maxLifetime = null) + { + var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromDays(4); + o.Session.SlidingExpiration = sliding; + o.Session.MaxLifetime = maxLifetime; + o.Session.IdleTimeout = null; + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(12); + }); + factory.Clock.Reset(); + return factory; + } + + private static HttpClient CreateClient(AuthServerFactory factory, string profile) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", $"sliding-device-{Guid.NewGuid():N}"); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + return client; + } + + private static async Task Login(AuthServerFactory factory, HttpClient client) + { + var user = await factory.CreateLoginUserAsync(); + using var response = await client.PostAsJsonAsync("/auth/login", + new { identifier = user.Identifier, secret = user.Secret }); + response.StatusCode.Should().Be(HttpStatusCode.Found); + ApplyCookies(client, response); + } + + private static async Task Refresh(HttpClient client, string profile) + { + var response = await client.PostAsync("/auth/refresh", null); + if (response.IsSuccessStatusCode && profile == "BlazorWasm") + ApplyCookies(client, response); + return response; + } + + private static void ApplyCookies(HttpClient client, HttpResponseMessage response) + { + var cookies = new Dictionary(StringComparer.Ordinal); + if (client.DefaultRequestHeaders.TryGetValues("Cookie", out var old)) + foreach (var item in string.Join("; ", old).Split("; ", StringSplitOptions.RemoveEmptyEntries)) + { + var pair = item.Split('=', 2); + if (pair.Length == 2) cookies[pair[0]] = pair[1]; + } + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + foreach (var item in values) + { + var pair = item.Split(';', 2)[0].Split('=', 2); + if (pair.Length == 2) cookies[pair[0]] = pair[1]; + } + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies.Select(x => $"{x.Key}={x.Value}"))); + } + + private static string Cookie(HttpClient client, string name) => + client.DefaultRequestHeaders.GetValues("Cookie").Single() + .Split("; ", StringSplitOptions.RemoveEmptyEntries) + .Single(x => x.StartsWith(name + "=", StringComparison.Ordinal)); + + private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrent( + AuthServerFactory factory, HttpClient client) + { + using var response = await client.PostAsJsonAsync("/auth/me/sessions/chains", + new PageRequest { PageNumber = 1, PageSize = 50 }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var page = await response.Content.ReadFromJsonAsync>(); + var chainId = page!.Items.Single(x => x.IsCurrentDevice).ChainId; + using var scope = factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var chain = await store.GetChainAsync(chainId); + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().NotBeNull(); + var session = await store.GetSessionAsync(chain.ActiveSessionId!.Value); + session.Should().NotBeNull(); + return (session!, chain); + } + + + private static AuthServerFactory CreateIdleSlidingFactory() + { + var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromHours(1); + o.Session.MaxLifetime = null; + o.Session.SlidingExpiration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(30); + o.Session.TouchInterval = TimeSpan.FromMinutes(5); + + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); + }); + + factory.Clock.Reset(); + return factory; + } + + private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrentDirectly(AuthServerFactory factory, UAuthSessionChain originalChain) + { + using var scope = factory.Services.CreateScope(); + + var store = scope.ServiceProvider + .GetRequiredService() + .Create(TenantKeys.Single); + + var chain = await store.GetChainAsync(originalChain.ChainId); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().NotBeNull(); + + var session = await store.GetSessionAsync( + chain.ActiveSessionId!.Value); + + session.Should().NotBeNull(); + + return (session!, chain); + } + + private static HttpClient CloneClientWithCookies(AuthServerFactory factory, HttpClient source, string profile) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + foreach (var header in source.DefaultRequestHeaders) + { + client.DefaultRequestHeaders.TryAddWithoutValidation( + header.Key, + header.Value); + } + + return client; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 340a615b..7296dd99 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -877,16 +877,6 @@ public void Server_access_token_exceeding_session_max_fails() => o.Token.RefreshTokenLifetime = TimeSpan.FromMinutes(4); }, "Token.AccessTokenLifetime"); - [Fact] - public void Server_refresh_token_exceeding_session_max_fails() => - ShouldReject(o => - { - ConfigureValidCrossOptions(o); - o.Session.MaxLifetime = TimeSpan.FromMinutes(5); - o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(1); - o.Token.RefreshTokenLifetime = TimeSpan.FromMinutes(6); - }, "Token.RefreshTokenLifetime"); - [Fact] public void Server_token_lifetimes_equal_to_session_max_pass() => ShouldAccept(o => diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs index df10514a..13682b85 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs @@ -20,17 +20,19 @@ public async Task RefreshAsync_WhenValidationIsInvalid_ReturnsReauthRequired() { var factory = new Mock(MockBehavior.Strict); var sut = new SessionTouchService(factory.Object); - var validation = SessionValidationResult.Invalid(SessionState.Revoked); var result = await sut.RefreshAsync( - validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionValidationResult.Invalid(SessionState.Revoked), + Policy(), SessionTouchMode.IfNeeded, Now); - result.RequiresReauth.Should().BeTrue(); result.IsSuccess.Should().BeFalse(); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); + result.RequiresReauth.Should().BeTrue(); + + factory.Verify( + x => x.Create(It.IsAny()), + Times.Never); } [Fact] @@ -38,6 +40,7 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() { var factory = new Mock(MockBehavior.Strict); var sut = new SessionTouchService(factory.Object); + var validation = SessionValidationResult.Invalid( SessionState.Active, userKey: TestUsers.User, @@ -45,62 +48,66 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.RequiresReauth.Should().BeTrue(); result.IsSuccess.Should().BeFalse(); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); + result.RequiresReauth.Should().BeTrue(); + + factory.Verify( + x => x.Create(It.IsAny()), + Times.Never); } [Fact] - public async Task RefreshAsync_WhenTouchIntervalIsDisabled_ReturnsSuccessWithoutTouchingStore() + public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsReauthRequired() { - var factory = new Mock(MockBehavior.Strict); - var sut = new SessionTouchService(factory.Object); + var factory = new InMemorySessionStoreFactory(); var validation = CreateActiveValidation(); + var sut = new SessionTouchService(factory); var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = null }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - result.SessionId.Should().Be(validation.SessionId); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); } [Fact] - public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenSessionDoesNotExist_ReturnsReauthRequired() { var factory = new InMemorySessionStoreFactory(); - var sut = new SessionTouchService(factory); var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + + var chain = CreateChain(validation, Now.AddMinutes(-2)); + await store.CreateChainAsync(chain); + + var sut = new SessionTouchService(factory); var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - result.SessionId.Should().Be(validation.SessionId); } [Fact] - public async Task RefreshAsync_WhenChainIsRevoked_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenChainIsRevoked_ReturnsReauthRequired() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); - var store = factory.Create(validation.Tenant); - - var chain = CreateChain(validation, Now.AddMinutes(-10)); - await store.CreateChainAsync(chain); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-2)); + var store = factory.Create(validation.Tenant); var revoked = chain.Revoke(Now.AddMinutes(-1)); await store.SaveChainAsync(revoked, chain.Version); @@ -108,69 +115,114 @@ public async Task RefreshAsync_WhenChainIsRevoked_ReturnsSuccessWithoutTouch() var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); + var persisted = await store.GetChainAsync(chain.ChainId); - persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); - persisted.LastSeenAt.Should().Be(revoked.LastSeenAt); persisted.TouchCount.Should().Be(revoked.TouchCount); persisted.Version.Should().Be(revoked.Version); } [Fact] - public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenSessionIsRevoked_ReturnsReauthRequired() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); + var (factory, validation, _, session) = + await CreateValidSessionAsync(Now.AddMinutes(-2)); + var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-4)); - await store.CreateChainAsync(chain); + var revoked = session.Revoke(Now.AddMinutes(-1)); - var sut = new SessionTouchService(factory); + await store.SaveSessionAsync(revoked, session.Version); - var result = await sut.RefreshAsync( + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenSessionIsExpired_ReturnsReauthRequired() + { + var (factory, validation, _, _) = + await CreateValidSessionAsync( + Now.AddMinutes(-2), + sessionExpiresAt: Now); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.Force, Now); + + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenChainIdleTimeoutElapsed_ReturnsReauthRequired() + { + var (factory, validation, _, _) = + await CreateValidSessionAsync(Now.AddMinutes(-11)); + + var result = await new SessionTouchService(factory).RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, - SessionTouchMode.IfNeeded, + Policy(idleTimeout: TimeSpan.FromMinutes(10)), + SessionTouchMode.Force, Now); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWithoutTouch() + { + var (factory, validation, chain, session) = + await CreateValidSessionAsync(Now.AddMinutes(-4)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + result.IsSuccess.Should().BeTrue(); + result.RequiresReauth.Should().BeFalse(); result.DidTouch.Should().BeFalse(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); - persisted!.LastSeenAt.Should().Be(chain.LastSeenAt); - persisted.TouchCount.Should().Be(0); + var store = factory.Create(validation.Tenant); + var persistedChain = await store.GetChainAsync(chain.ChainId); + var persistedSession = await store.GetSessionAsync(session.SessionId); + + persistedChain!.LastSeenAt.Should().Be(chain.LastSeenAt); + persistedChain.TouchCount.Should().Be(0); + persistedChain.Version.Should().Be(chain.Version); + + persistedSession!.ExpiresAt.Should().Be(session.ExpiresAt); + persistedSession.Version.Should().Be(session.Version); } [Fact] public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); - var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-6)); - await store.CreateChainAsync(chain); - - var sut = new SessionTouchService(factory); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-6)); - var result = await sut.RefreshAsync( - validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, - SessionTouchMode.IfNeeded, - Now); + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); result.SessionId.Should().Be(validation.SessionId); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + persisted!.LastSeenAt.Should().Be(Now); persisted.TouchCount.Should().Be(1); persisted.Version.Should().Be(chain.Version + 1); @@ -179,26 +231,137 @@ public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain( [Fact] public async Task RefreshAsync_WhenTouchIntervalExactlyElapsed_TouchesChain() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-5)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalIsNull_TouchesEveryRefresh() + { + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-1)); + + var policy = PolicyWithoutTouchInterval(); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, policy, SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + } + + [Fact] + public async Task RefreshAsync_WhenForceIsUsed_TouchesBeforeInterval() + { + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-1)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.Force, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + } + + [Fact] + public async Task RefreshAsync_WhenSlidingEnabled_ExtendsSessionExpiration() + { + var originalExpiry = Now.AddMinutes(10); + + var (factory, validation, chain, session) = + await CreateValidSessionAsync( + Now.AddMinutes(-6), + sessionExpiresAt: originalExpiry); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, + Policy( + slidingExpiration: true, + lifetime: TimeSpan.FromHours(1)), + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-5)); - await store.CreateChainAsync(chain); + var persistedSession = await store.GetSessionAsync(session.SessionId); + var persistedChain = await store.GetChainAsync(chain.ChainId); - var sut = new SessionTouchService(factory); + persistedSession!.ExpiresAt.Should().Be(Now.AddHours(1)); + persistedSession.Version.Should().Be(session.Version + 1); + persistedChain!.LastSeenAt.Should().Be(Now); + } - var result = await sut.RefreshAsync( + [Fact] + public async Task RefreshAsync_WhenSlidingDisabled_DoesNotExtendSession() + { + var originalExpiry = Now.AddMinutes(10); + + var (factory, validation, _, session) = + await CreateValidSessionAsync( + Now.AddMinutes(-6), + sessionExpiresAt: originalExpiry); + + var result = await new SessionTouchService(factory).RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(slidingExpiration: false), SessionTouchMode.IfNeeded, Now); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); - persisted!.LastSeenAt.Should().Be(Now); - persisted.TouchCount.Should().Be(1); + var persisted = await factory.Create(validation.Tenant) + .GetSessionAsync(session.SessionId); + + persisted!.ExpiresAt.Should().Be(originalExpiry); + persisted.Version.Should().Be(session.Version); + } + + private static SessionTouchPolicy Policy(TimeSpan? idleTimeout = null, TimeSpan? lifetime = null, bool slidingExpiration = false) + { + return new SessionTouchPolicy + { + TouchInterval = TimeSpan.FromMinutes(5), + IdleTimeout = idleTimeout ?? TimeSpan.FromMinutes(30), + Lifetime = lifetime ?? TimeSpan.FromHours(1), + SlidingExpiration = slidingExpiration + }; + } + + private static SessionTouchPolicy PolicyWithoutTouchInterval() + { + return new SessionTouchPolicy + { + TouchInterval = null, + IdleTimeout = TimeSpan.FromMinutes(30), + Lifetime = TimeSpan.FromHours(1), + SlidingExpiration = false + }; } private static SessionValidationResult CreateActiveValidation() @@ -214,7 +377,9 @@ private static SessionValidationResult CreateActiveValidation() boundDeviceId: TestDevice.Default().DeviceId); } - private static UAuthSessionChain CreateChain(SessionValidationResult validation, DateTimeOffset lastSeenAt) + private static UAuthSessionChain CreateChain( + SessionValidationResult validation, + DateTimeOffset lastSeenAt) { return UAuthSessionChain.Create( chainId: validation.ChainId!.Value, @@ -227,4 +392,43 @@ private static UAuthSessionChain CreateChain(SessionValidationResult validation, claimsSnapshot: ClaimsSnapshot.Empty, securityVersion: 0); } + + private static async Task<(InMemorySessionStoreFactory Factory, SessionValidationResult Validation, UAuthSessionChain Chain, + UAuthSession Session)> CreateValidSessionAsync(DateTimeOffset lastSeenAt, DateTimeOffset? sessionExpiresAt = null) + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + + // 1. Create and persist the initial chain (Version = 0). + var chain = CreateChain(validation, lastSeenAt); + await store.CreateChainAsync(chain); + + // 2. Create and persist the session (Version = 0). + var session = UAuthSession.Create( + sessionId: validation.SessionId!.Value, + tenant: validation.Tenant, + userKey: validation.UserKey!.Value, + chainId: validation.ChainId!.Value, + now: lastSeenAt, + expiresAt: sessionExpiresAt ?? Now.AddHours(1), + securityVersion: 0, + device: TestDevice.Default(), + claims: ClaimsSnapshot.Empty, + metadata: SessionMetadata.Empty); + + await store.CreateSessionAsync(session); + + // 3. Attach the session to the existing chain. + var activeChain = chain.AttachSession( + session.SessionId, + lastSeenAt); + + // Optimistic concurrency: expected version is 0. + await store.SaveChainAsync( + activeChain, + chain.Version); + + return (factory, validation, activeChain, session); + } }