Implemented a nonce for refresh tokens
This adds a nonce to the refresh token such that each request to obtain a refresh token must now also provide a matching nonce. When using non-persisted logins, the request to the server is the same, but the "remember me" flag toggles a shorter duration for the refresh token. The FE can then store the nonce in either local storage for persisted logins or in session storage for non-persisted logins. The default is currently to always issue refresh tokens with a nonce, but this can be toggled with the JWT configuration. The ngax client does not have the non-persisted login so it stores the nonce in local storage, using a name that is compatible with ngclient so the user can swap between them without needing to re-login. The server util was updated to also store the nonce. This fixes #6451
This commit is contained in:
@@ -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
|
||||
/// <param name="password">The password to use</param>
|
||||
/// <param name="obtainRefreshToken">Whether to obtain a refresh token</param>
|
||||
/// <returns>The access and refresh tokens</returns>
|
||||
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
|
||||
/// </summary>
|
||||
/// <param name="client">The HTTP client</param>
|
||||
/// <param name="refreshToken">The refresh token to use</param>
|
||||
/// <returns>The access and refresh tokens</returns>
|
||||
private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken)
|
||||
=> ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh")
|
||||
/// <returns>The access token, refresh tokens, and nonce</returns>
|
||||
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 })
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -318,7 +321,7 @@ public class Connection
|
||||
/// </summary>
|
||||
/// <param name="response">The response to parse</param>
|
||||
/// <returns>The access and refresh tokens</returns>
|
||||
private static async Task<(string AccessToken, string? RefreshToken)> ParseAuthResponse(Task<HttpResponseMessage> responseTask)
|
||||
private static async Task<(string AccessToken, string? RefreshToken, string? RefreshNonce)> ParseAuthResponse(Task<HttpResponseMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -561,7 +566,7 @@ public class Connection
|
||||
/// <returns>The token</returns>
|
||||
public async Task<string> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace Duplicati.CommandLine.ServerUtil;
|
||||
/// </summary>
|
||||
/// <param name="Password">The commandline password</param>
|
||||
/// <param name="RefreshToken">The saved refresh token</param>
|
||||
/// <param name="RefreshNonce">The saved refresh nonce</param>
|
||||
/// <param name="HostUrl">The host url to connect to</param>
|
||||
/// <param name="SettingsFile">The settings file where data is loaded/saved</param>
|
||||
/// <param name="Insecure">Whether to disable TLS/SSL certificate trust check</param>
|
||||
@@ -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(
|
||||
/// <param name="ServerDatafolder">The server datafolder, if any</param>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets a connection to the server
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// This class is used to store the PBKDF configuration parameters
|
||||
/// </summary>
|
||||
public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
|
||||
{
|
||||
/// <summary>
|
||||
/// The version to embed in the configuration
|
||||
/// </summary>
|
||||
private const int _Version = 1;
|
||||
/// <summary>
|
||||
/// The algorithm to use
|
||||
/// </summary>
|
||||
private const string _Algorithm = "PBKDF2";
|
||||
/// <summary>
|
||||
/// The hash algorithm to use
|
||||
/// </summary>
|
||||
private const string _HashAlorithm = "SHA256";
|
||||
/// <summary>
|
||||
/// The number of iterations to use
|
||||
/// </summary>
|
||||
private const int _Iterations = 10000;
|
||||
/// <summary>
|
||||
/// The size of the hash
|
||||
/// </summary>
|
||||
private const int _HashSize = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default PBKDF2 configuration
|
||||
/// </summary>
|
||||
public static PbkdfConfig Default => new PbkdfConfig(_Algorithm, _Version, string.Empty, _Iterations, _HashAlorithm, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PBKDF2 configuration with a random salt
|
||||
/// </summary>
|
||||
/// <param name="password">The password to hash</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the hash for the given password using the current configuration
|
||||
/// </summary>
|
||||
/// <param name="password">The password to hash</param>
|
||||
/// <returns>The hashed password</returns>
|
||||
private string ComputeHash(string password)
|
||||
{
|
||||
var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm));
|
||||
return Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PBKDF2 configuration with the given password
|
||||
/// </summary>
|
||||
/// <param name="password">The password to use</param>
|
||||
/// <returns>The updated PBKDF2 configuration</returns>
|
||||
public PbkdfConfig WithPassword(string password)
|
||||
=> WithHash(ComputeHash(password));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PBKDF2 configuration with the given hash
|
||||
/// </summary>
|
||||
/// <param name="hash">The hash to use</param>
|
||||
/// <returns>The updated PBKDF2 configuration</returns>
|
||||
public PbkdfConfig WithHash(string hash)
|
||||
=> this with { Hash = hash };
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a password against a PBKDF2 configuration
|
||||
/// </summary>
|
||||
/// <param name="password">The password to verify</param>
|
||||
/// <returns>True if the password matches the configuration</returns>
|
||||
public bool VerifyPassword(string password)
|
||||
=> CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(Hash), Convert.FromBase64String(ComputeHash(password)));
|
||||
}
|
||||
@@ -330,63 +330,6 @@ namespace Duplicati.Server.Database
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This class is used to store the PBKDF configuration parameters
|
||||
/// </summary>
|
||||
private record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterations, string HashAlorithm, string Hash)
|
||||
{
|
||||
/// <summary>
|
||||
/// The version to embed in the configuration
|
||||
/// </summary>
|
||||
private const int _Version = 1;
|
||||
/// <summary>
|
||||
/// The algorithm to use
|
||||
/// </summary>
|
||||
private const string _Algorithm = "PBKDF2";
|
||||
/// <summary>
|
||||
/// The hash algorithm to use
|
||||
/// </summary>
|
||||
private const string _HashAlorithm = "SHA256";
|
||||
/// <summary>
|
||||
/// The number of iterations to use
|
||||
/// </summary>
|
||||
private const int _Iterations = 10000;
|
||||
/// <summary>
|
||||
/// The size of the hash
|
||||
/// </summary>
|
||||
private const int _HashSize = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PBKDF2 configuration with a random salt
|
||||
/// </summary>
|
||||
/// <param name="password">The password to hash</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a password against a PBKDF2 configuration
|
||||
/// </summary>
|
||||
/// <param name="password">The password to verify</param>
|
||||
/// <returns>True if the password matches the configuration</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a password against the stored PBKDF configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -90,8 +90,9 @@ public interface IJWTTokenProvider
|
||||
/// <param name="userId">The user ID the token is for.</param>
|
||||
/// <param name="tokenFamilyId">The token family ID the token is for.</param>
|
||||
/// <param name="counter">The counter of the token family the token is for.</param>
|
||||
/// <returns>The JWT token.</returns>
|
||||
string CreateRefreshToken(string userId, string tokenFamilyId, int counter);
|
||||
/// <param name="shortLived">Whether the token should be short-lived.</param>
|
||||
/// <returns>The JWT token and the nonce.</returns>
|
||||
(string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="token">The JWT token.</param>
|
||||
/// <param name="nonce">The nonce used to validate the token.</param>
|
||||
/// <returns>The parsed and validated refresh token.</returns>
|
||||
RefreshToken ReadRefreshToken(string token);
|
||||
RefreshToken ReadRefreshToken(string token, string? nonce);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the family ID from a JWT token with no family counter.
|
||||
|
||||
@@ -29,10 +29,10 @@ public interface ILoginProvider
|
||||
/// Performs a login with a signin token.
|
||||
/// </summary>
|
||||
/// <param name="signinTokenString">The signin token.</param>
|
||||
/// <param name="issueRefreshToken">Whether to issue a refresh token.</param>
|
||||
/// <param name="shortLived">Whether to issue a short-lived refresh token.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The access and refresh tokens.</returns>
|
||||
Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct);
|
||||
/// <returns>The access token, refresh token, and nonce.</returns>
|
||||
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a login with a refresh token.
|
||||
@@ -40,24 +40,25 @@ public interface ILoginProvider
|
||||
/// <param name="refreshTokenString">The refresh token.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The access and refresh tokens.</returns>
|
||||
Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct);
|
||||
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a login with a password.
|
||||
/// </summary>
|
||||
/// <param name="password">The password.</param>
|
||||
/// <param name="issueRefreshToken">Whether to issue a refresh token.</param>
|
||||
/// <param name="shortLived">Whether to issue a short-lived refresh token.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The access and refresh tokens.</returns>
|
||||
Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct);
|
||||
/// <returns>The access token, refresh token, and nonce.</returns>
|
||||
Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a logout with a refresh token.
|
||||
/// </summary>
|
||||
/// <param name="refreshTokenString">The refresh token.</param>
|
||||
/// <param name="nonce">The nonce associated with the refresh token.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The task.</returns>
|
||||
Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct);
|
||||
Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a complete logout for a user.
|
||||
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// Refreshes the access token using the refresh token (V1).
|
||||
/// </summary>
|
||||
/// <param name="nonce">The nonce to include in the refresh request. Optional, can be null.</param>
|
||||
/// <param name="cancellationToken">The cancellation token. Optional, defaults to <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The new access token output.</returns>
|
||||
public async Task<AccessTokenOutputDto> RefreshTokenV1Async(CancellationToken cancellationToken = default)
|
||||
public async Task<AccessTokenOutputDto> RefreshTokenV1Async(string? nonce, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PostAsync<AccessTokenOutputDto>("/api/v1/auth/refresh", null, cancellationToken, false).ConfigureAwait(false);
|
||||
return await PostAsync<AccessTokenOutputDto>("/api/v1/auth/refresh", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -348,11 +349,12 @@ public class DuplicatiServerClient : IDisposable
|
||||
/// <summary>
|
||||
/// Logs out and invalidates the refresh token (V1).
|
||||
/// </summary>
|
||||
/// <param name="nonce">The nonce to include in the logout request.</param>
|
||||
/// <param name="cancellationToken">The cancellation token. Optional, defaults to <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task LogoutV1Async(CancellationToken cancellationToken = default)
|
||||
public async Task LogoutV1Async(string? nonce, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await PostAsync<object>("/api/v1/auth/refresh/logout", null, cancellationToken).ConfigureAwait(false);
|
||||
await PostAsync<object>("/api/v1/auth/refresh/logout", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// V1 Backup Management Methods
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
namespace Duplicati.WebserverCore.Dto;
|
||||
|
||||
public record AccessTokenOutputDto(string AccessToken);
|
||||
public record AccessTokenOutputDto(string AccessToken, string? RefreshNonce);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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.
|
||||
namespace Duplicati.WebserverCore.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the input for a refresh token operation.
|
||||
/// </summary>
|
||||
/// <param name="Nonce">The nonce associated with the refresh token.</param>
|
||||
public sealed record RefreshTokenInputDto(string Nonce);
|
||||
@@ -198,6 +198,9 @@ public class DuplicatiWebserver
|
||||
if (settings.TokenLifetimeInMinutes > 0)
|
||||
jwtConfig = jwtConfig with { RefreshTokenDurationInMinutes = settings.TokenLifetimeInMinutes };
|
||||
|
||||
if (jwtConfig.PbkdfConfig == null)
|
||||
jwtConfig = jwtConfig with { PbkdfConfig = Server.PbkdfConfig.Default };
|
||||
|
||||
if (EnableSwagger)
|
||||
{
|
||||
// Swagger is picking up JSON settings from controllers, even though we do not use controllers
|
||||
|
||||
@@ -40,16 +40,16 @@ public partial class Auth : IEndpointV1
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("auth/refresh", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, CancellationToken ct) =>
|
||||
group.MapPost("auth/refresh", async ([FromServices] ILoginProvider loginProvider, [FromServices] JWTConfig jWTConfig, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input, CancellationToken ct) =>
|
||||
{
|
||||
var cookieName = GetCookieName(httpContextAccessor);
|
||||
if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString))
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await loginProvider.PerformLoginWithRefreshToken(refreshTokenString, ct);
|
||||
var result = await loginProvider.PerformLoginWithRefreshToken(refreshTokenString, input?.Nonce ?? "", ct);
|
||||
AddCookie(httpContextAccessor.HttpContext, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken);
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -70,10 +70,10 @@ public partial class Auth : IEndpointV1
|
||||
var cookieName = GetCookieName(httpContextAccessor);
|
||||
try
|
||||
{
|
||||
var result = await loginProvider.PerformLoginWithSigninToken(input.SigninToken, input.RememberMe ?? false, ct);
|
||||
var result = await loginProvider.PerformLoginWithSigninToken(input.SigninToken, !(input.RememberMe ?? false), ct);
|
||||
if (!string.IsNullOrWhiteSpace(result.RefreshToken))
|
||||
AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken);
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -92,10 +92,10 @@ public partial class Auth : IEndpointV1
|
||||
var cookieName = GetCookieName(httpContextAccessor);
|
||||
try
|
||||
{
|
||||
var result = await loginProvider.PerformLoginWithPassword(input.Password, input.RememberMe ?? false, ct);
|
||||
var result = await loginProvider.PerformLoginWithPassword(input.Password, !(input.RememberMe ?? false), ct);
|
||||
if (!string.IsNullOrWhiteSpace(result.RefreshToken))
|
||||
AddCookie(httpContextAccessor.HttpContext!, cookieName, result.RefreshToken, DateTimeOffset.UtcNow.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken);
|
||||
return new Dto.AccessTokenOutputDto(result.AccessToken, result.Nonce);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -117,8 +117,8 @@ public partial class Auth : IEndpointV1
|
||||
return new Dto.SigninTokenOutputDto(signinToken);
|
||||
});
|
||||
|
||||
group.MapPost("auth/refresh/logout", ([FromServices] ILoginProvider loginProvider, [FromServices] IHttpContextAccessor httpContextAccessor) =>
|
||||
PerformLogout(loginProvider, httpContextAccessor));
|
||||
group.MapPost("auth/refresh/logout", ([FromServices] ILoginProvider loginProvider, [FromServices] IHttpContextAccessor httpContextAccessor, [FromBody] Dto.RefreshTokenInputDto? input) =>
|
||||
PerformLogout(loginProvider, httpContextAccessor, input));
|
||||
|
||||
group.MapPost("auth/issuetoken/{operation}", ([FromServices] Connection connection, [FromServices] IJWTTokenProvider tokenProvider, [FromRoute] string operation) =>
|
||||
{
|
||||
@@ -143,7 +143,7 @@ public partial class Auth : IEndpointV1
|
||||
if (!res.Value)
|
||||
throw new UnauthorizedException("Cannot generate multiple forever tokens, restart the server to generate a new one");
|
||||
|
||||
return new Dto.AccessTokenOutputDto(tokenProvider.CreateForeverToken());
|
||||
return new Dto.AccessTokenOutputDto(tokenProvider.CreateForeverToken(), null);
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
@@ -159,14 +159,14 @@ public partial class Auth : IEndpointV1
|
||||
Domain = context.Request.Host.Host
|
||||
});
|
||||
|
||||
private static object PerformLogout(ILoginProvider loginProvider, IHttpContextAccessor httpContextAccessor)
|
||||
private static object PerformLogout(ILoginProvider loginProvider, IHttpContextAccessor httpContextAccessor, Dto.RefreshTokenInputDto? input)
|
||||
{
|
||||
var cookieName = GetCookieName(httpContextAccessor);
|
||||
if (httpContextAccessor.HttpContext!.Request.Cookies.TryGetValue(cookieName, out var refreshTokenString))
|
||||
{
|
||||
try
|
||||
{
|
||||
loginProvider.PerformLogoutWithRefreshToken(refreshTokenString, CancellationToken.None);
|
||||
loginProvider.PerformLogoutWithRefreshToken(refreshTokenString, input?.Nonce, CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Duplicati.Server;
|
||||
using Duplicati.WebserverCore.Abstractions;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
@@ -35,17 +36,20 @@ public record JWTConfig
|
||||
public required string SigningKey { get; init; }
|
||||
public int AccessTokenDurationInMinutes { get; init; } = 15;
|
||||
public int RefreshTokenDurationInMinutes { get; init; } = 60 * 24 * 30;
|
||||
public int RefreshTokenShortLivedDurationInMinutes { get; init; } = 60 * 2;
|
||||
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 bool RequireRefreshNonce { get; init; } = true;
|
||||
public SymmetricSecurityKey SymmetricSecurityKey() => new(Encoding.UTF8.GetBytes(SigningKey));
|
||||
public PbkdfConfig PbkdfConfig { get; init; } = PbkdfConfig.Default;
|
||||
|
||||
public static JWTConfig Create() => new()
|
||||
{
|
||||
Authority = "https://duplicati",
|
||||
Audience = "https://duplicati",
|
||||
SigningKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||
SigningKey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,27 +71,40 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider
|
||||
], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.SigninTokenDurationInMinutes));
|
||||
|
||||
public string CreateAccessToken(string userId, string tokenFamilyId, TimeSpan? expiration = null)
|
||||
=> GenerateToken([
|
||||
=> GenerateToken([
|
||||
new Claim(Claims.Type, TokenType.AccessToken.ToString()),
|
||||
new Claim(Claims.UserId, userId),
|
||||
new Claim(Claims.Family, tokenFamilyId)
|
||||
], DateTime.Now, expires: DateTime.Now.AddMinutes(Math.Min(jWTConfig.AccessTokenDurationInMinutes, expiration?.TotalMinutes ?? jWTConfig.AccessTokenDurationInMinutes)));
|
||||
|
||||
public string CreateForeverToken()
|
||||
=> GenerateToken([
|
||||
=> GenerateToken([
|
||||
new Claim(Claims.Type, TokenType.AccessToken.ToString()),
|
||||
new Claim(Claims.UserId, ForeverTokenUserId),
|
||||
new Claim(Claims.Family, TemporaryFamilyId)
|
||||
], DateTime.Now, expires: DateTime.Now.AddYears(10));
|
||||
|
||||
public string CreateRefreshToken(string userId, string tokenFamilyId, int counter)
|
||||
=> GenerateToken([
|
||||
public (string RefreshToken, string? Nonce) CreateRefreshToken(string userId, string tokenFamilyId, int counter, bool shortLived)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(Claims.Type, TokenType.RefreshToken.ToString()),
|
||||
new Claim(Claims.UserId, userId),
|
||||
new Claim(Claims.Family, tokenFamilyId),
|
||||
new Claim(Claims.Counter, counter.ToString()),
|
||||
new Claim(Claims.IssuedAt, (DateTime.UnixEpoch - DateTime.UtcNow).TotalSeconds.ToString())
|
||||
], DateTime.Now, expires: DateTime.Now.AddMinutes(jWTConfig.RefreshTokenDurationInMinutes));
|
||||
new Claim(Claims.IssuedAt, (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds.ToString())
|
||||
};
|
||||
|
||||
string? nonce = null;
|
||||
if (shortLived || jWTConfig.RequireRefreshNonce)
|
||||
{
|
||||
nonce = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||
claims.Add(new Claim(Claims.Nonce, jWTConfig.PbkdfConfig.WithPassword(nonce).Hash));
|
||||
}
|
||||
|
||||
var refreshToken = GenerateToken(claims, DateTime.Now, expires: DateTime.Now.AddMinutes(shortLived ? jWTConfig.RefreshTokenShortLivedDurationInMinutes : jWTConfig.RefreshTokenDurationInMinutes));
|
||||
return (refreshToken, nonce);
|
||||
}
|
||||
|
||||
private string GenerateToken(IEnumerable<Claim> claims, DateTime notBefore, DateTime expires)
|
||||
{
|
||||
@@ -131,9 +148,19 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider
|
||||
);
|
||||
}
|
||||
|
||||
public IJWTTokenProvider.RefreshToken ReadRefreshToken(string token)
|
||||
public IJWTTokenProvider.RefreshToken ReadRefreshToken(string token, string? nonce)
|
||||
{
|
||||
// We rely on the fact that the caller cannot remove or modify the nonce after creation.
|
||||
// If the token was created without a nonce, it will be accepted even with a mismatched nonce.
|
||||
var jwtToken = ParseAndValidateToken(token, TokenType.RefreshToken);
|
||||
var validateNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == Claims.Nonce);
|
||||
if (validateNonce != null)
|
||||
{
|
||||
var nonceClaim = validateNonce.Value;
|
||||
var pbkdf = jWTConfig.PbkdfConfig.WithHash(nonceClaim);
|
||||
if (!pbkdf.VerifyPassword(nonce ?? ""))
|
||||
throw new SecurityTokenValidationException("Refresh nonce does not match the expected value");
|
||||
}
|
||||
|
||||
return new IJWTTokenProvider.RefreshToken(
|
||||
jwtToken.ValidFrom,
|
||||
@@ -223,6 +250,7 @@ public class JWTTokenProvider(JWTConfig jWTConfig) : IJWTTokenProvider
|
||||
public const string Counter = "cnt";
|
||||
public const string IssuedAt = "iat";
|
||||
public const string Operation = "sop";
|
||||
public const string Nonce = "nce";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,24 +30,24 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid
|
||||
{
|
||||
private static readonly string LOGTAG = Log.LogTagFromType<LoginProvider>();
|
||||
|
||||
public async Task<(string AccessToken, string? RefreshToken)> PerformLoginWithSigninToken(string signinTokenString, bool issueRefreshToken, CancellationToken ct)
|
||||
public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithSigninToken(string signinTokenString, bool shortLived, CancellationToken ct)
|
||||
{
|
||||
var signinToken = tokenProvider.ReadSigninToken(signinTokenString);
|
||||
var userId = signinToken.UserId;
|
||||
if (!issueRefreshToken)
|
||||
return (tokenProvider.CreateAccessToken(userId, tokenProvider.TemporaryFamilyId), null);
|
||||
|
||||
var tokenFamily = await repo.CreateTokenFamily(userId, ct);
|
||||
var (refreshToken, nonce) = tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter, shortLived);
|
||||
|
||||
return (
|
||||
tokenProvider.CreateAccessToken(userId, tokenFamily.Id),
|
||||
tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter)
|
||||
refreshToken,
|
||||
nonce
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<(string AccessToken, string RefreshToken)> PerformLoginWithRefreshToken(string refreshTokenString, CancellationToken ct)
|
||||
public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct)
|
||||
{
|
||||
var refreshToken = tokenProvider.ReadRefreshToken(refreshTokenString);
|
||||
var refreshToken = tokenProvider.ReadRefreshToken(refreshTokenString, nonce);
|
||||
var tokenFamily = await repo.GetTokenFamily(refreshToken.UserId, refreshToken.TokenFamilyId, ct)
|
||||
?? throw new UnauthorizedException("Invalid refresh token");
|
||||
|
||||
@@ -65,33 +65,35 @@ public class LoginProvider(ITokenFamilyStore repo, IJWTTokenProvider tokenProvid
|
||||
}
|
||||
|
||||
tokenFamily = await repo.IncrementTokenFamily(tokenFamily, ct);
|
||||
var isShortLived = (refreshToken.Expiration - refreshToken.ValidFrom).TotalMinutes <= jwtConfig.RefreshTokenShortLivedDurationInMinutes + 1;
|
||||
(var newRefreshToken, var newNonce) = tokenProvider.CreateRefreshToken(refreshToken.UserId, tokenFamily.Id, tokenFamily.Counter, isShortLived);
|
||||
|
||||
return (
|
||||
tokenProvider.CreateAccessToken(refreshToken.UserId, tokenFamily.Id),
|
||||
tokenProvider.CreateRefreshToken(refreshToken.UserId, tokenFamily.Id, tokenFamily.Counter)
|
||||
newRefreshToken,
|
||||
newNonce
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<(string AccessToken, string? RefreshToken)> PerformLoginWithPassword(string password, bool issueRefreshToken, CancellationToken ct)
|
||||
public async Task<(string AccessToken, string RefreshToken, string? Nonce)> PerformLoginWithPassword(string password, bool shortLived, CancellationToken ct)
|
||||
{
|
||||
if (!connection.ApplicationSettings.VerifyWebserverPassword(password))
|
||||
throw new UnauthorizedException("Invalid password");
|
||||
|
||||
var userId = "webserver";
|
||||
if (!issueRefreshToken)
|
||||
return (tokenProvider.CreateAccessToken(userId, tokenProvider.TemporaryFamilyId), null);
|
||||
|
||||
var tokenFamily = await repo.CreateTokenFamily(userId, ct);
|
||||
var (refreshToken, nonce) = tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter, shortLived);
|
||||
|
||||
return (
|
||||
tokenProvider.CreateAccessToken(userId, tokenFamily.Id),
|
||||
tokenProvider.CreateRefreshToken(userId, tokenFamily.Id, tokenFamily.Counter)
|
||||
refreshToken,
|
||||
nonce
|
||||
);
|
||||
}
|
||||
|
||||
public async Task PerformLogoutWithRefreshToken(string refreshTokenString, CancellationToken ct)
|
||||
public async Task PerformLogoutWithRefreshToken(string refreshTokenString, string? nonce, CancellationToken ct)
|
||||
{
|
||||
var token = tokenProvider.ReadRefreshToken(refreshTokenString);
|
||||
var token = tokenProvider.ReadRefreshToken(refreshTokenString, nonce);
|
||||
await repo.InvalidateTokenFamily(token.UserId, token.TokenFamilyId, ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class Program
|
||||
// Refresh token (if using refresh tokens)
|
||||
try
|
||||
{
|
||||
var refreshResult = await client.RefreshTokenV1Async(CancellationToken.None);
|
||||
var refreshResult = await client.RefreshTokenV1Async(null, CancellationToken.None);
|
||||
Console.WriteLine($"✓ Token refreshed: {refreshResult.AccessToken[..10]}...");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user