diff --git a/Duplicati/WebserverCore/Abstractions/ITokenFamilyStore.cs b/Duplicati/WebserverCore/Abstractions/ITokenFamilyStore.cs
index 5ea055c0a..1aa510df3 100644
--- a/Duplicati/WebserverCore/Abstractions/ITokenFamilyStore.cs
+++ b/Duplicati/WebserverCore/Abstractions/ITokenFamilyStore.cs
@@ -53,6 +53,7 @@ public interface ITokenFamilyStore
///
/// The ID.
/// The user ID.
- /// The counter.
- public record TokenFamily(string Id, string UserId, int Counter);
+ /// The counter.
+ /// The last updated timestamp.
+ public record TokenFamily(string Id, string UserId, int Counter, DateTime LastUpdated);
}
diff --git a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs
index 789abe6e2..e05feaf2b 100644
--- a/Duplicati/WebserverCore/Middlewares/JWTProvider.cs
+++ b/Duplicati/WebserverCore/Middlewares/JWTProvider.cs
@@ -17,6 +17,8 @@ public record JWTConfig
public int RefreshTokenDurationInMinutes { get; init; } = 60 * 24 * 30;
public int SigninTokenDurationInMinutes { get; init; } = 5;
public int SingleOperationTokenDurationInMinutes { get; init; } = 1;
+ public int MaxRefreshTokenDrift { get; init; } = 1;
+ public int MaxRefreshTokenDriftSeconds { get; init; } = 30;
public SymmetricSecurityKey SymmetricSecurityKey() => new(Encoding.UTF8.GetBytes(SigningKey));
public static JWTConfig Create() => new()
diff --git a/Duplicati/WebserverCore/Services/LoginProvider.cs b/Duplicati/WebserverCore/Services/LoginProvider.cs
index e19fe4fae..59be211ac 100644
--- a/Duplicati/WebserverCore/Services/LoginProvider.cs
+++ b/Duplicati/WebserverCore/Services/LoginProvider.cs
@@ -1,10 +1,11 @@
using Duplicati.Library.Logging;
using Duplicati.Server.Database;
using Duplicati.WebserverCore.Abstractions;
+using Duplicati.WebserverCore.Middlewares;
namespace Duplicati.WebserverCore.Services;
-public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvider, Connection connection) : ILoginProvider
+public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvider, JWTConfig jwtConfig, Connection connection) : ILoginProvider
{
private static readonly string LOGTAG = Log.LogTagFromType();
@@ -29,7 +30,13 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid
var tokenFamily = await repo.GetTokenFamily(refreshToken.UserId, refreshToken.TokenFamilyId, ct)
?? throw new UnauthorizedAccessException("Invalid refresh token");
- if (tokenFamily.Counter != refreshToken.Counter)
+ // Allow slight drift to adjust for cases where the browser refreshes
+ // just before the token is received, so the server is ahead
+ var counterDiff = tokenFamily.Counter - refreshToken.Counter;
+ var maxDrift = (DateTime.UtcNow - tokenFamily.LastUpdated).TotalSeconds > jwtConfig.MaxRefreshTokenDriftSeconds
+ ? 0
+ : jwtConfig.MaxRefreshTokenDrift;
+ if (counterDiff < 0 || counterDiff > maxDrift)
{
Log.WriteWarningMessage(LOGTAG, "TokenFamilyReuse", null, $"Invalid refresh token counter: {tokenFamily.Counter} != {refreshToken.Counter}");
await repo.InvalidateTokenFamily(tokenFamily.UserId, tokenFamily.Id, ct);
diff --git a/Duplicati/WebserverCore/Services/TokenFamilyStore.cs b/Duplicati/WebserverCore/Services/TokenFamilyStore.cs
index 0a5f5c59c..ae6ecf8c4 100644
--- a/Duplicati/WebserverCore/Services/TokenFamilyStore.cs
+++ b/Duplicati/WebserverCore/Services/TokenFamilyStore.cs
@@ -11,17 +11,18 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
{
var familyId = System.Security.Cryptography.RandomNumberGenerator.GetHexString(16);
var counter = System.Security.Cryptography.RandomNumberGenerator.GetInt32(1024) % 1024;
+ var lastUpdated = DateTime.UtcNow;
connection.ExecuteWithCommand(cmd =>
{
cmd.CommandText = @"INSERT INTO TokenFamily (""Id"", ""UserId"", ""Counter"", ""LastUpdated"") VALUES (?, ?, ?, ?)";
cmd.AddParameter(familyId);
cmd.AddParameter(userId);
cmd.AddParameter(counter);
- cmd.AddParameter(DateTime.UtcNow.Ticks);
+ cmd.AddParameter(lastUpdated.Ticks);
cmd.ExecuteNonQuery();
});
- return Task.FromResult(new ITokenFamilyStore.TokenFamily(familyId, userId, counter));
+ return Task.FromResult(new ITokenFamilyStore.TokenFamily(familyId, userId, counter, lastUpdated));
}
public Task GetTokenFamily(string userId, string familyId, CancellationToken ct)
@@ -29,14 +30,14 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
ITokenFamilyStore.TokenFamily? family = null;
connection.ExecuteWithCommand(cmd =>
{
- cmd.CommandText = @"SELECT ""Id"", ""UserId"", ""Counter"" FROM ""TokenFamily"" WHERE ""Id"" = ? AND ""UserId"" = ?";
+ cmd.CommandText = @"SELECT ""Id"", ""UserId"", ""Counter"", ""LastUpdated"" FROM ""TokenFamily"" WHERE ""Id"" = ? AND ""UserId"" = ?";
cmd.AddParameter(familyId);
cmd.AddParameter(userId);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return;
- family = new ITokenFamilyStore.TokenFamily(reader.GetString(0), reader.GetString(1), reader.GetInt32(2));
+ family = new ITokenFamilyStore.TokenFamily(reader.GetString(0), reader.GetString(1), reader.GetInt32(2), new DateTime(reader.GetInt64(3)));
});
return Task.FromResult(family ?? throw new Exceptions.UnauthorizedException("Token family not found"));
}
@@ -44,11 +45,12 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
public Task IncrementTokenFamily(ITokenFamilyStore.TokenFamily tokenFamily, CancellationToken ct)
{
var nextCounter = tokenFamily.Counter + 1;
+ var lastUpdated = DateTime.UtcNow;
connection.ExecuteWithCommand(cmd =>
{
cmd.CommandText = @"UPDATE ""TokenFamily"" SET ""Counter"" = ?, ""LastUpdated"" = ? WHERE ""Id"" = ? AND ""UserId"" = ? AND ""Counter"" = ?";
cmd.AddParameter(nextCounter);
- cmd.AddParameter(DateTime.UtcNow.Ticks);
+ cmd.AddParameter(lastUpdated.Ticks);
cmd.AddParameter(tokenFamily.Id);
cmd.AddParameter(tokenFamily.UserId);
cmd.AddParameter(tokenFamily.Counter);
@@ -56,7 +58,7 @@ public class TokenFamilyStore(Connection connection) : ITokenFamilyStore
throw new Exceptions.ConflictException("Token family counter mismatch or not found");
});
- return Task.FromResult(new ITokenFamilyStore.TokenFamily(tokenFamily.Id, tokenFamily.UserId, nextCounter));
+ return Task.FromResult(new ITokenFamilyStore.TokenFamily(tokenFamily.Id, tokenFamily.UserId, nextCounter, lastUpdated));
}
public Task InvalidateTokenFamily(string userId, string familyId, CancellationToken ct)