Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to reload AWSOptions after ASP.NET Core Lambda Startup #210

Closed
IgorPietraszko opened this issue Feb 15, 2022 · 19 comments
Closed

Ability to reload AWSOptions after ASP.NET Core Lambda Startup #210

IgorPietraszko opened this issue Feb 15, 2022 · 19 comments
Labels
guidance Question that needs advice or information. module/cog-id-provider

Comments

@IgorPietraszko
Copy link

The Question

I am enhancing an existing ASP.NET Core Lambda solution to add multi-tenant support. Our multitenant setup would use the same codebase (front-end, ANguar, oaded from S3 buckets and back-end, .NET Lambdas) but separate database (either by schema, physical database or physical cluster - depnding on the client).

Per tenant requests would be recognized by incoming host since each client would have their own domain (or be mapped to one of our tenant-specific subdomains). Configuration for our application resides in AWS Parameter Store and would be composed of a Common section (/myapplication/Commmon) which would contain mappings between Hosts and Logical Tenant Names. In addition, there would be tenant-specific sections, for each configured tenant, that would contain tenant-specific application configuration values (/myapplication/tenantA/, /myapplication/tenantB/).

Processing piepeline in the ASP.NET Core 3.1 application would contain custom middleware which would do the initial mapping of Host to Tenant and then load the appropriate tenant specific configuration section.
I have tried it out in a local environment and al works great. Next step is to install in in AWS and make it work which I doubt should be an issue.

One problem I have noticed is that AWSOptions are loaded in the ConfigureServices section of my ASP.NET Core app while our middleware does not run until the next Configure section. AWSOptions are loaded from "the base" configuration "key (e.g. /myapplication/AWS/UserPoolClientId) and while I have tried to reload it from a tenant-specific section using this construct:

configuration.Bind($"{tenantName}:AWS", awsOptions.Value);

But it does not seem to work.

My general question is whether there is a way to reload AWSOptions to support different Congito (and other AWS service) on a per-client/tenant basis rather than relying on the AWSOptions configured at the "base" level. Alternatively, is there a way to lazily configure Congito (or other AWS Services) to delay configuration until the client is known.

Environment

  • Build Version: 2.2.2
  • OS Info: Compiled on Ubuntu 20.04, runtime set to linux-x64
  • Build Environment: Azure pipelines (pool Ubuntu 20.04, runtime set to linux-x64)
  • Targeted .NET Platform: ASP.NET Core 3.1

This is a ❓ general question

@IgorPietraszko IgorPietraszko added guidance Question that needs advice or information. needs-triage This issue or PR still needs to be triaged. labels Feb 15, 2022
@ashishdhingra
Copy link
Contributor

ashishdhingra commented Feb 16, 2022

Hi @IgorPietraszko,

Good afternoon.

Thanks for posting guidance question. Could you please share the sample code snippet to investigate further? As far as I know, there isn't an ability to reload AWSOptions in Amazon.AspNetCore.Identity.Cognito package. However, you can take some guidance from Amazon.Extensions.Configuration.SystemsManager which supports configuration reload, if a change in Systems Manager config is detected.

Thanks,
Ashish

@ashishdhingra ashishdhingra added response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. and removed needs-triage This issue or PR still needs to be triaged. labels Feb 16, 2022
@ppittle
Copy link
Member

ppittle commented Feb 16, 2022

Are you trying to persist AWS Service Clients between requests? It you're setting up a Multi Tenant Lambda function, where the Cognito Client needs to be configured differently based on request data (ie headers etc), why not recreate the Cognito Client at the beginning of every request?

public APIGatewayProxyResponse Get(APIGatewayProxyRequest request, ILambdaContext context)
{
     AmazonCognitoIdentityConfig config = LoadTenantSpecificCognitoConfi(request, context);
     var client = new AmazonCognitoIdentityClient(config);

     // perform Tenant specific work
}

Alternatively, you could implement your own Client Factory to externalize some of the logic outside of your Lambda class. Something like this may work:

public interface IMultiTenantCognitoClient
{
    AmazonCognitoIdentiyClient GetCognitoClient(APIGatewayProxyRequest request, ILambdaContext context)
}

public class MultiTenantCognitoClient : IMultiTenantCognitoClient
{
     // optional
     private readonly AWSOptions _options;

     public MultiTenantCognitoClient(AWSOptions) { _options = options; }

