I have a dozen or so ASP.NET Core applications that must implement the same core authentication logic. In broad strokes I need to...
Rather than repeat myself a dozen times I would like to use a common Authentication library to contain the common logic and allow applications to configure the few options that would be unique to them, such as the cookie name. Here is what I have so far and seems to be working.
// MyAuthenticationExtensions.cs
public class MyCookieAuthenticationOptions
{
// cookie only options
public string CookieName { get; set; }
// oauth options
public string ClientId { get; set; }
public string ClientSecret { get; set; }
// misc options
public bool IsDevelopment { get; set; } = false;
}
public static class MyAuthenticationExtensions
{
private const string _KeyVaultKey = "KeyVaultName";
private const string _PortalUrlKey = "Global:PortalUrl";
private const string _DataProtectionKeyLocationKey = "Global:DataProtection:KeyLocation";
private const string _DataProtectionKeyIdentifierKey = "Global:DataProtection:KeyIdentifier";
public static IServiceCollection AddMyCookieAuthentication(
this IServiceCollection services,
Action<MyCookieAuthenticationOptions> configureOptions,
IConfiguration configuration,
DefaultAzureCredential credential)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(credential);
var theOptions = new MyCookieAuthenticationOptions();
configureOptions.Invoke(theOptions);
if (!theOptions.IsDevelopment)
{
var keyVault = configuration[_KeyVaultKey];
var dpKeyIdentifier = configuration[_DataProtectionKeyIdentifierKey];
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(
configuration[_DataProtectionKeyLocationKey]))
.ProtectKeysWithAzureKeyVault(
new Uri($"https://{keyVault}.vault.usgovcloudapi.net/keys/{dpKeyIdentifier}"),
credential);
}
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.Cookie.Name = theOptions.CookieName;
if (theOptions.IsDevelopment)
{
options.Cookie.Path = "/";
}
options.Events.OnRedirectToLogin = (ctx) =>
{
// Override ApiControllers to return a 401 and not redirect
// https://github.com/dotnet/aspnetcore/issues/9039
if (ctx.Request.Path.StartsWithSegments("/api"))
{
ctx.Response.Headers.Location = ctx.RedirectUri;
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
else
{
ctx.Response.Redirect(ctx.RedirectUri);
}
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = (ctx) =>
{
// Override ApiControllers to return a 401 and not redirect
// https://github.com/dotnet/aspnetcore/issues/9039
if (ctx.Request.Path.StartsWithSegments("/api"))
{
ctx.Response.Headers.Location = ctx.RedirectUri;
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
}
else
{
ctx.Response.Redirect(ctx.RedirectUri);
}
return Task.CompletedTask;
};
options.Events.OnSigningOut = (ctx) =>
{
// Revoke the user's refresh token on signout
return Task.Run(async () =>
{
var clientId = theOptions.ClientId;
var token = await ctx.HttpContext.GetTokenAsync("refresh_token");
var revokeTokenParams = new FormUrlEncodedContent(
new Dictionary<string, string> {
{ "client_id", clientId },
{ "auth_token", token },
{ "f", "json" }
});
var baseUri = configuration[_PortalUrlKey];
var revokeTokenUri = new Uri($"{baseUri}/sharing/rest/oauth2/revokeToken");
var clientFactory = ctx
.HttpContext
.RequestServices
.GetRequiredService<IHttpClientFactory>();
var client = clientFactory.CreateClient();
var result = await client.PostAsync(revokeTokenUri, revokeTokenParams);
});
};
}).AddArcGIS(options =>
{
options.ClientId = theOptions.ClientId ?? string.Empty;
options.ClientSecret = theOptions.ClientSecret ?? string.Empty;
var baseUri = configuration[_PortalUrlKey];
options.AuthorizationEndpoint = $"{baseUri}/sharing/rest/oauth2/authorize";
options.TokenEndpoint = $"{baseUri}/sharing/rest/oauth2/token";
options.UserInformationEndpoint = $"{baseUri}/sharing/rest/community/self";
options.SaveTokens = true;
// Override default claims as email isn't stored in an "email" property
options.ClaimActions.Clear();
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "username");
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "username");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "fullName");
options.ClaimActions.MapJsonKey("LastLogin", "lastLogin");
});
return services;
}
}
Its usage.
// Program.cs
builder.Services.AddMyCookieAuthentication(options =>
{
options.CookieName = "my_cookie_name";
options.ClientId = builder.Configuration["OAuth:ClientId:MyApp"];
options.ClientSecret = builder.Configuration["OAuth:ClientSecret:MyApp"];
options.IsDevelopment = builder.Environment.IsDevelopment();
}, builder.Configuration, azCredential);
This is based on the Combining service collection example from Microsoft's own documents.
I would also like to inject an instance of ILogger so that I can provide some basic logging into this shared component but I do not have an instance of ILogger at the time that builder.Services.AddMyCookieAuthentication
is invoked. Nor do I have access to app.Services.GetRequiredService
from within the extension method.
I'm more accustomed allowing the framework to inject the objects as needed by specifying the dependencies in the constructor. For example, here is some shared logic to configure forwarded headers identically across applications.
public sealed class ConfigureMyForwardedHeaders : IConfigureOptions<ForwardedHeadersOptions>
{
private readonly IConfiguration _config;
private readonly ILogger _logger;
private const string _KnownProxiesKey = "DotNet:ForwardedHeaderOptions:KnownProxies";
private const string _KnownNetworksKey = "DotNet:ForwardedHeaderOptions:KnownNetworks";
public ConfigureMyForwardedHeaders(IConfiguration config, ILogger<ConfigureMyForwardedHeaders> logger)
{
_config = config;
_logger = logger;
}
public void Configure(ForwardedHeadersOptions options)
{
_logger.LogInformation("Configuring My Forwarded Headers");
options.ForwardedHeaders = ForwardedHeaders.All;
var proxyIP = _config[_KnownProxiesKey];
options.KnownProxies.Add(IPAddress.Parse(proxyIP));
var network = _config[_KnownNetworksKey].Split("/");
var networkIP = network[0];
var networkMask = int.Parse(network[1]);
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse(networkIP), networkMask));
}
}
How can I cleanly inject ILogger in my extension method? Do I need to drop the idea of using an extension method entirely?
The solution I implemented based comments was to add ILogger as a paramter to the AddMyCookieAuthentication
extension method as shown below. I had to manually create an ILogger<Program>
instance from the NLogFactory rather than relying on the framework to create the instance for me.
// Program.cs
var logFactory = new NLog.Extensions.Logging.NLogLoggerFactory();
var logger = logFactory.CreateLogger<Program>();
builder.Services.AddMyCookieAuthentication(options =>
{
options.CookieName = "my_cookie_name";
options.ClientId = builder.Configuration["OAuth:ClientId:MyApp"];
options.ClientSecret = builder.Configuration["OAuth:ClientSecret:MyApp"];
options.IsDevelopment = builder.Environment.IsDevelopment();
}, builder.Configuration, logger, azCredential);