// 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.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Duplicati.WebserverCore.Dto;
using Duplicati.WebserverCore.Dto.V2;
using Duplicati.WebserverCore.Endpoints.V1.Backup;
namespace Duplicati.WebserverCore.Client;
///
/// A client for interacting with the Duplicati server API, supporting both v1 and v2 endpoints.
///
public class DuplicatiServerClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly JsonSerializerOptions _jsonOptions;
private bool _disposed;
private readonly bool _selfOwnedHttpClient;
private readonly SemaphoreSlim _tokenRefreshSemaphore = new(1, 1);
private readonly ServerCredentialType _credentialType;
private readonly string _credential;
///
/// Gets a value indicating whether the client is authenticated.
///
public bool IsAuthenticated => !string.IsNullOrEmpty(_httpClient.DefaultRequestHeaders.Authorization?.Parameter);
///
/// Initializes a new instance of the class.
///
/// The base URL of the Duplicati server.
/// The type of credential being provided (Password or Token).
/// The server password or access token.
/// Optional HttpClient instance. If not provided, a new one will be created.
public DuplicatiServerClient(string baseUrl, ServerCredentialType credentialType, string credential, HttpClient? httpClient = null)
{
_selfOwnedHttpClient = httpClient is not null;
_baseUrl = baseUrl.TrimEnd('/');
_httpClient = httpClient ?? new HttpClient();
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
AllowTrailingCommas = true
};
_credential = credential;
_credentialType = credentialType;
}
///
/// Authenticates the client with the Duplicati server using the provided credentials and acquires a bearer token.
///
/// If a call is made to another method before calling Authenticate, it will automatically call this method to ensure the client is authenticated.
/// The idea of having a separate Authenticate method is to allow for explicit authentication to avoid getting a 401 result on the server log.
///
/// The cancellation token. Optional, defaults to .
public async Task Authenticate(CancellationToken cancellationToken = default)
{
await RefreshTokenAsync(cancellationToken).ConfigureAwait(false);
}
///
/// Acquires a bearer token using password-based authentication.
///
/// The server password.
/// The cancellation token.
private async Task AcquireBearerViaPasswordAuthAsync(string password, CancellationToken cancellationToken)
{
var loginResult = await LoginV1Async(new LoginInputDto(password, true), cancellationToken).ConfigureAwait(false);
SetTokenAuthentication(loginResult.AccessToken);
}
///
/// Acquires a bearer token using token-based authentication.
///
/// The signin token.
/// The cancellation token.
private async Task AcquireBearerViaTokenAuthAsync(string token, CancellationToken cancellationToken)
{
var signinResult = await SigninV1Async(new SigninInputDto(token, true), cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(signinResult.AccessToken))
SetTokenAuthentication(signinResult.AccessToken);
}
///
/// Sets token-based authentication.
///
/// The authentication token.
public void SetTokenAuthentication(string token)
{
if (string.IsNullOrWhiteSpace(token))
throw new ArgumentException("Token cannot be null or empty");
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
///
/// Makes an HTTP GET request to the specified endpoint.
///
/// The type of the response data.
/// The API endpoint.
/// The cancellation token. Optional, defaults to .
/// Whether to retry on 401 unauthorized responses.
/// The response data.
private async Task GetAsync(string endpoint, CancellationToken cancellationToken = default, bool retryOn401 = true)
{
return await ExecuteWithRetryAsync(async () =>
{
using var response = await _httpClient.GetAsync($"{_baseUrl}{endpoint}", cancellationToken).ConfigureAwait(false);
return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
}, retryOn401, cancellationToken).ConfigureAwait(false);
}
///
/// Makes an HTTP POST request to the specified endpoint.
///
/// The type of the response data.
/// The API endpoint.
/// The request data.
/// The cancellation token. Optional, defaults to .
/// Whether to retry on 401 unauthorized responses.
/// The response data.
private async Task PostAsync(string endpoint, object? data, CancellationToken cancellationToken = default, bool retryOn401 = true)
{
return await ExecuteWithRetryAsync(async () =>
{
var json = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : string.Empty;
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync($"{_baseUrl}{endpoint}", content, cancellationToken).ConfigureAwait(false);
return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
}, retryOn401, cancellationToken).ConfigureAwait(false);
}
///
/// Makes an HTTP PUT request to the specified endpoint.
///
/// The type of the response data.
/// The API endpoint.
/// The request data.
/// The cancellation token. Optional, defaults to .
/// Whether to retry on 401 unauthorized responses.
/// The response data.
private async Task PutAsync(string endpoint, object? data, CancellationToken cancellationToken = default, bool retryOn401 = true)
{
return await ExecuteWithRetryAsync(async () =>
{
var json = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : string.Empty;
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync($"{_baseUrl}{endpoint}", content, cancellationToken).ConfigureAwait(false);
return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
}, retryOn401, cancellationToken).ConfigureAwait(false);
}
///
/// Makes an HTTP DELETE request to the specified endpoint.
///
/// The type of the response data.
/// The API endpoint.
/// The cancellation token. Optional, defaults to .
/// Whether to retry on 401 unauthorized responses.
/// The response data.
private async Task DeleteAsync(string endpoint, CancellationToken cancellationToken = default, bool retryOn401 = true)
{
return await ExecuteWithRetryAsync(async () =>
{
using var response = await _httpClient.DeleteAsync($"{_baseUrl}{endpoint}", cancellationToken).ConfigureAwait(false);
return await ProcessResponseAsync(response, cancellationToken).ConfigureAwait(false);
}, retryOn401, cancellationToken).ConfigureAwait(false);
}
///
/// Processes an HTTP response and handles potential errors.
///
/// The type of the response data.
/// The HTTP response message.
/// The cancellation token. Optional, defaults to .
/// The response data.
private async Task ProcessResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException("Unauthorized access - token may be expired");
}
response.EnsureSuccessStatusCode();
if (typeof(T) == typeof(Stream))
{
// Response is a stream, read the response into it
var ms = new MemoryStream();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
ms.Position = 0; // rewind
return (T)(object)ms;
}
else
{
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize(content)!;
}
}
///
/// Executes an HTTP request with retry logic for 401 unauthorized responses.
///
/// The type of the response data.
/// The HTTP operation to execute.
/// Whether to retry on 401 unauthorized responses.
/// The cancellation token. Optional, defaults to .
/// The response data.
private async Task ExecuteWithRetryAsync(Func> operation, bool retryOn401, CancellationToken cancellationToken = default)
{
const int maxRetries = 3;
var attempts = 0;
while (attempts <= maxRetries)
{
try
{
return await operation().ConfigureAwait(false);
}
catch (UnauthorizedAccessException) when (retryOn401 && attempts < maxRetries)
{
attempts++;
await RefreshTokenAsync(cancellationToken).ConfigureAwait(false);
}
}
// This should never be reached due to the loop logic, but included for completeness
throw new UnauthorizedAccessException("Maximum retry attempts reached for token refresh");
}
///
/// Refreshes the authentication token based on the credential type.
///
/// The cancellation token. Optional, defaults to .
private async Task RefreshTokenAsync(CancellationToken cancellationToken = default)
{
await _tokenRefreshSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
switch (_credentialType)
{
case ServerCredentialType.Password:
await AcquireBearerViaPasswordAuthAsync(_credential, cancellationToken).ConfigureAwait(false);
break;
case ServerCredentialType.Token:
await AcquireBearerViaTokenAuthAsync(_credential, cancellationToken).ConfigureAwait(false);
break;
default:
throw new InvalidOperationException($"Unsupported credential type: {_credentialType}");
}
}
finally
{
_tokenRefreshSemaphore.Release();
}
}
// V1 Authentication Methods
///
/// Performs password-based login to the Duplicati server (V1).
///
/// The login input containing password and remember me flag.
/// The cancellation token. Optional, defaults to .
/// The access token output.
public async Task LoginV1Async(LoginInputDto input, CancellationToken cancellationToken = default)
{
return await PostAsync("/api/v1/auth/login", input, cancellationToken, false).ConfigureAwait(false);
}
///
/// Performs token-based signin to the Duplicati server (V1).
///
/// The signin input containing signin token and remember me flag.
/// The cancellation token. Optional, defaults to .
/// The access token output.
public async Task SigninV1Async(SigninInputDto input, CancellationToken cancellationToken = default)
{
return await PostAsync("/api/v1/auth/signin", input, cancellationToken, false).ConfigureAwait(false);
}
///
/// 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(string? nonce, CancellationToken cancellationToken = default)
{
return await PostAsync("/api/v1/auth/refresh", string.IsNullOrWhiteSpace(nonce) ? null : new { Nonce = nonce }, cancellationToken, false).ConfigureAwait(false);
}
///
/// Issues a signin token for authentication (V1).
///
/// The signin token input.
/// The cancellation token. Optional, defaults to .
/// The signin token output.
public async Task IssueSigninTokenV1Async(IssueSigninTokenInputDto input, CancellationToken cancellationToken = default)
{
return await PostAsync("/api/v1/auth/issuesignintoken", input, cancellationToken).ConfigureAwait(false);
}
///
/// Issues a single-operation token for a specific operation (V1).
///
/// The operation name.
/// The cancellation token. Optional, defaults to .
/// The single operation token output.
public async Task IssueTokenV1Async(string operation, CancellationToken cancellationToken = default)
{
return await PostAsync($"/api/v1/auth/issuetoken/{operation}", null, cancellationToken).ConfigureAwait(false);
}
///
/// Issues a forever token for long-term authentication (V1).
///
/// The cancellation token. Optional, defaults to .
/// The single operation token output.
public async Task IssueForeverTokenV1Async(CancellationToken cancellationToken = default)
{
return await PostAsync("/api/v1/auth/issue-forever-token", null, cancellationToken).ConfigureAwait(false);
}
///
/// 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(string? nonce, CancellationToken cancellationToken = default)
{
await PostAsync