diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs index cfa555f5f..2374ad13f 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs @@ -101,7 +101,7 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken user.UpdateLastSeen(timeProvider.GetUtcNow()); userRepository.Update(user); - var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, session.Id, cancellationToken); authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); events.CollectEvent(new SessionCreated(session.Id)); diff --git a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs index 8c828539a..08282cde6 100644 --- a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs +++ b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs @@ -91,7 +91,10 @@ public async Task Handle(RefreshAuthenticationTokensCommand command, Can if (!session.IsRefreshTokenValid(jti, refreshTokenVersion, now)) { - logger.LogWarning("Replay attack detected for session '{SessionId}'. Token JTI '{TokenJti}', current JTI '{CurrentJti}'. Token version '{TokenVersion}', current version '{CurrentVersion}'", session.Id, jti, session.RefreshTokenJti, refreshTokenVersion, session.RefreshTokenVersion); + logger.LogWarning( + "Replay attack detected for session '{SessionId}'. Token JTI '{TokenJti}', current JTI '{CurrentJti}'. Token version '{TokenVersion}', current version '{CurrentVersion}'", + session.Id, jti, session.RefreshTokenJti, refreshTokenVersion, session.RefreshTokenVersion + ); session.Revoke(now, SessionRevokedReason.ReplayAttackDetected); sessionRepository.Update(session); events.CollectEvent(new SessionReplayDetected(session.Id, refreshTokenVersion, session.RefreshTokenVersion)); @@ -105,20 +108,43 @@ public async Task Handle(RefreshAuthenticationTokensCommand command, Can return Result.Unauthorized($"No user found with user id '{userId}'."); } + RefreshTokenJti tokenJti; + int tokenVersion; + if (jti == session.RefreshTokenJti && refreshTokenVersion == session.RefreshTokenVersion) { - session.Refresh(); - sessionRepository.Update(session); - - user.UpdateLastSeen(now); - userRepository.Update(user); + // Attempt atomic refresh via isolated connection - only one concurrent request can succeed. + // TryRefreshAsync commits immediately via its own connection, independent of UnitOfWorkPipelineBehavior. + var newJti = RefreshTokenJti.NewId(); + var refreshed = await sessionRepository.TryRefreshAsync(session.Id, jti, refreshTokenVersion, newJti, now, cancellationToken); + + if (refreshed) + { + // Atomic refresh succeeded - update User.LastSeenAt (committed by UnitOfWorkPipelineBehavior) + user.UpdateLastSeen(now); + userRepository.Update(user); + tokenJti = newJti; + tokenVersion = refreshTokenVersion + 1; + } + else + { + // Concurrent request refreshed session after our fetch - re-fetch for updated values. + // Grace period via PreviousRefreshTokenJti ensures this request still succeeds. + session = await sessionRepository.GetByIdUnfilteredAsync(session.Id, cancellationToken) + ?? throw new InvalidOperationException("Session revoked during refresh."); + tokenJti = session.RefreshTokenJti; + tokenVersion = session.RefreshTokenVersion; + } + } + else + { + // Grace period request - token validated via PreviousRefreshTokenJti, use current session values + tokenJti = session.RefreshTokenJti; + tokenVersion = session.RefreshTokenVersion; } - var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); - authenticationTokenService.RefreshAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, refreshTokenVersion, refreshTokenExpires); - - events.CollectEvent(new SessionRefreshed(session.Id)); - events.CollectEvent(new AuthenticationTokensRefreshed()); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, session.Id, cancellationToken); + authenticationTokenService.GenerateAuthenticationTokens(userInfo, session.Id, tokenJti, tokenVersion, refreshTokenExpires); return Result.Success(); } diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index a8267caee..304eb3f61 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -75,8 +75,8 @@ public async Task Handle(SwitchTenantCommand command, CancellationToken targetUser.UpdateLastSeen(timeProvider.GetUtcNow()); userRepository.Update(targetUser); - var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken, session.Id); - authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt); + var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, session.Id, cancellationToken); + authenticationTokenService.SwitchTenantAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt); events.CollectEvent(new SessionCreated(session.Id)); events.CollectEvent(new TenantSwitched(executionContext.TenantId!, command.TenantId, targetUser.Id)); diff --git a/application/account-management/Core/Features/Authentication/Domain/Session.cs b/application/account-management/Core/Features/Authentication/Domain/Session.cs index 6dce6a2da..16fe7b0a7 100644 --- a/application/account-management/Core/Features/Authentication/Domain/Session.cs +++ b/application/account-management/Core/Features/Authentication/Domain/Session.cs @@ -1,4 +1,5 @@ using System.Net; +using JetBrains.Annotations; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Domain; @@ -24,10 +25,13 @@ private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string public UserId UserId { get; private init; } + [UsedImplicitly] // Updated via raw SQL in SessionRepository.TryRefreshAsync to handle concurrent refresh requests atomically public RefreshTokenJti RefreshTokenJti { get; private set; } + [UsedImplicitly] // Updated via raw SQL in SessionRepository.TryRefreshAsync public RefreshTokenJti? PreviousRefreshTokenJti { get; private set; } + [UsedImplicitly] // Updated via raw SQL in SessionRepository.TryRefreshAsync public int RefreshTokenVersion { get; private set; } public DeviceType DeviceType { get; private init; } @@ -52,13 +56,6 @@ public static Session Create(TenantId tenantId, UserId userId, string userAgent, return new Session(tenantId, userId, deviceType, userAgent, ipAddress.ToString()); } - public void Refresh() - { - PreviousRefreshTokenJti = RefreshTokenJti; - RefreshTokenJti = RefreshTokenJti.NewId(); - RefreshTokenVersion++; - } - public void Revoke(DateTimeOffset now, SessionRevokedReason reason) { if (IsRevoked) throw new UnreachableException("Session is already revoked."); diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs index c286d034b..47a6cf3b1 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using Microsoft.EntityFrameworkCore; using PlatformPlatform.AccountManagement.Database; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; @@ -21,6 +22,12 @@ public interface ISessionRepository : ICrudRepository /// This method should only be used in the Sessions dialog where users need to see all sessions for their email. /// Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] userIds, CancellationToken cancellationToken); + + /// + /// Attempts to refresh the session token if the current JTI and version match. + /// Returns false if another concurrent request already refreshed the session. + /// + Task TryRefreshAsync(SessionId sessionId, RefreshTokenJti currentJti, int currentVersion, RefreshTokenJti newJti, DateTimeOffset now, CancellationToken cancellationToken); } public sealed class SessionRepository(AccountManagementDbContext accountManagementDbContext) @@ -31,6 +38,43 @@ public sealed class SessionRepository(AccountManagementDbContext accountManageme return await DbSet.IgnoreQueryFilters().FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); } + /// + /// Uses an atomic UPDATE via raw ADO.NET with a separate connection to ensure complete isolation. + /// This creates an independent database connection that commits immediately, preventing race conditions + /// when multiple concurrent requests attempt to refresh the same token. + /// + public async Task TryRefreshAsync(SessionId sessionId, RefreshTokenJti currentJti, int currentVersion, RefreshTokenJti newJti, DateTimeOffset now, CancellationToken cancellationToken) + { + var existingConnection = accountManagementDbContext.Database.GetDbConnection(); + + // Create a new connection of the same type to ensure complete isolation from EF Core's transaction. + await using var connection = (DbConnection)Activator.CreateInstance(existingConnection.GetType())!; + connection.ConnectionString = accountManagementDbContext.Database.GetConnectionString(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE Sessions + SET PreviousRefreshTokenJti = RefreshTokenJti, + RefreshTokenJti = @newJti, + RefreshTokenVersion = RefreshTokenVersion + 1, + ModifiedAt = @now + WHERE Id = @sessionId + AND RefreshTokenJti = @currentJti + AND RefreshTokenVersion = @currentVersion + """; + + AddParameter(command, "@newJti", newJti.Value); + AddParameter(command, "@now", now.ToString("O")); + AddParameter(command, "@sessionId", sessionId.Value); + AddParameter(command, "@currentJti", currentJti.Value); + AddParameter(command, "@currentVersion", currentVersion); + + var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken); + + return rowsAffected == 1; + } + public async Task GetActiveSessionsForUserAsync(UserId userId, CancellationToken cancellationToken) { var sessions = await DbSet @@ -47,4 +91,12 @@ public async Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] u .ToArrayAsync(cancellationToken); return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); } + + private static void AddParameter(DbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } } diff --git a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs index e45693733..9246d9a75 100644 --- a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs +++ b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs @@ -59,7 +59,7 @@ public async Task Handle(CompleteSignupCommand command, CancellationToke user.UpdateLastSeen(timeProvider.GetUtcNow()); userRepository.Update(user); - var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, session.Id, cancellationToken); authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); events.CollectEvent(new SessionCreated(session.Id)); diff --git a/application/account-management/Core/Features/TelemetryEvents.cs b/application/account-management/Core/Features/TelemetryEvents.cs index 58e213766..75a673ee5 100644 --- a/application/account-management/Core/Features/TelemetryEvents.cs +++ b/application/account-management/Core/Features/TelemetryEvents.cs @@ -14,9 +14,6 @@ namespace PlatformPlatform.AccountManagement.Features; /// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that /// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good /// data quality from the start. -public sealed class AuthenticationTokensRefreshed - : TelemetryEvent; - public sealed class EmailConfirmationBlocked(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int retryCount) : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("retry_count", retryCount)); @@ -47,9 +44,6 @@ public sealed class Logout public sealed class SessionCreated(SessionId sessionId) : TelemetryEvent(("session_id", sessionId)); -public sealed class SessionRefreshed(SessionId sessionId) - : TelemetryEvent(("session_id", sessionId)); - public sealed class SessionReplayDetected(SessionId sessionId, int tokenVersion, int currentVersion) : TelemetryEvent(("session_id", sessionId), ("token_version", tokenVersion), ("current_version", currentVersion)); diff --git a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs index f51dd227a..e3a6430df 100644 --- a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs @@ -15,10 +15,10 @@ public sealed class UserInfoFactory(ITenantRepository tenantRepository) /// Creates a UserInfo instance from a User entity, including tenant name. /// /// The user entity - /// Cancellation token /// Optional session ID to include in the UserInfo + /// Cancellation token /// UserInfo with all required properties including tenant name - public async Task CreateUserInfoAsync(User user, CancellationToken cancellationToken, SessionId? sessionId = null) + public async Task CreateUserInfoAsync(User user, SessionId? sessionId, CancellationToken cancellationToken) { var tenant = await tenantRepository.GetByIdAsync(user.TenantId, cancellationToken); diff --git a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs index 1498dc659..b7f7b4d51 100644 --- a/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs +++ b/application/account-management/Tests/Authentication/RefreshAuthenticationTokensTests.cs @@ -41,11 +41,6 @@ public async Task RefreshAuthenticationTokens_WhenValidToken_ShouldRefreshAndInc var updatedVersion = Connection.ExecuteScalar("SELECT RefreshTokenVersion FROM Sessions WHERE Id = @id", [new { id = sessionId.ToString() }]); updatedVersion.Should().Be(2); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionRefreshed"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("AuthenticationTokensRefreshed"); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); } [Fact] @@ -69,10 +64,6 @@ public async Task RefreshAuthenticationTokens_WhenPreviousVersionWithinGracePeri var sessionVersion = Connection.ExecuteScalar("SELECT RefreshTokenVersion FROM Sessions WHERE Id = @id", [new { id = sessionId.ToString() }]); sessionVersion.Should().Be(2); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionRefreshed"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("AuthenticationTokensRefreshed"); } [Fact] @@ -140,6 +131,31 @@ public async Task RefreshAuthenticationTokens_WhenSessionNotFound_ShouldReturnUn TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } + [Fact] + public async Task RefreshAuthenticationTokens_WhenSequentialRequestsWithSameToken_ShouldBothSucceed() + { + // Arrange - simulate grace period scenario where concurrent request already refreshed the session + var jti = RefreshTokenJti.NewId(); + var sessionId = SessionId.NewId(); + InsertSession(DatabaseSeeder.Tenant1Owner.TenantId, DatabaseSeeder.Tenant1Owner.Id, sessionId, jti, 1); + var userInfo = DatabaseSeeder.Tenant1Owner.Adapt(); + var refreshToken = _refreshTokenGenerator.Generate(userInfo, sessionId, jti); + TelemetryEventsCollectorSpy.Reset(); + + // Act - First request refreshes the session + var response1 = await SendRefreshRequest(refreshToken); + + // Act - Second request with same token should succeed via grace period + var response2 = await SendRefreshRequest(refreshToken); + + // Assert + response1.StatusCode.Should().Be(HttpStatusCode.OK); + response2.StatusCode.Should().Be(HttpStatusCode.OK); + + var sessionVersion = Connection.ExecuteScalar("SELECT RefreshTokenVersion FROM Sessions WHERE Id = @id", [new { id = sessionId.ToString() }]); + sessionVersion.Should().Be(2); + } + private async Task SendRefreshRequest(string refreshToken) { var request = new HttpRequestMessage(HttpMethod.Post, "/internal-api/account-management/authentication/refresh-authentication-tokens"); @@ -151,14 +167,8 @@ private string GenerateRefreshTokenWithVersion(UserInfo userInfo, SessionId sess { using var serviceScope = Provider.CreateScope(); var generator = serviceScope.ServiceProvider.GetRequiredService(); - var token = generator.Generate(userInfo, sessionId, jti); - - for (var i = 1; i < version; i++) - { - token = generator.Update(userInfo, sessionId, jti, i, TimeProvider.System.GetUtcNow().AddHours(2160)); - } - - return token; + var expires = TimeProvider.System.GetUtcNow().AddHours(RefreshTokenGenerator.ValidForHours); + return generator.Generate(userInfo, sessionId, jti, version, expires); } private void InsertSession(long tenantId, string userId, SessionId sessionId, RefreshTokenJti jti, int version, bool isRevoked = false) diff --git a/application/account-management/Tests/EndpointBaseTest.cs b/application/account-management/Tests/EndpointBaseTest.cs index 134f84e84..e38c60d2a 100644 --- a/application/account-management/Tests/EndpointBaseTest.cs +++ b/application/account-management/Tests/EndpointBaseTest.cs @@ -48,8 +48,8 @@ protected EndpointBaseTest() Services.AddLogging(); Services.AddTransient(); - // Create connection and add DbContext to the service collection - Connection = new SqliteConnection("DataSource=:memory:"); + // Create connection using shared cache mode so isolated connections can access the same in-memory database + Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); // Configure SQLite to behave more like SQL Server diff --git a/application/back-office/Tests/EndpointBaseTest.cs b/application/back-office/Tests/EndpointBaseTest.cs index b98f7581b..63493c23d 100644 --- a/application/back-office/Tests/EndpointBaseTest.cs +++ b/application/back-office/Tests/EndpointBaseTest.cs @@ -48,8 +48,8 @@ protected EndpointBaseTest() Services.AddLogging(); Services.AddTransient(); - // Create connection and add DbContext to the service collection - Connection = new SqliteConnection("DataSource=:memory:"); + // Create connection using shared cache mode so isolated connections can access the same in-memory database + Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); // Configure SQLite to behave more like SQL Server diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs index 507802fe1..c9ceafa45 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AuthenticationTokenService.cs @@ -15,16 +15,18 @@ public void CreateAndSetAuthenticationTokens(UserInfo userInfo, SessionId sessio SetAuthenticationTokensOnHttpResponse(refreshToken, accessToken); } - public void CreateAndSetAuthenticationTokens(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, DateTimeOffset expires) + /// Preserves the original expiry to prevent session lifetime extension through repeated tenant switching. + public void SwitchTenantAndSetAuthenticationTokens(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, DateTimeOffset expires) { - var refreshToken = refreshTokenGenerator.Generate(userInfo, sessionId, jti, expires); + var refreshToken = refreshTokenGenerator.Generate(userInfo, sessionId, jti, 1, expires); var accessToken = accessTokenGenerator.Generate(userInfo); SetAuthenticationTokensOnHttpResponse(refreshToken, accessToken); } - public void RefreshAuthenticationTokens(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, int currentRefreshTokenVersion, DateTimeOffset expires) + /// Used during token refresh to issue new tokens with incremented version while preserving original expiry. + public void GenerateAuthenticationTokens(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, int refreshTokenVersion, DateTimeOffset expires) { - var refreshToken = refreshTokenGenerator.Update(userInfo, sessionId, jti, currentRefreshTokenVersion, expires); + var refreshToken = refreshTokenGenerator.Generate(userInfo, sessionId, jti, refreshTokenVersion, expires); var accessToken = accessTokenGenerator.Generate(userInfo); SetAuthenticationTokensOnHttpResponse(refreshToken, accessToken); } diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs index daa3d8e0c..bf4b67223 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs @@ -11,22 +11,14 @@ public sealed class RefreshTokenGenerator(ITokenSigningClient tokenSigningClient // Similar to Facebook and GitHub, when a user logs in, the session will be valid for a very long time. public const int ValidForHours = 2160; // 24 hours * 90 days + /// Creates initial refresh token for new sessions (login/signup). public string Generate(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti) { - return GenerateRefreshToken(userInfo, sessionId, jti, 1, timeProvider.GetUtcNow().AddHours(ValidForHours)); + return Generate(userInfo, sessionId, jti, 1, timeProvider.GetUtcNow().AddHours(ValidForHours)); } - public string Generate(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, DateTimeOffset expires) - { - return GenerateRefreshToken(userInfo, sessionId, jti, 1, expires); - } - - public string Update(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, int currentRefreshTokenVersion, DateTimeOffset expires) - { - return GenerateRefreshToken(userInfo, sessionId, jti, currentRefreshTokenVersion + 1, expires); - } - - private string GenerateRefreshToken(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, int refreshTokenVersion, DateTimeOffset expires) + /// Creates refresh token with explicit version and expiry (for token refresh and tenant switching). + public string Generate(UserInfo userInfo, SessionId sessionId, RefreshTokenJti jti, int refreshTokenVersion, DateTimeOffset expires) { var tokenDescriptor = new SecurityTokenDescriptor {