Skip to content

Commit

Permalink
Delay and consolidate coercion and validation of PasswordlessOptions (
Browse files Browse the repository at this point in the history
  • Loading branch information
Tyrrrz authored Jul 29, 2024
1 parent a1bc696 commit b02f1f0
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 53 deletions.
2 changes: 1 addition & 1 deletion examples/Passwordless.AspNetIdentity.Example/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ static async Task<IResult> StepUp(IOptions<PasswordlessOptions> 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 } }
};

Expand Down
2 changes: 1 addition & 1 deletion src/Passwordless/IPasswordlessClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
Task<RegisterTokenResponse> CreateRegisterTokenAsync(
RegisterOptions options,
RegisterOptions registerOptions,
CancellationToken cancellationToken = default
);

Expand Down
96 changes: 70 additions & 26 deletions src/Passwordless/PasswordlessClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,30 @@ namespace Passwordless;

/// <inheritdoc cref="IPasswordlessClient" />
[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}"
}
}
};

/// <summary>
/// Initializes an instance of <see cref="PasswordlessClient" />.
/// </summary>
Expand All @@ -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."
);
}
};
}

/// <inheritdoc />
public async Task<RegisterTokenResponse> CreateRegisterTokenAsync(
RegisterOptions options,
RegisterOptions registerOptions,
CancellationToken cancellationToken = default)
{
ValidateOptions();

using var response = await _http.PostAsJsonAsync("register/token",
options,
registerOptions,
PasswordlessSerializerContext.Default.RegisterOptions,
cancellationToken
);
Expand All @@ -77,6 +92,8 @@ public async Task<AuthenticationTokenResponse> 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,
Expand All @@ -95,6 +112,8 @@ public async Task<VerifiedUser> VerifyAuthenticationTokenAsync(
string authenticationToken,
CancellationToken cancellationToken = default)
{
ValidateOptions();

using var response = await _http.PostAsJsonAsync("signin/verify",
new VerifyTokenRequest(authenticationToken),
PasswordlessSerializerContext.Default.VerifyTokenRequest,
Expand All @@ -110,14 +129,22 @@ public async Task<VerifiedUser> VerifyAuthenticationTokenAsync(
}

/// <inheritdoc />
public async Task<GetEventLogResponse> GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default) =>
(await _http.GetFromJsonAsync($"events?pageNumber={request.PageNumber}&numberOfResults={request.NumberOfResults}",
public async Task<GetEventLogResponse> GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default)
{
ValidateOptions();

return (await _http.GetFromJsonAsync(
$"events?pageNumber={request.PageNumber}&numberOfResults={request.NumberOfResults}",
PasswordlessSerializerContext.Default.GetEventLogResponse,
cancellationToken))!;
cancellationToken)
)!;
}

/// <inheritdoc />
public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationToken cancellationToken = default)
{
ValidateOptions();

using var response = await _http.PostAsJsonAsync(
"magic-links/send",
request.ToRequest(),
Expand All @@ -128,16 +155,23 @@ public async Task SendMagicLinkAsync(SendMagicLinkRequest request, CancellationT
}

/// <inheritdoc />
public async Task<UsersCount> GetUsersCountAsync(CancellationToken cancellationToken = default) =>
(await _http.GetFromJsonAsync(
public async Task<UsersCount> GetUsersCountAsync(CancellationToken cancellationToken = default)
{
ValidateOptions();

return (await _http.GetFromJsonAsync(
"users/count",
PasswordlessSerializerContext.Default.UsersCount,
cancellationToken))!;
cancellationToken)
)!;
}

/// <inheritdoc />
public async Task<IReadOnlyList<PasswordlessUserSummary>> ListUsersAsync(
CancellationToken cancellationToken = default)
{
ValidateOptions();

var response = await _http.GetFromJsonAsync(
"users/list",
PasswordlessSerializerContext.Default.ListResponsePasswordlessUserSummary,
Expand All @@ -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,
Expand All @@ -164,6 +200,8 @@ public async Task<IReadOnlyList<AliasPointer>> ListAliasesAsync(
string userId,
CancellationToken cancellationToken = default)
{
ValidateOptions();

var response = await _http.GetFromJsonAsync(
$"alias/list?userid={userId}",
PasswordlessSerializerContext.Default.ListResponseAliasPointer,
Expand All @@ -178,6 +216,8 @@ public async Task SetAliasAsync(
SetAliasRequest request,
CancellationToken cancellationToken)
{
ValidateOptions();

using var response = await _http.PostAsJsonAsync("alias",
request,
PasswordlessSerializerContext.Default.SetAliasRequest,
Expand All @@ -192,6 +232,8 @@ public async Task<IReadOnlyList<Credential>> ListCredentialsAsync(
string userId,
CancellationToken cancellationToken = default)
{
ValidateOptions();

var response = await _http.GetFromJsonAsync(
$"credentials/list?userid={userId}",
PasswordlessSerializerContext.Default.ListResponseCredential,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/Passwordless/PasswordlessOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ namespace Passwordless;
public class PasswordlessOptions
{
/// <summary>
/// Passwordless Cloud Url
/// Passwordless Cloud Url.
/// </summary>
public const string CloudApiUrl = "https://v4.passwordless.dev";

/// <summary>
/// Gets or sets the url to use for Passwordless operations.
/// </summary>
/// <remarks>
/// Defaults to <see cref="CloudApiUrl" />.
/// If not set, defaults to <see cref="CloudApiUrl" />.
/// </remarks>
public string ApiUrl { get; set; } = CloudApiUrl;
public string? ApiUrl { get; set; }

/// <summary>
/// Gets or sets the secret API key used to authenticate with the Passwordless API.
Expand Down
12 changes: 2 additions & 10 deletions src/Passwordless/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ public static IServiceCollection AddPasswordlessSdk(
this IServiceCollection services,
Action<PasswordlessOptions> configureOptions)
{
services.AddOptions<PasswordlessOptions>()
.Configure(configureOptions)
.PostConfigure(options => options.ApiUrl ??= PasswordlessOptions.CloudApiUrl)
.Validate(options => !string.IsNullOrEmpty(options.ApiSecret), "Passwordless: Missing ApiSecret");

services.AddOptions<PasswordlessOptions>().Configure(configureOptions);
services.RegisterDependencies();

return services;
Expand All @@ -42,10 +38,7 @@ public static IServiceCollection AddPasswordlessSdk(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<PasswordlessOptions>()
.Configure(configuration.Bind)
.Validate(options => !string.IsNullOrEmpty(options.ApiSecret), "Passwordless: Missing ApiSecret");

services.AddOptions<PasswordlessOptions>().Configure(configuration.Bind);
services.RegisterDependencies();

return services;
Expand All @@ -68,7 +61,6 @@ public static IServiceCollection AddPasswordlessSdk(
string section)
{
services.AddOptions<PasswordlessOptions>().BindConfiguration(section);

services.RegisterDependencies();

return services;
Expand Down
16 changes: 6 additions & 10 deletions tests/Passwordless.Tests.Infra/PasswordlessApplication.cs
Original file line number Diff line number Diff line change
@@ -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<IPasswordlessClient>();
public IPasswordlessClient CreateClient() => new PasswordlessClient(new PasswordlessOptions
{
ApiUrl = ApiUrl,
ApiSecret = ApiSecret,
ApiKey = ApiKey
});
}
14 changes: 12 additions & 2 deletions tests/Passwordless.Tests/MagicLinksTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("[email protected]", "https://www.example.com?token=$TOKEN", "user", new TimeSpan(0, 15, 0));

var request = new SendMagicLinkRequest(
"[email protected]",
"https://www.example.com?token=$TOKEN", "user",
new TimeSpan(0, 15, 0)
);

// Act
var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None);
Expand All @@ -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("[email protected]", "https://www.example.com?token=$TOKEN", "user", null);

var request = new SendMagicLinkRequest(
"[email protected]",
"https://www.example.com?token=$TOKEN", "user",
null
);

// Act
var action = async () => await passwordless.SendMagicLinkAsync(request, CancellationToken.None);
Expand Down
29 changes: 29 additions & 0 deletions tests/Passwordless.Tests/ServiceRegistrationTests.cs
Original file line number Diff line number Diff line change
@@ -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<IPasswordlessClient>();

// Act & assert
await Assert.ThrowsAnyAsync<InvalidOperationException>(async () =>
await passwordless.CreateRegisterTokenAsync(new RegisterOptions("user123", "User"))
);
}
}

0 comments on commit b02f1f0

Please sign in to comment.