diff --git a/src/Passwordless.AspNetCore/IdentityBuilderExtensions.cs b/src/Passwordless.AspNetCore/IdentityBuilderExtensions.cs index 3c8b237..4fe083e 100644 --- a/src/Passwordless.AspNetCore/IdentityBuilderExtensions.cs +++ b/src/Passwordless.AspNetCore/IdentityBuilderExtensions.cs @@ -18,47 +18,53 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class IdentityBuilderExtensions { - /// - /// Adds the services to support - /// - /// The . - /// Configures the . - /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IServiceCollection AddPasswordless(this IServiceCollection services, Action configure) - where TUser : class, new() + private static IServiceCollection AddPasswordlessIdentity( + this IServiceCollection services, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + Type userType, + Action> configureOptions, + string? defaultScheme) { - return services.AddPasswordlessCore(typeof(TUser), configure, defaultScheme: null); + // Options + var optionsBuilder = services.AddOptions(); + configureOptions(optionsBuilder); + + // Default scheme + if (!string.IsNullOrEmpty(defaultScheme)) + { + optionsBuilder.Configure(o => o.SignInScheme = defaultScheme); + } + + // Services + services.TryAddScoped( + typeof(IPasswordlessService), + typeof(PasswordlessService<>).MakeGenericType(userType) + ); + + services.TryAddScoped(); + + return services; } /// /// Adds the services to support /// - /// The current instance. + /// The . /// Configures the . - /// The . + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IdentityBuilder AddPasswordless(this IdentityBuilder builder, Action configure) - { - builder.Services.AddPasswordlessCore(builder.UserType, configure, IdentityConstants.ApplicationScheme); - return builder; - } - - [RequiresDynamicCode("Calls Microsoft.Extensions.DependencyInjection.IdentityBuilderExtensions.AddShared(Type, OptionsBuilder, String)")] - [RequiresUnreferencedCode("Calls Microsoft.Extensions.DependencyInjection.IdentityBuilderExtensions.AddShared(Type, OptionsBuilder, String)")] - private static IServiceCollection AddPasswordlessCore(this IServiceCollection services, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type userType, - Action configure, - string? defaultScheme) + public static IServiceCollection AddPasswordless( + this IServiceCollection services, + Action configure) + where TUser : class, new() { - var optionsBuilder = services.AddOptions().Configure(configure); - - // Add the SDK services but don't configure it there since ASP.NET Core options are a superset of their options. + // Don't set up options here because we can't use the provided delegate as it's for a different type services.AddPasswordlessSdk(_ => { }); - // Override SDK options to come from ASP.NET Core options + // Derive core options from ASP.NET Core options services.AddOptions() .Configure>((options, aspNetCoreOptionsAccessor) => { @@ -68,106 +74,152 @@ private static IServiceCollection AddPasswordlessCore(this IServiceCollection se options.ApiKey = aspNetCoreOptions.ApiKey; }); - return services.AddShared(userType, optionsBuilder, defaultScheme); + return services.AddPasswordlessIdentity(typeof(TUser), o => o.Configure(configure), null); } /// /// Adds the services to support /// - /// The current instance. - /// The to use to bind to . Generally it's own section. - /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IServiceCollection AddPasswordless(this IdentityBuilder builder, IConfiguration configuration) + public static IServiceCollection AddPasswordless( + this IServiceCollection services, + IConfiguration configuration) + where TUser : class, new() { - return builder.AddPasswordless(configuration, builder.UserType, IdentityConstants.ApplicationScheme); + services.AddPasswordlessSdk(configuration); + return services.AddPasswordlessIdentity(typeof(TUser), o => o.Bind(configuration), null); } /// /// Adds the services to support /// - /// The current instance. - /// The to use to bind to . Generally it's own section. - /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IServiceCollection AddPasswordless(this IdentityBuilder builder, IConfiguration configuration) + public static IServiceCollection AddPasswordless( + this IServiceCollection services, + string configurationSection) + where TUser : class, new() { - return builder.AddPasswordless(configuration, typeof(TUserType), null); + services.AddPasswordlessSdk(configurationSection); + return services.AddPasswordlessIdentity(typeof(TUser), o => o.BindConfiguration(configurationSection), null); } + /// + /// Adds the services to support + /// + /// The current instance. + /// Configures the . + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - private static IServiceCollection AddPasswordless(this IdentityBuilder builder, IConfiguration configuration, Type? userType, string? defaultScheme) + public static IdentityBuilder AddPasswordless( + this IdentityBuilder identity, + Action configure) { - var optionsBuilder = builder.Services - .AddOptions() - .Bind(configuration); + // Don't set up options here because we can't use the provided delegate as it's for a different type + identity.Services.AddPasswordlessSdk(_ => { }); + + // Derive core options from ASP.NET Core options + identity.Services.AddOptions() + .Configure>((options, aspNetCoreOptionsAccessor) => + { + var aspNetCoreOptions = aspNetCoreOptionsAccessor.Value; + options.ApiUrl = aspNetCoreOptions.ApiUrl; + options.ApiSecret = aspNetCoreOptions.ApiSecret; + options.ApiKey = aspNetCoreOptions.ApiKey; + }); - builder.Services.AddPasswordlessSdk(configuration); + identity.Services.AddPasswordlessIdentity( + identity.UserType, + o => o.Configure(configure), + IdentityConstants.ApplicationScheme + ); - return builder.Services.AddShared(userType ?? builder.UserType, optionsBuilder, IdentityConstants.ApplicationScheme); + return identity; } /// /// Adds the services to support /// - /// The current instance. - /// The configuration path to use to bind to . - /// The . + /// The current instance. + /// The to use to bind to . Generally it's own section. + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IServiceCollection AddPasswordless(this IdentityBuilder builder, string path) + public static IdentityBuilder AddPasswordless(this IdentityBuilder identity, IConfiguration configuration) { - return builder.AddPasswordless(path, builder.UserType, IdentityConstants.ApplicationScheme); + identity.Services.AddPasswordlessSdk(configuration); + + identity.Services.AddPasswordlessIdentity( + identity.UserType, + o => o.Bind(configuration), + IdentityConstants.ApplicationScheme + ); + + return identity; } /// /// Adds the services to support /// - /// The current instance. - /// The configuration path to use to bind to . - /// The . + /// The current instance. + /// The to use to bind to . Generally it's own section. + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - public static IServiceCollection AddPasswordless(this IdentityBuilder builder, string path) + public static IdentityBuilder AddPasswordless(this IdentityBuilder identity, IConfiguration configuration) { - return builder.AddPasswordless(path, typeof(TUserType), null); + identity.Services.AddPasswordlessSdk(configuration); + + identity.Services.AddPasswordlessIdentity( + typeof(TUserType), + o => o.Bind(configuration), + IdentityConstants.ApplicationScheme + ); + + return identity; } + /// + /// Adds the services to support + /// + /// The current instance. + /// The configuration path to use to bind to . + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - private static IServiceCollection AddPasswordless(this IdentityBuilder builder, string path, Type userType, string? defaultScheme) + public static IdentityBuilder AddPasswordless(this IdentityBuilder identity, string configurationSection) { - var optionsBuilder = builder.Services - .AddOptions() - .BindConfiguration(path); + identity.Services.AddPasswordlessSdk(configurationSection); - builder.Services.AddPasswordlessSdk(path); + identity.Services.AddPasswordlessIdentity( + identity.UserType, + o => o.BindConfiguration(configurationSection), + IdentityConstants.ApplicationScheme + ); - return builder.Services.AddShared(userType ?? builder.UserType, optionsBuilder, IdentityConstants.ApplicationScheme); + return identity; } + /// + /// Adds the services to support + /// + /// The current instance. + /// The configuration path to use to bind to . + /// The . [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] - private static IServiceCollection AddShared(this IServiceCollection services, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - Type userType, - OptionsBuilder optionsBuilder, - string? defaultScheme) + public static IdentityBuilder AddPasswordless(this IdentityBuilder identity, string configurationSection) { - if (!string.IsNullOrEmpty(defaultScheme)) - { - optionsBuilder.Configure(o => o.SignInScheme = defaultScheme); - } + identity.Services.AddPasswordlessSdk(configurationSection); - services.TryAddScoped( - typeof(IPasswordlessService), - typeof(PasswordlessService<>).MakeGenericType(userType)); + identity.Services.AddPasswordlessIdentity( + typeof(TUserType), + o => o.BindConfiguration(configurationSection), + IdentityConstants.ApplicationScheme + ); - services.TryAddScoped(); - - return services; + return identity; } } \ No newline at end of file diff --git a/src/Passwordless/ServiceCollectionExtensions.cs b/src/Passwordless/ServiceCollectionExtensions.cs index 8fd2164..8cedc7e 100644 --- a/src/Passwordless/ServiceCollectionExtensions.cs +++ b/src/Passwordless/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Passwordless; @@ -12,67 +11,62 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class ServiceCollectionExtensions { - /// - /// Adds and configures Passwordless-related services. - /// - public static IServiceCollection AddPasswordlessSdk( + private static IServiceCollection AddPasswordlessSdk( this IServiceCollection services, - Action configureOptions) + Action> configureOptions) { - services.AddOptions().Configure(configureOptions); - services.RegisterDependencies(); + // Options + configureOptions( + services.AddOptions() + ); + + // Client + services.AddHttpClient((http, sp) => + new PasswordlessClient(http, sp.GetRequiredService>().Value) + ); + + // TODO: Get rid of this service, all consumers should use the interface + services.AddTransient(sp => (PasswordlessClient)sp.GetRequiredService()); return services; } + /// + /// Adds and configures Passwordless-related services. + /// + public static IServiceCollection AddPasswordlessSdk( + this IServiceCollection services, + Action configureOptions) => + services.AddPasswordlessSdk(o => o.Configure(configureOptions)); + /// /// Adds and configures Passwordless-related services. /// #if NET6_0_OR_GREATER - [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] #endif #if NET7_0_OR_GREATER - [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("This method is incompatible with native AOT compilation.")] #endif public static IServiceCollection AddPasswordlessSdk( this IServiceCollection services, - IConfiguration configuration) - { - services.AddOptions().Configure(configuration.Bind); - services.RegisterDependencies(); - - return services; - } + IConfiguration configuration) => + services.AddPasswordlessSdk(o => o.Bind(configuration)); /// /// Adds and configures Passwordless-related services. /// /// - /// + /// /// #if NET6_0_OR_GREATER - [RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("This method is incompatible with assembly trimming.")] #endif #if NET7_0_OR_GREATER - [RequiresDynamicCode("This method is incompatible with native AOT compilation.")] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("This method is incompatible with native AOT compilation.")] #endif public static IServiceCollection AddPasswordlessSdk( this IServiceCollection services, - string section) - { - services.AddOptions().BindConfiguration(section); - services.RegisterDependencies(); - - return services; - } - - private static void RegisterDependencies(this IServiceCollection services) - { - services.AddHttpClient((http, sp) => - new PasswordlessClient(http, sp.GetRequiredService>().Value) - ); - - // TODO: Get rid of this service, all consumers should use the interface - services.AddTransient(sp => (PasswordlessClient)sp.GetRequiredService()); - } + string configurationSection) => + services.AddPasswordlessSdk(o => o.BindConfiguration(configurationSection)); } \ No newline at end of file diff --git a/tests/Passwordless.Tests/Passwordless.Tests.csproj b/tests/Passwordless.Tests/Passwordless.Tests.csproj index d95da88..b120d81 100644 --- a/tests/Passwordless.Tests/Passwordless.Tests.csproj +++ b/tests/Passwordless.Tests/Passwordless.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/Passwordless.Tests/ServiceRegistrationTests.cs b/tests/Passwordless.Tests/ServiceRegistrationTests.cs index f93d7ad..8cdb179 100644 --- a/tests/Passwordless.Tests/ServiceRegistrationTests.cs +++ b/tests/Passwordless.Tests/ServiceRegistrationTests.cs @@ -1,12 +1,69 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Passwordless.Tests; public class ServiceRegistrationTests { + [Fact] + public void I_can_register_a_client_with_options_derived_from_configuration() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([ + new KeyValuePair("Passwordless:ApiSecret", "foo"), + new KeyValuePair("Passwordless:ApiKey", "bar") + ]) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddPasswordlessSdk(configuration.GetSection("Passwordless")) + .BuildServiceProvider(); + + // Act + var options = services.GetRequiredService>(); + + // Assert + options.Value.ApiSecret.Should().Be("foo"); + options.Value.ApiKey.Should().Be("bar"); + } + + [Fact] + public void I_can_register_a_client_and_update_its_options_at_any_point_in_time() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([ + new KeyValuePair("Passwordless:ApiSecret", "foo"), + new KeyValuePair("Passwordless:ApiKey", "bar") + ]) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddPasswordlessSdk(configuration.GetSection("Passwordless")) + .BuildServiceProvider(); + + // Act + foreach (var provider in configuration.Providers) + { + provider.Set("Passwordless:ApiSecret", "baz"); + provider.Set("Passwordless:ApiKey", "zap"); + } + + // Assert + var options = services.GetRequiredService>(); + options.Value.ApiSecret.Should().Be("baz"); + options.Value.ApiKey.Should().Be("zap"); + } + [Fact] public async Task I_can_register_a_client_with_invalid_credentials_and_not_receive_an_error_until_it_is_used() { @@ -26,4 +83,27 @@ await Assert.ThrowsAnyAsync(async () => await passwordless.CreateRegisterTokenAsync(new RegisterOptions("user123", "User")) ); } + + [Fact] + public void I_can_register_a_client_and_retrieve_a_new_copy_for_each_scope() + { + // Arrange + var services = new ServiceCollection() + .AddPasswordlessSdk(o => + { + o.ApiSecret = "foo"; + o.ApiKey = "bar"; + }) + .BuildServiceProvider(); + + // Act + using var scope1 = services.CreateScope(); + var client1 = scope1.ServiceProvider.GetRequiredService(); + + using var scope2 = services.CreateScope(); + var client2 = scope2.ServiceProvider.GetRequiredService(); + + // Assert + client1.Should().NotBeSameAs(client2); + } } \ No newline at end of file