TimKaechele
TimKaechele

Reputation: 530

Configuring asp.net core identity oauth2 authentication with user provided oauth2 credentials

I build an application that extends the gitlab user experience and admins of an organization (Organizations are the tenants in the system) can configure their gitlab installation (register an OAuth2 application in their gitlab instance) and normal users in an organization can just authenticate themselves with their gitlab account via OAuth2.

My problem at the moment is, the credentials (oauth2 client id and client secret, as well as the base url) are provided by the organization admin and are stored in the database. I want to give every organization its own subdomain and the Sign In with Gitlab button should redirect the user to their gitlab instance and follow the usual oauth2 flow for authentication, but I can't figure out how to configure the asp.net core identity framework to decide on the fly (based on the subdomain) which credentials to use for the oauth2 flow. All tutorials and microsoft provided documentations assume that you only have one "hard coded" oauth2 provided (usually configured in the ConfigureServices method of the Startup class).

My current implementation follows the microsoft provided documentation and looks like this:

public void ConfigureServices(IServiceCollection services) 
{      
  // ... 
  services.AddAuthentication(options =>
  {
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "Gitlab";
  }).AddCookie()
    .AddOAuth("Gitlab", options =>
    {
      options.ClientId = Configuration["Gitlab:ClientId"];
      options.ClientSecret = Configuration["Gitlab:ClientSecret"];
      options.CallbackPath = new Microsoft.AspNetCore.Http.PathString("/signin-gitlab");

      options.AuthorizationEndpoint = Configuration["Gitlab:BaseUrl"] + "/oauth/authorize";
      options.TokenEndpoint = Configuration["Gitlab:BaseUrl"] + "/oauth/token";

      options.UserInformationEndpoint = Configuration["Gitlab:BaseUrl"] + "/api/v4/user";
      options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
      options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");

      options.ClaimActions.MapJsonKey("gitlab:avatar_url", "avatar_url");
      options.ClaimActions.MapJsonKey("gitlab:profile_url", "web_url");

      options.SaveTokens = true;

      options.Events = new OAuthEvents
      {
        OnCreatingTicket = async context =>
              {
              var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
              request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
              request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);

              var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
              response.EnsureSuccessStatusCode();

              var user = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());

              context.RunClaimActions(user);
            }
      };
    });
}

How can I implement such a system?

Upvotes: 2

Views: 1357

Answers (1)

Dejan Janjušević
Dejan Janjušević

Reputation: 3230

The OAuth handler uses the options pattern for configuration, which means you can utilize it to set properties such as ClientId, ClientSecret, etc, dynamically, on per-request basis, based on request properties.

You need to do the following (please bear with any compile problems, I used it with different options so writing this mostly from my head):

  1. Modify the ConfigureServices body as follows:
public void ConfigureServices(IServiceCollection services) 
{      
  // ... 
  services.AddAuthentication(options =>
  {
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "Gitlab";
  }).AddCookie()
    .AddOAuth("Gitlab", delegate { }); // Don't specify hard coded OAuth options. Instead, you will return them from an options provider.

  services.AddTransient<TenantResolver>();
  services.AddSingleton<OAuthOptionsCacheAccessor>();
  services.AddTransient<IConfigureNamedOptions<OAuthOptions>, OAuthOptionsInitializer>();
  services.AddTransient<IOptionsMonitor<OAuthOptions>, OAuthOptionsProvider>();
}
  1. Implement your tenant resolution logic based on the incoming request and register it to DI. For example:
public class TenantResolver // don't forget to register this to DI in ConfigureServices
{
    private readonly IHttpContextAccessor httpContextAccessor;

    public TenantAuthorityResolver(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    public string GetCurrentTenant()
    {
        // TODO: Read the current request from httpContextAccessor.HttpContext.Request 
        // and parse it to resolve the current tenant Id based on your own logic
    }
}
  1. Use the cache to store your instances of OAuthOptions and register it to DI as singleton. I used the ConcurrentDictionary like this:
public class OAuthOptionsCacheAccessor // register to DI as singleton
{
    public ConcurrentDictionary<(string name, string tenant), Lazy<OAuthOptions>> Cache =>
        new ConcurrentDictionary<(string, string), Lazy<OAuthOptions>>();
}
  1. Implement the options initializer which will return the correct OAuthOptions instance based on the resolved tenant, and register this class to DI as a transient dependency.
public class OAuthOptionsInitializer : IConfigureNamedOptions<OAuthOptions> // register as transient
{
    private readonly IDataProtectionProvider dataProtectionProvider;
    private readonly TenantResolver tenantResolver;

    public OAuthOptionsInitializer(
        IDataProtectionProvider dataProtectionProvider,
        TenantResolver tenantResolver)
    {
        this.dataProtectionProvider = dataProtectionProvider;
        this.tenantResolver = tenantResolver;
    }

    public void Configure(string name, OAuthOptions options)
    {
        if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.Ordinal))
        {
            return;
        }

        var tenant = tenantResolver.GetCurrentTenant();

        // TODO: You will probably want to save your per-tenant OAuth options 
        // in the database or somewhere, so now is the time to obtain those.
        // I also recommend using Nito.AsyncEx to be able to safely call async methods from here
        var savedOptions = Nito.AsyncEx.AsyncContext.Run(async () => await GetSavedOptions(tenant));

        options.ClientId = savedOptions.ClientId;
        options.ClientSecret = savedOptions.ClientSecret;
        options.CallbackPath = new Microsoft.AspNetCore.Http.PathString("/signin-gitlab");

        options.AuthorizationEndpoint = savedOptions.BaseUrl + "/oauth/authorize";
        options.TokenEndpoint = savedOptions.BaseUrl + "/oauth/token";

        options.UserInformationEndpoint = savedOptions.BaseUrl + "/api/v4/user";
        options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
        options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");

        options.ClaimActions.MapJsonKey("gitlab:avatar_url", "avatar_url");
        options.ClaimActions.MapJsonKey("gitlab:profile_url", "web_url");

        options.SaveTokens = true;

        options.Events = new OAuthEvents
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
                request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);

                var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();

                var user = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());

                context.RunClaimActions(user);
           }
        };
    }

    public void Configure(OpenIdConnectOptions options)
        => Debug.Fail("This infrastructure method shouldn't be called.");
}
  1. And finally, implement the OAuth options provider and register it to DI as transient:
public class OAuthOptionsProvider : IOptionsMonitor<OAuthOptions>
{
    private readonly OAuthOptionsCacheAccessor cacheAccessor;
    private readonly IOptionsFactory<OAuthOptions> optionsFactory;
    private readonly TenantResolver tenantResolver;

    public OAuthOptionsProvider(
        IOptionsFactory<OAuthOptions> optionsFactory,
        TenantResolver tenantResolver,
        OAuthOptionsCacheAccessor cacheAccessor)
    {
        this.cacheAccessor = cacheAccessor;
        this.optionsFactory = optionsFactory;
        this.tenantAuthorityResolver = tenantAuthorityResolver;
    }

    public OAuthOptions CurrentValue => Get(Options.DefaultName);

    public OAuthOptions Get(string name)
    {
        var tenant = tenantResolver.GetCurrentTenant();

        Lazy<OAuthOptions> Create() => new Lazy<OAuthOptions>(() => optionsFactory.Create(name));
        return cacheAccessor.Cache.GetOrAdd((name, tenant), _ => Create()).Value;
    }

    public IDisposable OnChange(Action<OAuthOptions, string> listener) => null;
}

And not to forget, I want to attribute the original answer for this idea: https://stackoverflow.com/a/52977687/828023

Upvotes: 2

Related Questions