From b02f1f08b7a6f1a4a6093d30c61d7f4465691906 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Mon, 29 Jul 2024 16:36:02 +0300 Subject: [PATCH] Delay and consolidate coercion and validation of `PasswordlessOptions` (#143) --- .../Program.cs | 2 +- src/Passwordless/IPasswordlessClient.cs | 2 +- src/Passwordless/PasswordlessClient.cs | 96 ++++++++++++++----- src/Passwordless/PasswordlessOptions.cs | 6 +- .../ServiceCollectionExtensions.cs | 12 +-- .../PasswordlessApplication.cs | 16 ++-- tests/Passwordless.Tests/MagicLinksTests.cs | 14 ++- .../ServiceRegistrationTests.cs | 29 ++++++ 8 files changed, 124 insertions(+), 53 deletions(-) create mode 100644 tests/Passwordless.Tests/ServiceRegistrationTests.cs diff --git a/examples/Passwordless.AspNetIdentity.Example/Program.cs b/examples/Passwordless.AspNetIdentity.Example/Program.cs index cad4c64..312a8ec 100644 --- a/examples/Passwordless.AspNetIdentity.Example/Program.cs +++ b/examples/Passwordless.AspNetIdentity.Example/Program.cs @@ -94,7 +94,7 @@ static async Task StepUp(IOptions options, HttpCon { var http = new HttpClient { - BaseAddress = new Uri(options.Value.ApiUrl), + BaseAddress = new Uri(options.Value.ApiUrl ?? PasswordlessOptions.CloudApiUrl), DefaultRequestHeaders = { { "ApiSecret", options.Value.ApiSecret } } }; diff --git a/src/Passwordless/IPasswordlessClient.cs b/src/Passwordless/IPasswordlessClient.cs index f2143ac..da6996c 100644 --- a/src/Passwordless/IPasswordlessClient.cs +++ b/src/Passwordless/IPasswordlessClient.cs @@ -14,7 +14,7 @@ public interface IPasswordlessClient /// Creates a register token which will be used by your frontend to negotiate the creation of a WebAuth credential. /// Task CreateRegisterTokenAsync( - RegisterOptions options, + RegisterOptions registerOptions, CancellationToken cancellationToken = default ); diff --git a/src/Passwordless/PasswordlessClient.cs b/src/Passwordless/PasswordlessClient.cs index 40de3a4..80035ee 100644 --- a/src/Passwordless/PasswordlessClient.cs +++ b/src/Passwordless/PasswordlessClient.cs @@ -14,9 +14,30 @@ namespace Passwordless; /// [DebuggerDisplay("{DebuggerToString(),nq}")] -public class PasswordlessClient(HttpClient http, bool disposeClient, PasswordlessOptions options) +public class PasswordlessClient(HttpClient http, bool disposeHttp, PasswordlessOptions options) : IPasswordlessClient, IDisposable { + private static readonly string SdkVersion = + typeof(PasswordlessClient).Assembly.GetName().Version?.ToString(3) ?? + // This should never happen, unless the assembly had its metadata trimmed + "unknown"; + + private readonly HttpClient _http = new(new PasswordlessHttpHandler(http, disposeHttp), true) + { + BaseAddress = new Uri(options.ApiUrl ?? PasswordlessOptions.CloudApiUrl), + DefaultRequestHeaders = + { + { + "ApiSecret", + options.ApiSecret + }, + { + "Client-Version", + $".NET-{SdkVersion}" + } + } + }; + /// /// Initializes an instance of . /// @@ -33,34 +54,28 @@ public PasswordlessClient(PasswordlessOptions options) { } - private static readonly string SdkVersion = - typeof(PasswordlessClient).Assembly.GetName().Version?.ToString(3) ?? - // This should never happen, unless the assembly had its metadata trimmed - "unknown"; - - private readonly HttpClient _http = new(new PasswordlessHttpHandler(http, disposeClient), true) + // We validate the options right before making a request, so that we don't + // throw an exception during the service registration. + private void ValidateOptions() { - BaseAddress = new Uri(options.ApiUrl), - DefaultRequestHeaders = + if (string.IsNullOrWhiteSpace(options.ApiSecret)) { - { - "ApiSecret", - options.ApiSecret - }, - { - "Client-Version", - $".NET-{SdkVersion}" - } + throw new InvalidOperationException( + "Missing Passwordless API Secret. " + + "Please make sure a valid value is configured." + ); } - }; + } /// public async Task CreateRegisterTokenAsync( - RegisterOptions options, + RegisterOptions registerOptions, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("register/token", - options, + registerOptions, PasswordlessSerializerContext.Default.RegisterOptions, cancellationToken ); @@ -77,6 +92,8 @@ public async Task GenerateAuthenticationTokenAsync( AuthenticationOptions authenticationOptions, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("signin/generate-token", new AuthenticationOptionsRequest(authenticationOptions.UserId, authenticationOptions.TimeToLive?.TotalSeconds.Pipe(Convert.ToInt32)), PasswordlessSerializerContext.Default.AuthenticationOptionsRequest, @@ -95,6 +112,8 @@ public async Task VerifyAuthenticationTokenAsync( string authenticationToken, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("signin/verify", new VerifyTokenRequest(authenticationToken), PasswordlessSerializerContext.Default.VerifyTokenRequest, @@ -110,14 +129,22 @@ public async Task VerifyAuthenticationTokenAsync( } /// - public async Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default) => - (await _http.GetFromJsonAsync($"events?pageNumber={request.PageNumber}&numberOfResults={request.NumberOfResults}", + public async Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default) + { + ValidateOptions(); + + return (await _http.GetFromJsonAsync( + $"events?pageNumber={request.PageNumber}&numberOfResults={request.NumberOfResults}", PasswordlessSerializerContext.Default.GetEventLogResponse, - cancellationToken))!; + cancellationToken) + )!; + } /// public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync( "magic-links/send", request.ToRequest(), @@ -128,16 +155,23 @@ public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationT } /// - public async Task GetUsersCountAsync(CancellationToken cancellationToken = default) => - (await _http.GetFromJsonAsync( + public async Task GetUsersCountAsync(CancellationToken cancellationToken = default) + { + ValidateOptions(); + + return (await _http.GetFromJsonAsync( "users/count", PasswordlessSerializerContext.Default.UsersCount, - cancellationToken))!; + cancellationToken) + )!; + } /// public async Task> ListUsersAsync( CancellationToken cancellationToken = default) { + ValidateOptions(); + var response = await _http.GetFromJsonAsync( "users/list", PasswordlessSerializerContext.Default.ListResponsePasswordlessUserSummary, @@ -152,6 +186,8 @@ public async Task DeleteUserAsync( string userId, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("users/delete", new DeleteUserRequest(userId), PasswordlessSerializerContext.Default.DeleteUserRequest, @@ -164,6 +200,8 @@ public async Task> ListAliasesAsync( string userId, CancellationToken cancellationToken = default) { + ValidateOptions(); + var response = await _http.GetFromJsonAsync( $"alias/list?userid={userId}", PasswordlessSerializerContext.Default.ListResponseAliasPointer, @@ -178,6 +216,8 @@ public async Task SetAliasAsync( SetAliasRequest request, CancellationToken cancellationToken) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("alias", request, PasswordlessSerializerContext.Default.SetAliasRequest, @@ -192,6 +232,8 @@ public async Task> ListCredentialsAsync( string userId, CancellationToken cancellationToken = default) { + ValidateOptions(); + var response = await _http.GetFromJsonAsync( $"credentials/list?userid={userId}", PasswordlessSerializerContext.Default.ListResponseCredential, @@ -206,6 +248,8 @@ public async Task DeleteCredentialAsync( string id, CancellationToken cancellationToken = default) { + ValidateOptions(); + using var response = await _http.PostAsJsonAsync("credentials/delete", new DeleteCredentialRequest(id), PasswordlessSerializerContext.Default.DeleteCredentialRequest, diff --git a/src/Passwordless/PasswordlessOptions.cs b/src/Passwordless/PasswordlessOptions.cs index a686bd1..3cde8a9 100644 --- a/src/Passwordless/PasswordlessOptions.cs +++ b/src/Passwordless/PasswordlessOptions.cs @@ -6,7 +6,7 @@ namespace Passwordless; public class PasswordlessOptions { /// - /// Passwordless Cloud Url + /// Passwordless Cloud Url. /// public const string CloudApiUrl = "https://v4.passwordless.dev"; @@ -14,9 +14,9 @@ public class PasswordlessOptions /// Gets or sets the url to use for Passwordless operations. /// /// - /// Defaults to . + /// If not set, defaults to . /// - public string ApiUrl { get; set; } = CloudApiUrl; + public string? ApiUrl { get; set; } /// /// Gets or sets the secret API key used to authenticate with the Passwordless API. diff --git a/src/Passwordless/ServiceCollectionExtensions.cs b/src/Passwordless/ServiceCollectionExtensions.cs index 8a11e76..8fd2164 100644 --- a/src/Passwordless/ServiceCollectionExtensions.cs +++ b/src/Passwordless/ServiceCollectionExtensions.cs @@ -19,11 +19,7 @@ public static IServiceCollection AddPasswordlessSdk( this IServiceCollection services, Action configureOptions) { - services.AddOptions() - .Configure(configureOptions) - .PostConfigure(options => options.ApiUrl ??= PasswordlessOptions.CloudApiUrl) - .Validate(options => !string.IsNullOrEmpty(options.ApiSecret), "Passwordless: Missing ApiSecret"); - + services.AddOptions().Configure(configureOptions); services.RegisterDependencies(); return services; @@ -42,10 +38,7 @@ public static IServiceCollection AddPasswordlessSdk( this IServiceCollection services, IConfiguration configuration) { - services.AddOptions() - .Configure(configuration.Bind) - .Validate(options => !string.IsNullOrEmpty(options.ApiSecret), "Passwordless: Missing ApiSecret"); - + services.AddOptions().Configure(configuration.Bind); services.RegisterDependencies(); return services; @@ -68,7 +61,6 @@ public static IServiceCollection AddPasswordlessSdk( string section) { services.AddOptions().BindConfiguration(section); - services.RegisterDependencies(); return services; diff --git a/tests/Passwordless.Tests.Infra/PasswordlessApplication.cs b/tests/Passwordless.Tests.Infra/PasswordlessApplication.cs index a923843..09421fd 100644 --- a/tests/Passwordless.Tests.Infra/PasswordlessApplication.cs +++ b/tests/Passwordless.Tests.Infra/PasswordlessApplication.cs @@ -1,15 +1,11 @@ -using Microsoft.Extensions.DependencyInjection; - namespace Passwordless.Tests.Infra; public record PasswordlessApplication(string Name, string ApiUrl, string ApiSecret, string ApiKey) { - public IPasswordlessClient CreateClient() => - // Initialize using a service container to cover more code paths in tests - new ServiceCollection().AddPasswordlessSdk(o => - { - o.ApiUrl = ApiUrl; - o.ApiKey = ApiKey; - o.ApiSecret = ApiSecret; - }).BuildServiceProvider().GetRequiredService(); + public IPasswordlessClient CreateClient() => new PasswordlessClient(new PasswordlessOptions + { + ApiUrl = ApiUrl, + ApiSecret = ApiSecret, + ApiKey = ApiKey + }); } \ No newline at end of file diff --git a/tests/Passwordless.Tests/MagicLinksTests.cs b/tests/Passwordless.Tests/MagicLinksTests.cs index f888721..012ec43 100644 --- a/tests/Passwordless.Tests/MagicLinksTests.cs +++ b/tests/Passwordless.Tests/MagicLinksTests.cs @@ -16,7 +16,12 @@ public async Task I_can_send_a_magic_link_with_a_specified_time_to_live() { // Arrange var passwordless = await Api.CreateClientAsync(); - var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", new TimeSpan(0, 15, 0)); + + var request = new SendMagicLinkRequest( + "test@passwordless.dev", + "https://www.example.com?token=$TOKEN", "user", + new TimeSpan(0, 15, 0) + ); // Act var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None); @@ -30,7 +35,12 @@ public async Task I_can_send_a_magic_link_without_a_time_to_live() { // Arrange var passwordless = await Api.CreateClientAsync(); - var request = new SendMagicLinkRequest("test@passwordless.dev", "https://www.example.com?token=$TOKEN", "user", null); + + var request = new SendMagicLinkRequest( + "test@passwordless.dev", + "https://www.example.com?token=$TOKEN", "user", + null + ); // Act var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None); diff --git a/tests/Passwordless.Tests/ServiceRegistrationTests.cs b/tests/Passwordless.Tests/ServiceRegistrationTests.cs new file mode 100644 index 0000000..f93d7ad --- /dev/null +++ b/tests/Passwordless.Tests/ServiceRegistrationTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Passwordless.Tests; + +public class ServiceRegistrationTests +{ + [Fact] + public async Task I_can_register_a_client_with_invalid_credentials_and_not_receive_an_error_until_it_is_used() + { + // Arrange + var services = new ServiceCollection() + .AddPasswordlessSdk(o => + { + o.ApiSecret = ""; + o.ApiKey = ""; + }) + .BuildServiceProvider(); + + var passwordless = services.GetRequiredService(); + + // Act & assert + await Assert.ThrowsAnyAsync(async () => + await passwordless.CreateRegisterTokenAsync(new RegisterOptions("user123", "User")) + ); + } +} \ No newline at end of file