     public AmazonCognitoIdentiyClient GetCognitoClient(APIGatewayProxyRequest request, ILambdaContext context)
     {
          // examine _options, request, and context to build a tenant specific AmazonCognitoIdentityConfig 
          var config = ...

         return new AmazonCognitoIdentityClient(config);
     }
}

You can then setup the binding for IMultiTenantCognitoClient and then construct inject a IMultiTenantCognitoClient into your Lambda function.

@IgorPietraszko
Copy link
Author

I am using the LambdaEntryPoint class inheriting from APIGatewayProxyFunction as the entry point. This is what runs first and this is where I set up SystemsManager to access the config from two place: 1) /myapplication/Common and 2) /myapplication/<environment_name>. <environment_name> is set up as an Environment Variable on the lambda which allows SystemsManager to support multiple instances of the same Lambda as long as each is configured with a different <environment_name>.

public class LambdaEntryPoint : Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
{
    protected override void Init(IWebHostBuilder builder)
    {
        builder
            .ConfigureLogging(logging => logging.AddLambdaLogger(loggerOptions))
            .ConfigureAppConfiguration((context, config) =>
            {
                var env = context.HostingEnvironment;

                config.AddSystemsManager($"/myapplication/common");
                config.AddSystemsManager($"/myapplication/{env.EnvironmentName}", 
                    optional: false, 
                    reloadAfter: TimeSpan.FromMinutes(5));
            })
            .UseStartup<Startup>();
    }
    protected override void Init(IHostBuilder builder)
    {
    }
}

Next, Startup() function runs which first configures services in ConfigureServices() and then configures the application in Configure(). AWS specific services like Cognito are added in the ConfigureServices() method.

I make a decision about calling tenants based on the incoming Request's Host which is not resolved until the Configure() method. ConfigureServices() method sets up an empty TenantOption class which is then populated by Middleware invoked from Configure() method based on the Request's Host and mapped to an appropriate section in the SystemsManager (e.g. /myapplication/<environment_name/tenantA). Here are the relevant code pieces:

public class Startup
{
    public Startup(IConfiguration configuration, IWebHostEnvironment environment) 
        : base(configuration, environment) { }

    public void ConfigureServices(IServiceCollection services)
    {
        // Set up Configuration with an empty TenantOptions, to be populated in ResolveMutitenantMiddleware
        services.Configure<TenantOptions>(t => new TenantOptions());

        var awsOption = Configuration.GetAWSOptions("AWS");

        services.AddDefaultAWSOptions(awsOption);
        services.AddAWSService<IAmazonSimpleEmailService>();

        services.AddCognitoIdentity();
        services.AddTransient<CognitoSignInManager<CognitoUser>>();
        services.AddTransient<CognitoUserManager<CognitoUser>>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env,
        ApplicationDbContext dbContext, ILogger<Startup> logger, IErrorReporter errorReporter)
    {
        // To run in multitenant setup, we need to extract the host
        app.UseMultitenantSetup();
    }
}

Below code shows how I map configuration sections to TenantOptions based on the incoming Request:

public class ResolveMutitenantMiddleware
{
    private readonly RequestDelegate _next;

    public ResolveMutitenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(
        HttpContext context, 
        IConfiguration configuration, 
        IOptions<TenantOptions> tenantOptions,
        IOptions<AWSOptions> awsOptions)
    {
        // get the host from the request
        var host = context.Request.Host;

        // extract host to tenant mapping from config
        var commonConfigSection = configuration.GetSection("Common");
        // grab tenant name based on the host
        var tenantName = commonConfigSection.GetValue<string>(host.Value);
        // bind TenantOptions to tenant specific config section
        configuration.Bind(tenantName, tenantOptions.Value);
        // re-bind AWSOptions to tenant specific ones - does not work!
        configuration.Bind($"{tenantName}:AWS", awsOptions.Value);

        await _next(context);
    }
}

So rest of my code relying on TenantOptions works fine since configuration values in TenantOptions are not used until needed later in the request pipeline where I can inject them. However it seems that AWS services configured in the ConfigureServices() method cannot be re-jigged to load their configuration from a section other than /myapplication/<environment_name>/AWS (e.g. /myapplication/<environment_name>/tenantA/AWS) nor can I repopulate AWSOptions and "reset" all AWS Service to re-configure themselves based on the new options.

Hope this sheds more light on my dilemma.

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Feb 17, 2022
@ppittle
Copy link
Member

ppittle commented Feb 25, 2022

Your question makes a lot more sense now, thanks for the details; and what an intersting problem.

