// 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.Text;
using Duplicati.Library.Backend.Backblaze.Model;
using Duplicati.Library.Interface;
using Newtonsoft.Json;
using Exception = System.Exception;
using Uri = System.Uri;
namespace Duplicati.Library.Backend.Backblaze;
///
/// Helper class for handling Backblaze B2 authorization
///
/// Username
/// Password
/// HttpClient instance to use and pass along to JsonWebHelperHttpClient
public class B2AuthHelper(string userid, string password, HttpClient httpClient) : JsonWebHelperHttpClient(httpClient)
{
///
/// Cached authorization response
///
private AuthResponse _CachedAuthResponse;
///
/// Cached authorization response expiration time
///
private DateTime _configExpires;
///
/// Defines lifetime of cached authorization response
///
private const int CACHE_EXPIRATION_MINUTES = 60;
///
/// Authorization URL
///
internal const string AUTH_URL = "https://api.backblazeb2.com/b2api/v1/b2_authorize_account";
///
/// Timeout for authentication requests
///
private const int AUTHENTICATION_TIMEOUT_SECONDS = 10;
///
/// Maximum number of retries for authorization
///
private const int MAX_AUTHORIZATION_RETRIES = 5;
///
/// Authorization token (fetches from Config, which will refresh if needed)
///
private string AuthorizationToken => Config.AuthorizationToken;
///
/// API URL (fetches from Config, which will refresh if needed)
///
public string ApiUrl => Config.APIUrl;
///
/// Download URL (fetches from Config, which will refresh if needed)
///
public string DownloadUrl => Config.DownloadUrl;
///
/// Account ID (fetches from Config, which will refresh if needed)
///
public string AccountId => Config.AccountID;
///
/// Creates an HTTP request message with authorization header
///
/// The URL for the request
/// HTTP method (defaults to GET if null)
/// Configured HttpRequestMessage
public override HttpRequestMessage CreateRequest(string url, string method = null)
{
HttpRequestMessage request = base.CreateRequest(url, method);
request.Headers.TryAddWithoutValidation("Authorization", AuthorizationToken);
request.Headers.Add("User-Agent", UserAgent);
return request;
}
///
/// Cleans the url to remove trailing slashes
///
/// URL
private string DropTrailingSlashes(string url)
{
while (url.EndsWith("/", StringComparison.Ordinal))
url = url.Substring(0, url.Length - 1);
return url;
}
///
/// API DNS Name
///
public string ApiDnsName =>
_CachedAuthResponse == null || string.IsNullOrWhiteSpace(_CachedAuthResponse.APIUrl)
? null
: new Uri(_CachedAuthResponse.APIUrl).Host;
///
/// Download DNS Name
///
public string DownloadDnsName =>
_CachedAuthResponse == null || string.IsNullOrWhiteSpace(_CachedAuthResponse.DownloadUrl)
? null
: new Uri(_CachedAuthResponse.DownloadUrl).Host;
private AuthResponse Config
{
get
{
if (_CachedAuthResponse != null && _configExpires >= DateTime.UtcNow) return _CachedAuthResponse;
var retries = 0;
while (true)
{
HttpResponseMessage response = null;
try
{
using var request = base.CreateRequest(AUTH_URL);
request.Headers.Add("Authorization", $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(userid + ":" + password))}");
request.Headers.Add("ContentType", "application/json; charset=utf-8");
using var timeoutToken = new CancellationTokenSource();
timeoutToken.CancelAfter(TimeSpan.FromSeconds(AUTHENTICATION_TIMEOUT_SECONDS));
response = _httpClient.Send(request, timeoutToken.Token);
response.EnsureSuccessStatusCode();
_CachedAuthResponse = ReadJsonResponse(response);
_CachedAuthResponse.APIUrl = DropTrailingSlashes(_CachedAuthResponse.APIUrl);
_CachedAuthResponse.DownloadUrl = DropTrailingSlashes(_CachedAuthResponse.DownloadUrl);
_configExpires = DateTime.UtcNow + TimeSpan.FromMinutes(CACHE_EXPIRATION_MINUTES);
response.Dispose();
return _CachedAuthResponse;
}
catch (Exception ex)
{
var clientError = false;
try
{
// Only retry once on client errors
if (ex is HttpRequestException { StatusCode: not null } exception)
{
var sc = (int)exception.StatusCode;
clientError = sc is >= 400 and <= 499;
}
}
catch
{
// ignored
}
if (retries >= (clientError ? 1 : MAX_AUTHORIZATION_RETRIES))
{
AttemptParseAndThrowException(ex, response);
throw;
}
Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, retries)));
retries++;
}
}
}
}
///
/// Parses the exception and throws a new exception with the error message
///
/// Exception to be parsed
/// Response context
/// New detailed exception
public override void AttemptParseAndThrowException(Exception ex, HttpResponseMessage responseContext = null)
{
if (ex is not HttpRequestException || responseContext == null)
return;
if (ex is HttpRequestException && responseContext != null && responseContext.StatusCode == HttpStatusCode.TooManyRequests)
throw new TooManyRequestException(responseContext.Headers.RetryAfter);
using var stream = responseContext.Content.ReadAsStream();
using var reader = new StreamReader(stream);
var rawData = reader.ReadToEnd();
var errorResponse = JsonConvert.DeserializeObject(rawData);
throw new UserInformationException($"Backblaze ErrorResponse: {errorResponse.Status} - {errorResponse.Code}: {errorResponse.Message}", "BackblazeErrorResponse");
}
///
/// Extract Http status code from exception
///
/// Exception to be parsed
public static HttpStatusCode GetExceptionStatusCode(Exception ex)
{
return ex is HttpRequestException { StatusCode: not null } httpEx ? httpEx.StatusCode.Value : default;
}
}