// 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; } }