diff --git a/Duplicati/CommandLine/ServerUtil/Connection.cs b/Duplicati/CommandLine/ServerUtil/Connection.cs
index 39346d029..720a1d94f 100644
--- a/Duplicati/CommandLine/ServerUtil/Connection.cs
+++ b/Duplicati/CommandLine/ServerUtil/Connection.cs
@@ -174,13 +174,13 @@ public class Connection
{
if (!string.IsNullOrWhiteSpace(settings.RefreshToken))
{
- var (accessToken, refreshToken) = await LoginWithRefreshToken(client, settings.RefreshToken);
+ var (accessToken, refreshToken, refreshNonce) = await LoginWithRefreshToken(client, settings.RefreshToken, settings.RefreshNonce);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Failed to get refresh token");
- (settings with { RefreshToken = refreshToken }).Save(console);
+ (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
}
@@ -223,12 +223,12 @@ public class Connection
).CreateSigninToken("server-cli");
var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken }));
- var (accessToken, refreshToken) = await ParseAuthResponse(responseTask);
+ var (accessToken, refreshToken, refreshNonce) = await ParseAuthResponse(responseTask);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
- (settings with { RefreshToken = refreshToken }).Save(console);
+ (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
@@ -260,12 +260,12 @@ public class Connection
if (string.IsNullOrWhiteSpace(settings.Password))
throw new UserReportedException("Password is required");
- var (accessToken, refreshToken) = await LoginWithPassword(client, settings.Password, obtainRefreshToken);
+ var (accessToken, refreshToken, refreshNonce) = await LoginWithPassword(client, settings.Password, obtainRefreshToken);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
- (settings with { RefreshToken = refreshToken }).Save(console);
+ (settings with { RefreshToken = refreshToken, RefreshNonce = refreshNonce }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
@@ -295,7 +295,7 @@ public class Connection
/// The password to use
/// Whether to obtain a refresh token
/// The access and refresh tokens
- private static Task<(string AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken)
+ private static Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken)
=> ParseAuthResponse(
client.PostAsync("auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken }))
);
@@ -305,12 +305,15 @@ public class Connection
///
/// The HTTP client
/// The refresh token to use
- /// The access and refresh tokens
- private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken)
- => ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh")
+ /// The access token, refresh tokens, and nonce
+ private static Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> LoginWithRefreshToken(HttpClient client, string refreshToken, string? nonce)
+ {
+ return ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh")
{
- Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } }
+ Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } },
+ Content = string.IsNullOrWhiteSpace(nonce) ? null : JsonContent.Create(new { Nonce = nonce })
}));
+ }
///
@@ -318,7 +321,7 @@ public class Connection
///
/// The response to parse
/// The access and refresh tokens
- private static async Task<(string AccessToken, string? RefreshToken)> ParseAuthResponse(Task responseTask)
+ private static async Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> ParseAuthResponse(Task responseTask)
{
var response = await responseTask;
await EnsureSuccessStatusCodeWithParsing(response);
@@ -328,10 +331,12 @@ public class Connection
if (!json.TryGetValue("AccessToken", out var accessToken))
throw new InvalidOperationException("Failed to get access token");
+ json.TryGetValue("RefreshNonce", out var refreshNonce);
+
response.Headers.TryGetValues("Set-Cookie", out var cookies);
var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith("RefreshToken_"))?.Split('=', 2)[1];
- return (accessToken, refreshToken);
+ return (accessToken, refreshToken, refreshNonce);
}
///
@@ -561,7 +566,7 @@ public class Connection
/// The token
public async Task CreateForeverToken()
{
- var (accessToken, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null));
+ var (accessToken, _, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null));
return accessToken;
}
diff --git a/Duplicati/CommandLine/ServerUtil/Settings.cs b/Duplicati/CommandLine/ServerUtil/Settings.cs
index 7dffb5a8d..aa476e2e7 100644
--- a/Duplicati/CommandLine/ServerUtil/Settings.cs
+++ b/Duplicati/CommandLine/ServerUtil/Settings.cs
@@ -36,6 +36,7 @@ namespace Duplicati.CommandLine.ServerUtil;
///
/// The commandline password
/// The saved refresh token
+/// The saved refresh nonce
/// The host url to connect to
/// The settings file where data is loaded/saved
/// Whether to disable TLS/SSL certificate trust check
@@ -46,6 +47,7 @@ namespace Duplicati.CommandLine.ServerUtil;
public sealed record Settings(
string? Password,
string? RefreshToken,
+ string? RefreshNonce,
Uri HostUrl,
string SettingsFile,
bool Insecure,
@@ -63,6 +65,7 @@ public sealed record Settings(
/// The server datafolder, if any
private sealed record PersistedSettings(
string? RefreshToken,
+ string? RefreshNonce,
Uri HostUrl,
string? ServerDatafolder
);
@@ -127,6 +130,7 @@ public sealed record Settings(
return new Settings(
password,
persistedSettings?.RefreshToken,
+ persistedSettings?.RefreshNonce,
hostUrl,
settingsFile,
insecure,
@@ -179,7 +183,7 @@ public sealed record Settings(
File.WriteAllText(SettingsFile, JsonSerializer.Serialize(LoadSettings(SettingsFile, thisKey)
.Where(x => x.HostUrl != HostUrl)
- .Append(new PersistedSettings(RefreshToken, HostUrl, DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet)))
+ .Append(new PersistedSettings(RefreshToken, RefreshNonce, HostUrl, DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet)))
.Select(x => x with
{
RefreshToken = string.IsNullOrWhiteSpace(x.RefreshToken) || thisKey == null
@@ -197,7 +201,7 @@ public sealed record Settings(
{
return Connection.Connect(this);
}
-
+
///
/// Gets a connection to the server
///
diff --git a/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs b/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs
new file mode 100644
index 000000000..3f924411f
--- /dev/null
+++ b/Duplicati/Library/RestAPI/Database/PbkdfConfig.cs
@@ -0,0 +1,111 @@
+// Copyright (C) 2025, The Duplicati Team
+// https://duplicati.com, hello@duplicati.com
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the "Software"),
+// to deal in the Software without restriction, including without limitation
+// the rights to use, copy, modify, merge, publish, distribute, sublicense,
+// and/or sell copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Security.Cryptography;
+
+#nullable enable
+
+namespace Duplicati.Server;
+
+///
+/// This class is used to store the PBKDF configuration parameters
+///
+public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
+{
+ ///
+ /// The version to embed in the configuration
+ ///
+ private const int _Version = 1;
+ ///
+ /// The algorithm to use
+ ///
+ private const string _Algorithm = "PBKDF2";
+ ///
+ /// The hash algorithm to use
+ ///
+ private const string _HashAlorithm = "SHA256";
+ ///
+ /// The number of iterations to use
+ ///
+ private const int _Iterations = 10000;
+ ///
+ /// The size of the hash
+ ///
+ private const int _HashSize = 32;
+
+ ///
+ /// Creates a default PBKDF2 configuration
+ ///
+ public static PbkdfConfig Default => new PbkdfConfig(_Algorithm, _Version, string.Empty, _Iterations, _HashAlorithm, string.Empty);
+
+ ///
+ /// Creates a new PBKDF2 configuration with a random salt
+ ///
+ /// The password to hash
+ public static PbkdfConfig CreatePBKDF2(string password)
+ {
+ var prng = RandomNumberGenerator.Create();
+ var buf = new byte[_HashSize];
+ prng.GetBytes(buf);
+
+ var salt = Convert.ToBase64String(buf);
+ var pbkdf2 = new Rfc2898DeriveBytes(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm));
+ var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
+
+ return new PbkdfConfig(_Algorithm, _Version, salt, _Iterations, _HashAlorithm, pwd);
+ }
+
+ ///
+ /// Calculates the hash for the given password using the current configuration
+ ///
+ /// The password to hash
+ /// The hashed password
+ private string ComputeHash(string password)
+ {
+ var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm));
+ return Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
+ }
+
+ ///
+ /// Creates a new PBKDF2 configuration with the given password
+ ///
+ /// The password to use
+ /// The updated PBKDF2 configuration
+ public PbkdfConfig WithPassword(string password)
+ => WithHash(ComputeHash(password));
+
+ ///
+ /// Creates a new PBKDF2 configuration with the given hash
+ ///
+ /// The hash to use
+ /// The updated PBKDF2 configuration
+ public PbkdfConfig WithHash(string hash)
+ => this with { Hash = hash };
+
+ ///
+ /// Verifies a password against a PBKDF2 configuration
+ ///
+ /// The password to verify
+ /// True if the password matches the configuration
+ public bool VerifyPassword(string password)
+ => CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(Hash), Convert.FromBase64String(ComputeHash(password)));
+}
diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs
index db882592c..7daf18ea5 100644
--- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs
+++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs
@@ -330,63 +330,6 @@ namespace Duplicati.Server.Database
}
}
- ///
- /// This class is used to store the PBKDF configuration parameters
- ///
- private record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
- {
- ///
- /// The version to embed in the configuration
- ///
- private const int _Version = 1;
- ///
- /// The algorithm to use
- ///
- private const string _Algorithm = "PBKDF2";
- ///
- /// The hash algorithm to use
- ///
- private const string _HashAlorithm = "SHA256";
- ///
- /// The number of iterations to use
- ///
- private const int _Iterations = 10000;
- ///
- /// The size of the hash
- ///
- private const int _HashSize = 32;
-
- ///
- /// Creates a new PBKDF2 configuration with a random salt
- ///
- /// The password to hash
- public static PbkdfConfig CreatePBKDF2(string password)
- {
- var prng = RandomNumberGenerator.Create();
- var buf = new byte[_HashSize];
- prng.GetBytes(buf);
-
- var salt = Convert.ToBase64String(buf);
- var pbkdf2 = new Rfc2898DeriveBytes(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm));
- var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
-
- return new PbkdfConfig(_Algorithm, _Version, salt, _Iterations, _HashAlorithm, pwd);
- }
-
- ///
- /// Verifies a password against a PBKDF2 configuration
- ///
- /// The password to verify
- /// True if the password matches the configuration
- public bool VerifyPassword(string password)
- {
- var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm));
- var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
-
- return pwd == Hash;
- }
- }
-
///
/// Verifies a password against the stored PBKDF configuration
///
diff --git a/Duplicati/Server/webroot/login/login.js b/Duplicati/Server/webroot/login/login.js
index d9c5dad97..2a924cb9a 100644
--- a/Duplicati/Server/webroot/login/login.js
+++ b/Duplicati/Server/webroot/login/login.js
@@ -15,6 +15,8 @@ $(document).ready(function() {
data: JSON.stringify({'Password': $('#login-password').val(), 'RememberMe': true })
})
.done(function(data) {
+ if (data.RefreshNonce)
+ localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce);
window.location = './';
})
.fail(function(data) {
diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js
index a9e18fb2a..aef60290f 100644
--- a/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js
+++ b/Duplicati/Server/webroot/ngax/scripts/controllers/AppController.js
@@ -36,10 +36,14 @@ backupApp.controller('AppController', function($rootScope, $scope, $cookies, $lo
$scope.isLoggedIn = false;
$scope.log_out = function() {
+ const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce');
+ const body = storedNonce ? { Nonce: storedNonce } : undefined;
+
// Use a path under /auth/refresh to allow the cookie to be sent for deletion
// Calling `/auth/logout` also works, but does not revoke the token in the database
- AppService.post('/auth/refresh/logout').then(function() {
+ AppService.post('/auth/refresh/logout', body).then(function() {
AppService.clearAccessToken();
+ localStorage.removeItem('v1:persist:duplicati:refreshNonce');
location.href = '/login.html';
}, AppUtils.connectionError);
};
diff --git a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
index 447baf43d..88cc8ff4f 100644
--- a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
+++ b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js
@@ -99,10 +99,16 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
} else {
var deferred = $q.defer();
self.access_token_promise = deferred.promise;
- $http.post(self.apiurl + '/auth/refresh')
+
+ const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce');
+ const body = storedNonce ? { Nonce: storedNonce } : undefined;
+
+ $http.post(self.apiurl + '/auth/refresh', body)
.then(function (response) {
self.access_token = response.data.AccessToken;
self.access_token_promise = null;
+ if (response.data.RefreshNonce)
+ localStorage.setItem('v1:persist:duplicati:refreshNonce', response.data.RefreshNonce);
deferred.resolve(self.access_token);
}, function (response) {
// Failed to get a new token, clear the old one
diff --git a/Duplicati/Server/webroot/signin/signin.js b/Duplicati/Server/webroot/signin/signin.js
index 9ee50ba93..f7805cbf3 100644
--- a/Duplicati/Server/webroot/signin/signin.js
+++ b/Duplicati/Server/webroot/signin/signin.js
@@ -12,12 +12,18 @@ $(document).ready(function() {
return;
processing = true;
+ const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce');
+ const body = storedNonce ? { Nonce: storedNonce } : undefined;
$.ajax({
url: './api/v1/auth/refresh',
- type: 'POST'
+ type: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(body)
})
.done(function(data) {
+ if (data.RefreshNonce)
+ localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce);
window.location = './';
})
.fail(function(data) {
@@ -54,6 +60,8 @@ $(document).ready(function() {
data: JSON.stringify({'SigninToken': $('#signin-token').val(), 'RememberMe': true })
})
.done(function(data) {
+ if (data.RefreshNonce)
+ localStorage.setItem('v1:persist:duplicati:refreshNonce', data.RefreshNonce);
window.location = './';
})
.fail(function(data) {
diff --git a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs
index 972088096..f8d650cc4 100644
--- a/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs
+++ b/Duplicati/WebserverCore/Abstractions/IJWTTokenProvider.cs
@@ -90,8 +90,9 @@ public interface IJWTTokenProvider
/// The user ID the token is for.
/// The token family ID the token is for.
/// The counter of the token family the token is for.
- /// The JWT token.
- string CreateRefreshToken(string userId, string tokenFamilyId, int counter);
+ /// Whether the token should be short-lived.
+ /// The JWT token and the nonce.
+ (string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived);
///
/// Reads a JWT token that only works for a single operation.
@@ -117,8 +118,9 @@ public interface IJWTTokenProvider
/// Reads a JWT token that can be used to refresh an access token.
///
/// The JWT token.
+ /// The nonce used to validate the token.
/// The parsed and validated refresh token.
- RefreshToken ReadRefreshToken(string token);
+ RefreshToken ReadRefreshToken(string token, string? nonce);
///
/// Gets the family ID from a JWT token with no family counter.
diff --git a/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs b/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs
index d68f90a7a..22b239d56 100644
--- a/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs
+++ b/Duplicati/WebserverCore/Abstractions/ILoginProvider.cs
@@ -29,10 +29,10 @@ public interface ILoginProvider
/// Performs a login with a signin token.
///
/// The signin token.
- /// Whether to issue a refresh token.
+ /// Whether to issue a short-lived refresh token.
/// The cancellation token.
- /// The access and refresh tokens.
- Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct);
+ /// The access token, refresh token, and nonce.
+ Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct);
///
/// Performs a login with a refresh token.
@@ -40,24 +40,25 @@ public interface ILoginProvider
/// The refresh token.
/// The cancellation token.
/// The access and refresh tokens.
- Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct);
+ Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
///
/// Performs a login with a password.
///
/// The password.
- /// Whether to issue a refresh token.
+ /// Whether to issue a short-lived refresh token.
/// The cancellation token.
- /// The access and refresh tokens.
- Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct);
+ /// The access token, refresh token, and nonce.
+ Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct);
///
/// Performs a logout with a refresh token.
///
/// The refresh token.
+ /// The nonce associated with the refresh token.
/// The cancellation token.
/// The task.
- Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct);
+ Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
///
/// Performs a complete logout for a user.
diff --git a/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs
index c713c302b..24f212d03 100644
--- a/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs
+++ b/Duplicati/WebserverCore/Client/DuplicatiServerClient.cs
@@ -38,7 +38,7 @@ public class DuplicatiServerClient : IDisposable
private readonly string _baseUrl;
private readonly JsonSerializerOptions _jsonOptions;
private bool _disposed;
- private readonly bool _selfOwnedHttpClient;
+ private readonly bool _selfOwnedHttpClient;
private readonly SemaphoreSlim _tokenRefreshSemaphore = new(1, 1);
private readonly ServerCredentialType _credentialType;
private readonly string _credential;
@@ -306,11 +306,12 @@ public class DuplicatiServerClient : IDisposable
///
/// Refreshes the access token using the refresh token (V1).
///
+ /// The nonce to include in the refresh request. Optional, can be null.
/// The cancellation token. Optional, defaults to .
/// The new access token output.
- public async Task RefreshTokenV1Async(CancellationToken cancellationToken = default)
+ public async Task RefreshTokenV1Async(string? nonce, CancellationToken cancellationToken = default)
{
- return await PostAsync("/api/v1/auth/refresh", null, cancellationToken, false).ConfigureAwait(false);
+ return await PostAsync("/api/v1/auth/refresh", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken, false).ConfigureAwait(false);
}
///
@@ -348,11 +349,12 @@ public class DuplicatiServerClient : IDisposable
///
/// Logs out and invalidates the refresh token (V1).
///
+ /// The nonce to include in the logout request.
/// The cancellation token. Optional, defaults to .
/// A task representing the asynchronous operation.
- public async Task LogoutV1Async(CancellationToken cancellationToken = default)
+ public async Task LogoutV1Async(string? nonce, CancellationToken cancellationToken = default)
{
- await PostAsync