You are correct that once a AWS Service is created it's not possible to influence the Service by mutating theAWSOptions. The ClientFactory converts AWSOptions to a Service specific Config class as part of constructing the Service and doesn't keep any reference to AWSOptions.

Instead, we can borrow from aws/aws-sdk-net#1957 and late build AWSOptions via a Factory AND then control the factory via Middleware.

I've created a small proof-of-concept that should have everything you need.

Create a helper class:

/// <summary>
/// DI Helping that allows Middleware to set a function for late binding <see cref="AWSOptions"/>.
/// NOTE:  Binding for <see cref="IAWSOptionsFactory"/> as well as any services that want to consume it
/// need to use <see cref="ServiceLifetime.Scoped"/>
/// </summary>
public interface IAWSOptionsFactory
{
    Func<IServiceProvider, AWSOptions> AWSOptionsBuilder { get; set; }
}

public class AWSOptionsFactory : IAWSOptionsFactory
{
    public Func<IServiceProvider, AWSOptions> AWSOptionsBuilder { get; set; }
}

Then in Startup

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public static IConfiguration Configuration { get; private set; }

    // This method gets called by the runtime. Use this method to add services to the container
    public void ConfigureServices(IServiceCollection services)
    {
        // note: it is not necessary to use AddDefaultAWSOptions().
    
        // note: AWSOptionsFactory.AWSOptionsBuilder func will be populated in middleware
        services.AddScoped<IAWSOptionsFactory, AWSOptionsFactory>();
        services.Add(new ServiceDescriptor(typeof(AWSOptions), sp => sp.GetService<IAWSOptionsFactory>().AWSOptionsBuilder(sp), ServiceLifetime.Scoped));
        services.AddAWSService<IAmazonSimpleEmailServiceV2>(lifetime: ServiceLifetime.Scoped);
		// can register multiple Services that will all consume AWSOptions from the IAWSOptionsFactory
        services.AddAWSService<IAmazonAthena>(lifetime: ServiceLifetime.Scoped);

        services.AddControllers();
    }
    
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // boiler plate config removed from example for brevity.
        
        app.UseMiddleware<LateBindingAWSOptionsMiddleware>();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }

Then in the middleware:

public class LateBindingAWSOptionsMiddleware
{
    private readonly RequestDelegate _next;

    public LateBindingAWSOptionsMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    /// <remarks>
    /// If you want to consume a global AWSOptions add a IOptions<AWSOptions> method parameter AND
    /// call servies.AddDefaultAWSOptions() in the Startup.ConfigureServices method.
    /// </remarks>
    public async Task InvokeAsync(
        HttpContext context,
        IConfiguration configuration,
        IAWSOptionsFactory optionsFactory)
    {
        
        // create AWS Options specific to this request:
        
        optionsFactory.AWSOptionsBuilder = sp =>
        {
            var awsOptions = new AWSOptions();

            // SAMPLE: to prove we can configure AWSOptions based on HttpContext,
            // get the region endpoint from the query string
            if (context.Request.Query.TryGetValue("regionEndpoint", out var regionEndpoint))
            {
                awsOptions.Region = RegionEndpoint.GetBySystemName(regionEndpoint);
            }

            return awsOptions;
        };

        await _next(context);
    }
}

Finally, there is no changes in the Controller, it can constructor inject Service Clients as normal:

[Route("api/[controller]")]
public class ValuesController : ControllerBase
{
    private readonly IAmazonSimpleEmailServiceV2 _amazonSimpleEmailService;

    public ValuesController(IAmazonSimpleEmailServiceV2 amazonSimpleEmailService)
    {
        _amazonSimpleEmailService = amazonSimpleEmailService;
    }

    // GET api/values
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new string[]
        {
            _amazonSimpleEmailService.Config.RegionEndpoint.DisplayName
        };
    }
}

To see this working:

Relative Url Result
/api/Values?regionEndpoint="us-east-1" ["US East (N. Virginia)"]
/api/Values?regionEndpoint="us-east-2" ["US East (Ohio)"]
/api/values?regionEndpoint=eu-west-1 ["Europe (Ireland)"]

@ppittle
Copy link
Member

ppittle commented Feb 25, 2022

Interested to hear if this works for your use case or if you have any follow up questions.

@ppittle ppittle added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Feb 25, 2022
@IgorPietraszko
Copy link
Author

Looks very promising...trying it out...

@IgorPietraszko
Copy link
Author

