Reputation: 530
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
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):
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>();
}
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
}
}
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>>();
}
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.");
}
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