// 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.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
namespace Duplicati.Library;
public class OAuthHelperHttpClient : JsonWebHelperHttpClient
{
private string _Token;
private string _Authid;
private DateTime _mTokenExpires = DateTime.UtcNow;
protected string OAuthLoginUrl { get; }
private readonly string _OAuthUrl;
///
/// Timeout for authentication requests
///
private static readonly TimeSpan AUTHENTICATION_TIMEOUT = TimeSpan.FromSeconds(25);
///
/// Maximum number of retries for authorization
///
private const int MAX_AUTHORIZATION_RETRIES = 5;
///
/// Set to true to automatically add the Authorization header to requests
///
public bool AutoAuthHeader { get; set; }
///
/// Set to true if the provider does not use refresh tokens, but only access tokens
///
public bool AccessTokenOnly { get; set; }
///
/// If true (the default), when a v1 authid is being used it will be swapped
/// with a v2 authid, when the OAuth service returns one (which it dypically
/// does after a provider token refresh has been performed). Some providers
/// are not compatible with v2 authid, tyically because they generate a new
/// refresh token with every access token refresh and invalidates the old.
/// If the oauth service still returns a v2 authid for such a provider,
/// set this property to false to make Duplicati ignore it.
///
public bool AutoV2 { get; set; } = true;
private static HttpClient CreateHttpClientWithInfiniteTimeout()
{
var client = HttpClientHelper.CreateClient();
client.Timeout = Timeout.InfiniteTimeSpan;
return client;
}
public OAuthHelperHttpClient(string authid, string servicename, string oauthurl, HttpClient httpClient = null, string useragent = null)
: base(httpClient ?? CreateHttpClientWithInfiniteTimeout())
{
_Authid = authid;
_OAuthUrl = oauthurl;
OAuthLoginUrl = AuthIdOptionsHelper.GetOAuthLoginUrl(servicename, oauthurl);
if (string.IsNullOrEmpty(authid))
throw new Interface.UserInformationException(
Strings.OAuthHelper.MissingAuthID(OAuthLoginUrl), "MissingAuthID");
}
private async Task CreateRequestAsync(string url, HttpMethod method, bool noAuthorization, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Add("User-Agent", UserAgent);
if (!noAuthorization && AutoAuthHeader && !string.Equals(_OAuthUrl, url)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer",
await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false));
return request;
}
public override Task CreateRequestAsync(string url, HttpMethod method, CancellationToken cancellationToken)
=> CreateRequestAsync(url, method, false, cancellationToken);
public async Task GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (AccessTokenOnly)
return _Authid;
if (_Token == null || _mTokenExpires < DateTime.UtcNow)
{
var retries = 0;
while (true)
{
HttpResponseMessage response = null;
try
{
if (cancellationToken.IsCancellationRequested)
throw new OperationCanceledException("Operation was cancelled", cancellationToken);
using var request = await CreateRequestAsync(_OAuthUrl, HttpMethod.Get, false, cancellationToken).ConfigureAwait(false);
return await Utility.Utility.WithTimeout(AUTHENTICATION_TIMEOUT, cancellationToken, async ct =>
{
request.Headers.Add("X-AuthID", _Authid);
response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var res = await ReadJsonResponseAsync(response, ct).ConfigureAwait(false);
_mTokenExpires = DateTime.UtcNow.AddSeconds(res.expires - 30);
if (AutoV2 && !string.IsNullOrWhiteSpace(res.v2_authid))
_Authid = res.v2_authid;
return _Token = res.access_token;
}).ConfigureAwait(false);
}
catch (Exception ex)
{
if (cancellationToken.IsCancellationRequested)
throw new OperationCanceledException("Operation was cancelled", ex, cancellationToken);
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
}
string msg = null;
if (response != null && response.Headers.Contains("X-Reason"))
{
msg = response.Headers.GetValues("X-Reason").FirstOrDefault();
if (string.IsNullOrWhiteSpace(msg))
msg = response.StatusCode.ToString();
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
string errorKey = msg == response.StatusCode.ToString()
? "OAuthOverQuotaError"
: "OAuthLoginError";
string errorMessage = errorKey == "OAuthOverQuotaError"
? Strings.OAuthHelper.OverQuotaError
: Strings.OAuthHelper.AuthorizationFailure(msg, OAuthLoginUrl);
throw new Interface.UserInformationException(errorMessage, errorKey,
errorKey == "OAuthLoginError" ? ex : null);
}
}
if (retries >= (clientError ? 1 : MAX_AUTHORIZATION_RETRIES))
{
await AttemptParseAndThrowExceptionAsync(ex, response, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(msg))
throw new Interface.UserInformationException(Strings.OAuthHelper.AuthorizationFailure(msg, OAuthLoginUrl), "OAuthLoginError", ex);
throw;
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retries)), cancellationToken).ConfigureAwait(false);
retries++;
}
finally
{
response?.Dispose();
}
}
}
return _Token;
}
}