Getting a NullReferenceException on the highlighted line - not sure why...

            //var awsOption = Configuration.GetAWSOptions("AWS");

            //services.AddDefaultAWSOptions(awsOption);

            // note: AWSOptionsFactory.AWSOptionsBuilder func will be populated in middleware
            services.AddScoped<IAWSOptionsFactory, AWSOptionsFactory>();
            -> services.Add(new ServiceDescriptor(typeof(AWSOptions), sp => sp.GetService<IAWSOptionsFactory>().AWSOptionsBuilder(sp), ServiceLifetime.Scoped));

            services.AddAWSService<IAmazonSimpleEmailService>();

            services.AddCognitoIdentity();
            services.AddTransient<CognitoSignInManager<CognitoUser>>();
            services.AddTransient<CognitoUserManager<CognitoUser>>();

I am not familiar with ServiceDescriptor() - may need to read up on that. But the concept of a delayed provision of AWSOptions looks promising.

@ppittle
Copy link
Member

ppittle commented Feb 25, 2022

This is just a guess without your full codebase, but I'm guessing IAWSOptionsFactory.AWSOptionsBuilder is what's null, so trying to invoke it (as it's a Func<>) will result in a Null Reference Exception.

In my example, it's the Middleware that populates IAWSOptionsFactory.AWSOptionsBuilder.

What this means is, if the Service Collection needs to construct an AWSOptions in the current Scope before the middleware has populated IAWSOptionsFactory.AWSOptionsBuilder, then you're going to get an Exception.

You should have a couple options depending on how you're initializing your application and what is trying to take a dependency on AWSOptions. You might be able to use serivces.AddDefaultAWSOptions() to define a Singleton scoped AWSOptions. Or you might need to create a custom Scope just for AWS Services created after your multi tenant middlware: https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#scope-scenarios

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Feb 26, 2022
@IgorPietraszko
Copy link
Author

Thanks for your input. Will read up on Scope Scenarios and see where I get.

@ashishdhingra ashishdhingra added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Feb 28, 2022
@IgorPietraszko
Copy link
Author

So I made small progress to see that what is happening is more or less what you have described. I am getting this exception now:

      Connection id "0HMFRRGHM3OQV", Request id "0HMFRRGHM3OQV:0000001B": An unhandled exception was thrown by the application.
System.InvalidOperationException: Cannot resolve scoped service 'Amazon.Extensions.NETCore.Setup.AWSOptions' from root provider.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteValidator.ValidateResolution(Type serviceType, IServiceScope scope, IServiceScope rootScope)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.Microsoft.Extensions.DependencyInjection.ServiceLookup.IServiceProviderEngineCallback.OnResolve(Type serviceType, IServiceScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T](IServiceProvider provider)
   at Amazon.Extensions.NETCore.Setup.ClientFactory.CreateServiceClient(IServiceProvider provider)

I guess one of my dependencies relies on a AWS service which is being created using CreateServiceClient():

        internal object CreateServiceClient(IServiceProvider provider)
        {
            var loggerFactory = provider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
            var logger = loggerFactory?.CreateLogger("AWSSDK");

            var options = _awsOptions ?? provider.GetService<AWSOptions>();
            if(options == null)
            {
                var configuration = provider.GetService<IConfiguration>();
                if(configuration != null)
                {
                    options = configuration.GetAWSOptions();
                    if (options != null)
                        logger?.LogInformation("Found AWS options in IConfiguration");
                }
            }

            return CreateServiceClient(logger, _serviceInterfaceType, options);
        }

which in turn is trying to get AWSOptions from the provider. Now, I am still a bit unclear on Scope Scenarios you have referred to but believe that provider referenced above exists in a different scope than AWSOptions I inject through the middleware. I also don't know which AWS Service is being created here. So still digging...

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 3, 2022
@ashishdhingra ashishdhingra added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 7, 2022
@github-actions
Copy link

github-actions bot commented Mar 8, 2022

This issue has not received a response in 5 days. If you want to keep this issue open, please just leave a comment below and auto-close will be canceled.

@github-actions github-actions bot added the closing-soon This issue will automatically close in 4 days unless further comments are made. label Mar 8, 2022
@IgorPietraszko
Copy link
Author

Yes, I would like to keep this issue open.

@github-actions github-actions bot removed closing-soon This issue will automatically close in 4 days unless further comments are made. response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. labels Mar 9, 2022
@IgorPietraszko
Copy link
Author

Browsing through aws-aspnet-cognito-identity-provider, I noticed that CognitoUserPool is added to the DI container as a Singleton. Looking at this article (https://blog.steadycoding.com/using-singletons-in-net-core-in-aws-lambda/) I have a question about statefullness of Singletons in AWS Lambda?
Above mentioned article suggest that a container (and thus Singletons) would servie a warm start and thus the same instance of the CognitouserPool would be reused between invocations?
The reason I am asking that is that I have played with the idea of adding AWSCognitoClientOptions as a singletone:

services.AddSingleton(t => new AWSCognitoClientOptions());

and then mutating it in my tenant resolution code:

var awsCognitoClientOptions = context.RequestServices.GetService<AWSCognitoClientOptions>();
// re-bind AWSCognitoClientOptions to tenant specific ones
configuration.Bind($"{tenantName}:AWS", awsCognitoClientOptions);

This allows me to have AWSCognitoClientOptions populated from a "general" config section and then mutated/changed to a tenant-specific configuration. This seems to be working locally but have to test it in lambda. The premise of this "solution" is that every tenant's request would get a new "instance" of "execution environment" with a brand new DI container and its own Singleton instance of CognitoUserPool.

@ashishdhingra ashishdhingra added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 22, 2022
@IgorPietraszko
Copy link
Author

IgorPietraszko commented Mar 22, 2022

I think I have a solution that does not require me messing around with how Cognito is declared in DI. This (https://referbruv.com/blog/posts/implementing-cognito-user-login-and-signup-in-aspnet-core-using-aws-sdk) solution does a "roll your own" authentication and thus instantiates CognitoUserPool and CognitoIdentityManager as a "hardcoded" dependency of a Scoped service called UserRepository, which in my case, would delay instantiation of these classes until I know which tenant the current request is for.

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 23, 2022
@ashishdhingra
Copy link
Contributor

I think I have a solution that does not require me messing around with how Cognito is declared in DI. This (https://referbruv.com/blog/posts/implementing-cognito-user-login-and-signup-in-aspnet-core-using-aws-sdk) solution does a "roll your own" authentication and thus instantiates CognitoUserPool and CognitoIdentityManager as a "hardcoded" dependency of a Scoped service called UserRepository, which in my case, would delay instantiation of these classes until I know which tenant the current request is for.

@IgorPietraszko Thanks for your response. Does this resolves your issue? Feel free to post the solution here for reference.

@ashishdhingra ashishdhingra added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 23, 2022
@IgorPietraszko
Copy link
Author

@ashishdhingra Its not a resolution but rather a workaround. I would still like, if possible, for someone on your or .NET/lambda team to elaborate on the question of Dependency Injection, Singletons and whether their instances survive between requests (warm startup) as is the case with CognitoUserPool which is added to DI as a Singleton. While there are some articles online from the point of view of Function invocation, I am specifically interested with ASP.NET (3.1 Core) Web API invocation when it runs in AWS Lambda.

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Mar 24, 2022
@IgorPietraszko
Copy link
Author

In the above-mentioned workaround, I am running into a an issue trying to ListUsersAsync(). Getting an exception on Invalid Security Token.

@ppittle
Copy link
Member

ppittle commented Mar 29, 2022

Lambda & Singletons

Yes, Lambda MAY keep a recently used instance to serve the next incoming request. This is known as a Warm Start and allows the lambda function to respond much quicker.

In the case of ASP.NET, this means the Web Host persists between requests. As does the IServiceCollection that is populated in Startup.ConfigureServices. Anything added as a Singleton in the initial request will therefor survive into subsequent requests.

You can see this by adding a Singleton binding in Startup

public class SingletonModel
{
    public string Data { get; set; }
}

public void ConfigureServices(IServiceCollection services)
{          
    services.AddSingleton<SingletonModel>(new SingletonModel{Data = $"Created on {DateTime.Now:s}"});
}

Then update the API Controller to inject SingletonModel and output it's hashcode.

By comparing the Hash Code to a Scoped Service, we can see that the Singleton stays the same between requests while the Scoped doesn't

public class ValuesController : ControllerBase
{
    private readonly IAmazonSimpleEmailServiceV2 _amazonSimpleEmailService;
    private readonly SingletonModel _singletonModel;

    public ValuesController(
        IAmazonSimpleEmailServiceV2 amazonSimpleEmailService, 
        SingletonModel singletonModel)
    {
        _amazonSimpleEmailService = amazonSimpleEmailService;
        _singletonModel = singletonModel;
    }

    // GET api/values
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new string[]
        {
            _amazonSimpleEmailService.GetHashCode().ToString(),
            $"Controller Hashcode: {GetHashCode()}",
            $"SingletonModel: [Hash: {_singletonModel.GetHashCode()}, Data: {_singletonModel.Data}]"
        };
    }
}
Requet Result
1 ["5894231","Controller Hashcode: 53048087","SingletonModel: [Hash: 56034750, Data: Created on 2022-03-29T04:06:41]"]
2 ["654914","Controller Hashcode: 8060118","SingletonModel: [Hash: 56034750, Data: Created on 2022-03-29T04:06:41]"]
3 ["37355470","Controller Hashcode: 5432205","SingletonModel: [Hash: 56034750, Data: Created on 2022-03-29T04:06:41]"]

Be careful with Singletons

Do be careful when deciding to bind a dependency as Singleton, as they can survive beyond servicing a single request. Additionally, asp.net in general is capable of servicing multiple requests concurrently, so Singletons need to be threadsafe. If your request pipeline relies on mutating Singleton you could open yourself up to a race condition where two concurrent requests could interfere with each other's state.

UserRepository Solution

You referenced the blog post https://referbruv.com/blog/posts/implementing-cognito-user-login-and-signup-in-aspnet-core-using-aws-sdk implementing a UserRepository class that doubles as a Factory, building a AmazonCognitoIdentityProviderClient. Sounds like you were able to adapt this pattern to meet your needs. I'd imagine, something like:

public class AmazonCognitoIdentityProviderFactory
{
    private readonly IConfiguration _configuration;

    public AmazonCognitoIdentityProviderFactory(IConfiguration config){
       _configuration = config;
    }

    public IAmazonCognitoIdentityProvider BuildCognitoClient(string host)
    {
        // extract host to tenant mapping from config
        var commonConfigSection = configuration.GetSection("Common");
        // grab tenant name based on the host
        var tenantName = commonConfigSection.GetValue<string>(host.Value);

        var tenantOptions = new TenantOptions();
        // bind TenantOptions to tenant specific config section
        configuration.Bind(tenantName, tenantOptions);
        
        // copy tenantOptions to AmazonCognitoIdentityProviderConfig 
        var config = new AmazonCognitoIdentityProviderConfig 
        {
            RegionEndpoint = tenantOptions.RegionEndpoint;
           // etc
        }
        return new AmazonCognitoIdentityProviderClient(config);
    }
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {      
           // note: in real application would recommend using a backing interface
            services.AddSingleton<AmazonCognitoIdentityProviderFactory>();
    }
}

Then your API Controller methods would take the extra step of using the factory:

public class ExampleController : ControllerBase
{
    private readonly AmazonCognitoIdentityProviderFactory _cognitoFactory;

     public ExampleController(AmazonCognitoIdentityProviderFactory cognitoFactory)
     {
          _cognitoFactory = cognitoFactory;
     }
    
      [HttpGet]
      public IEnumerable<string> Get()
      {
             var cognitoClient = _cognitoFactory.BuildCognitoClient(Request.Host);
      }
}

This solution has a bit more overhead if you want to use multiple Services in so far as you'll either up creating a Factory per service, or adjusting your Factory to create multiple Services in one method go. However, it does have the advantage of not needing to carefully control the lifecycle of an AWSOptions object. Which might be a worthwhile trade off if other pieces of code wanted to use AWSOptions before your original Middleware could execute. (My suspicion is Systems Manager might be trying to use AWS Options, but I haven't tracked it down to confirm).

Error using ListUsersAsync

I am running into a an issue trying to ListUsersAsync(). Getting an exception on Invalid Security Token.

Without a bit more context, I can't really help diagnose that 😄 .

How did you end up building your AmazonCognitoIdentityProviderClient and did you verify it was getting the correct configuration?

@IgorPietraszko
Copy link
Author

My solution is to plagiarize this code (https://github.com/aws/aws-aspnet-cognito-identity-provider/blob/master/src/Amazon.AspNetCore.Identity.Cognito/Extensions/CognitoServiceCollectionExtensions.cs) and changed the default ServiceLifetime to Scoped from Singleton. This should allow me to get a new instance of CognitoUserPool for every request thus differentiating between tenants (as each would be associated with a separate request).
Does it pose a problem that the CognitoKeyNormalizer and CognitoIdentityErrorDescriber (lines 70 and 71) are added as Singletons?

@aws aws locked and limited conversation to collaborators Apr 5, 2022
@ashishdhingra ashishdhingra converted this issue into discussion #219 Apr 5, 2022

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
guidance Question that needs advice or information. module/cog-id-provider
Projects
None yet
Development

No branches or pull requests

3 participants