Merge pull request #6670 from duplicati/feature/support-api-key-with-filen

Add support for Filen.io API key
This commit is contained in:
Kenneth Skovhede
2025-12-10 15:07:02 +01:00
committed by GitHub
6 changed files with 244 additions and 56 deletions
@@ -39,6 +39,10 @@ public class FilenBackend : IStreamingBackend
/// </summary>
private const string MoveToTrashOption = "move-to-trash";
/// <summary>
/// The API key option name
/// </summary>
private const string ApiKeyOption = "api-key";
/// <summary>
/// The Filen client instance
/// </summary>
private FilenClient? _client;
@@ -59,6 +63,10 @@ public class FilenBackend : IStreamingBackend
/// </summary>
private readonly string? _twoFactorCode;
/// <summary>
/// The API key, if any
/// </summary>
private readonly string? _apiKey;
/// <summary>
/// Whether to move files to the trash instead of deleting them
/// </summary>
private readonly bool _moveToTrash;
@@ -93,6 +101,7 @@ public class FilenBackend : IStreamingBackend
_moveToTrash = Utility.Utility.ParseBoolOption(options, MoveToTrashOption);
_twoFactorCode = options.GetValueOrDefault(TwoFactorOption);
_apiKey = options.GetValueOrDefault(ApiKeyOption);
_timeout = TimeoutOptionsHelper.Parse(options);
}
@@ -109,7 +118,7 @@ public class FilenBackend : IStreamingBackend
_client = null;
var httpClient = HttpClientHelper.CreateClient();
httpClient.Timeout = Timeout.InfiniteTimeSpan;
_client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, cancellationToken).ConfigureAwait(false);
_client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, _apiKey, cancellationToken).ConfigureAwait(false);
}
return _client;
@@ -129,10 +138,19 @@ public class FilenBackend : IStreamingBackend
public IList<ICommandLineArgument> SupportedCommands => [
.. AuthOptionsHelper.GetOptions(),
new CommandLineArgument(TwoFactorOption, CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong),
new CommandLineArgument(ApiKeyOption, CommandLineArgument.ArgumentType.Password, Strings.FilenBackend.ApiKeyShort, Strings.FilenBackend.ApiKeyLong),
new CommandLineArgument(MoveToTrashOption, CommandLineArgument.ArgumentType.Boolean, Strings.FilenBackend.MoveToTrashShort, Strings.FilenBackend.MoveToTrashLong),
.. TimeoutOptionsHelper.GetOptions()
];
/// <summary>
/// Gets an API key for the account
/// </summary>
/// <param name="cancellationToken">The cancellation token to use</param>
/// <returns>The API key</returns>
internal async Task<string?> GetApiKey(CancellationToken cancellationToken)
=> (await GetClientAsync(cancellationToken))?.ApiKey;
/// <summary>
/// Gets the folder uuid for the folder this backend is working in
/// </summary>
+122 -55
View File
@@ -182,6 +182,11 @@ public class FilenClient : IDisposable
/// </summary>
public DateTime ValidUntil => _validUntil;
/// <summary>
/// The API key for the client
/// </summary>
internal string ApiKey => _authResult.ApiKey;
/// <summary>
/// Creates a new Filen client and authenticates
/// </summary>
@@ -189,74 +194,136 @@ public class FilenClient : IDisposable
/// <param name="email">The email address to use for login</param>
/// <param name="password">The password to use for login</param>
/// <param name="twoFactorCode">The two-factor code to use for login</param>
/// <param name="apiKey">The API key to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authenticated Filen client</returns>
public static async Task<FilenClient> CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, CancellationToken cancellationToken)
public static async Task<FilenClient> CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken)
{
var baseUrl = GatewayUrls[Random.Shared.Next(0, GatewayUrls.Count)];
var authResult = await FilenLogin.AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, cancellationToken).ConfigureAwait(false);
var authResult = await AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, apiKey, cancellationToken).ConfigureAwait(false);
return new FilenClient(httpClient, authResult, baseUrl);
}
/// <summary>
/// Methods used for the initial login
/// Returns the authentication information for the user
/// </summary>
private static class FilenLogin
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication information for the user</returns>
private static async Task<AuthInfo> GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken)
{
/// <summary>
/// Returns the authentication information for the user
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication information for the user</returns>
private static async Task<AuthInfo> GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken)
var loginUrl = $"{baseUrl}/v3/auth/info";
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return await ExtractDataFromResponse<AuthInfo>(response, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Authenticates the user with the Filen API
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="password">The password to use for login</param>
/// <param name="twoFactorCode">The two-factor code to use for login</param>
/// <param name="apiKey">The API key to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication result from the initial login</returns>
private static async Task<FilenAuthResult> AuthenticateAsync(
HttpClient httpClient,
string baseUrl,
string email,
string password,
string? twoFactorCode,
string? apiKey,
CancellationToken cancellationToken)
{
// Always need authInfo to derive the account master key from password
var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken)
.ConfigureAwait(false);
var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(
password, authInfo.AuthVersion, authInfo.Salt);
// 1) Fast-path: if apiKey is provided, try to use it to fetch master keys first
if (!string.IsNullOrWhiteSpace(apiKey))
{
var loginUrl = $"{baseUrl}/v3/auth/info";
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return await ExtractDataFromResponse<AuthInfo>(response, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Authenticates the user with the Filen API
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="password">The password to use for login</param>
/// <param name="twoFactorCode">The two-factor code to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication result from the initial login</returns>
public static async Task<FilenAuthResult> AuthenticateAsync(HttpClient httpClient, string baseUrl, string email, string password, string? twoFactorCode, CancellationToken cancellationToken)
{
var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(twoFactorCode))
twoFactorCode = "XXXXXX";
var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(password, authInfo.AuthVersion, authInfo.Salt);
var loginUrl = $"{baseUrl}/v3/login";
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
request.Content = new StringContent(JsonSerializer.Serialize(new { email, password = rootKeys.Password, twoFactorCode, authVersion = authInfo.AuthVersion }), Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(request, cancellationToken);
var result = await ExtractDataFromResponse<AuthResponse>(response, cancellationToken).ConfigureAwait(false);
// var mk = await GetAllMasterKeys(masterKey1, result.ApiKey, cancellationToken).ConfigureAwait(false);
var masterKeys = rootKeys.MasterKey.DecryptMetadata(result.MasterKeys);
return new FilenAuthResult
try
{
ApiKey = result.ApiKey,
AccountMasterKey = rootKeys.MasterKey,
MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList()
};
var mkUrl = $"{baseUrl}/v3/user/masterKeys";
using var mkReq = new HttpRequestMessage(HttpMethod.Post, mkUrl);
mkReq.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
// For retrieval, Filen accepts an empty/placeholder body
mkReq.Content = new StringContent(
JsonSerializer.Serialize(new { masterKeys = "" }),
Encoding.UTF8,
"application/json");
using var mkResp = await httpClient.SendAsync(mkReq, cancellationToken)
.ConfigureAwait(false);
mkResp.EnsureSuccessStatusCode();
var mkResult = await ExtractDataFromResponse<MasterKeysResponse>(mkResp, cancellationToken)
.ConfigureAwait(false);
var masterKeysPlain = rootKeys.MasterKey.DecryptMetadata(mkResult.MasterKeys);
return new FilenAuthResult
{
ApiKey = apiKey,
AccountMasterKey = rootKeys.MasterKey,
MasterKeys = masterKeysPlain.Split('|').Select(DerivedKey.Create).ToList()
};
}
catch
{
// Any failure (invalid/expired apiKey, network, schema, decrypt) -> fall back to login
}
}
// 2) Fallback: login endpoint (requires MFA if enabled)
if (string.IsNullOrWhiteSpace(twoFactorCode))
twoFactorCode = "XXXXXX";
var loginUrl = $"{baseUrl}/v3/login";
using var loginReq = new HttpRequestMessage(HttpMethod.Post, loginUrl);
loginReq.Content = new StringContent(
JsonSerializer.Serialize(new
{
email,
password = rootKeys.Password,
twoFactorCode,
authVersion = authInfo.AuthVersion
}),
Encoding.UTF8,
"application/json");
using var loginResp = await httpClient.SendAsync(loginReq, cancellationToken)
.ConfigureAwait(false);
var loginResult = await ExtractDataFromResponse<AuthResponse>(loginResp, cancellationToken)
.ConfigureAwait(false);
var masterKeys = rootKeys.MasterKey.DecryptMetadata(loginResult.MasterKeys);
return new FilenAuthResult
{
ApiKey = loginResult.ApiKey,
AccountMasterKey = rootKeys.MasterKey,
MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList()
};
}
private sealed class MasterKeysResponse
{
public string MasterKeys { get; set; } = "";
}
/// <summary>
@@ -0,0 +1,81 @@
// 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 Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
namespace Duplicati.Library.Backend.Filen;
/// <summary>
/// Web module to help users obtain an API key for Filen.io
/// </summary>
public class GetApiKeyModule : IWebModule
{
/// <inheritdoc/>
public string Key => "filen-get-api-key";
/// <inheritdoc/>
public string DisplayName => "Get Filen.io API Key";
/// <inheritdoc/>
public string Description => "Module to help users obtain an API key for Filen.io using their email password, and MFA code.";
/// <summary>
/// Constructor for metadata loading
/// </summary>
public GetApiKeyModule()
{
}
/// <inheritdoc/>
public IList<ICommandLineArgument> SupportedCommands =>
[
.. AuthOptionsHelper.GetOptions(),
new CommandLineArgument("two-factor", CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong)
];
/// <inheritdoc/>
public IDictionary<string, string> Execute(IDictionary<string, string?> options)
{
options.TryGetValue("filen-operation", out var operation);
if (operation != "GetApiKey")
throw new UserInformationException("Invalid operation", "InvalidOperation");
options.TryGetValue("url", out var url);
if (string.IsNullOrEmpty(url))
throw new UserInformationException("URL is required", "UrlOptionMissing");
var uri = new Utility.Uri(url);
var newOpts = new Dictionary<string, string?>(options);
foreach (var key in uri.QueryParameters.AllKeys)
if (key != null)
newOpts[key] = uri.QueryParameters[key];
var backend = new FilenBackend(url, newOpts);
var apiKey = backend.GetApiKey(CancellationToken.None).Await();
return new Dictionary<string, string> { { "api-key", apiKey ?? string.Empty } };
}
/// <inheritdoc/>
public IDictionary<string, IDictionary<string, string>> GetLookups()
=> new Dictionary<string, IDictionary<string, string>>();
}
@@ -27,6 +27,8 @@ namespace Duplicati.Library.Backend.Strings
public static string DisplayName => LC.L(@"Filen.io");
public static string TwoFactorShort => LC.L(@"Optional 2-factor code");
public static string TwoFactorLong => LC.L(@"The 2-factor code to use for authentication, leave empty if the account is not MFA protected. Note that a new code must be provided by the user for each authentication attempt.");
public static string ApiKeyShort => LC.L(@"Optional API key");
public static string ApiKeyLong => LC.L(@"The API key to use for authentication, which will work even if the account is MFA protected. Obtain the API key via the Filen CLI tool.");
public static string MoveToTrashShort => LC.L(@"Move to trash");
public static string MoveToTrashLong => LC.L(@"If set, files will be moved to the trash instead of being deleted permanently.");
}
+1
View File
@@ -37,5 +37,6 @@ public static class WebModules
new KeyGenerator(),
new KeyUploader(),
new Storj.StorjConfig(),
new Filen.GetApiKeyModule(),
];
}
@@ -41,6 +41,19 @@ public record WebModules : IEndpointV1
private static IEnumerable<IWebModule> ExecuteGet()
=> Library.DynamicLoader.WebLoader.Modules;
private static string UnmaskUrl(Connection connection, string maskedurl, string? backupId)
{
var previousUrl = !string.IsNullOrWhiteSpace(backupId) ? connection.GetBackup(backupId)?.TargetURL : null;
var unmasked = string.IsNullOrWhiteSpace(previousUrl)
? maskedurl
: QuerystringMasking.Unmask(maskedurl, previousUrl);
if (Connection.UrlContainsPasswordPlaceholder(unmasked))
throw new ArgumentException("Unmasked URL contains password placeholder");
return unmasked;
}
private static async Task<Dto.WebModuleOutputDto> ExecutePost(Connection connection, IApplicationSettings applicationSettings, string modulekey, Dictionary<string, string> inputOptions, CancellationToken cancellationToken)
{
var m = Library.DynamicLoader.WebLoader.Modules.FirstOrDefault(x => x.Key.Equals(modulekey, StringComparison.OrdinalIgnoreCase))
@@ -52,6 +65,12 @@ public record WebModules : IEndpointV1
await SecretProviderHelper.ApplySecretProviderAsync([], [], options, Library.Utility.TempFolder.SystemTempPath, applicationSettings.SecretProvider, cancellationToken);
if (options.TryGetValue("url", out var maskedurl) && !string.IsNullOrEmpty(maskedurl))
{
var unmasked = UnmaskUrl(connection, maskedurl, options.GetValueOrDefault("backup-id"));
options["url"] = unmasked;
}
return new Dto.WebModuleOutputDto(
Status: "OK",
Result: m.Execute(options)