Introduction
When working on enterprise solutions, complexity comes with it, different customers having out of ordinary requirements, making it too difficult to deliver a solution that makes everyone happy. One such requirement showed up couple of months ago when I was moving a web API project from .Net Framework 4.7.2 to .Net Core 3.1. The project was not an Identity provider and relied on an external system like Active Directory to provide the identity. The problem was that each customer had their own identity provider, and some had multiple providers. So here is how I solved the issue.
Customer Requirements
- Support the following identity providers
- Windows Authentication
- Auth0
- Azure Active Directory
- Custom JWT that can be validated by a key
- Being able to setup the use of other identity providers through some interfaces
- Multiple identity providers work at the same time
Let's set the scene
Let's set these options in the appsettings.json
which will be the configuration point for the authentication provider (explained in the next section).
"AuthenticationModules": [
{
"Type": "WindowsAuthentication",
"Name": "Active Directory"
},
{
"Type": "Auth0Authentication",
"Name": "Auth0",
"Issuer": "{Domain}",
"Audience": "{APIAudienceIdentifier}",
"OpenIdEndPoint": "{Domain}",
"ClientId": "{ClientID}"
},
{
"Type": "AzureAdAuthentication",
"Name": "AzureAD",
"Issuer": "https://sts.windows.net/{tenantID}/",
"Audience": "{fullCustomScope}",
"OpenIdEndPoint": "https://login.microsoftonline.com/{tenantID}/v2.0",
"ClientId": "{ClientID}"
},
{
"Type": "JwtAuthentication",
"Name": "SalesForce",
"Issuer": "{SalesForce issuer}",
"Audience": "{Audience}",
"HS256Key": "{Base64 encoded value}"
},
{
"Type": "JwtAuthentication",
"Name": "Microsoft CRM",
"Issuer": "{Microsoft CRM issuer}",
"Audience": "{Audience}",
"HS256Key": "{Base64 encoded value}"
}
]
The Type
is used to identify which authentication scheme to register, with the specified options. Each scheme also must be registered with a unique name that needs to be provided on the Name
option. The rest of the options are related to JWT authentication. These options are then loaded into an object that implements IJwtAuthenticationModuleOptions
interface which inherits from a separate interface IAuthenticationModuleOptions
that is used as a base interface for identity providers. This way we will achieve better separation between the necessary options and the optional ones that are used by JWT, and it also opens for adding more options related to other ways of authentications.
/// <summary>
/// The interface that represent the options needed for registering authentication modules.
/// </summary>
public interface IAuthenticationModuleOptions
{
/// <summary>
/// Is the type of the authentication module registered for authenticating. it is the Name of the authentication
/// module object.
/// </summary>
string Type { get; }
/// <summary>
/// Is the unique name of the authentication schema registered in ASP.Net.
/// </summary>
string Name { get; }
}
public interface IJwtAuthenticationModuleOptions: IAuthenticationModuleOptions
{
/// <summary>
/// the token issuer
/// </summary>
string Issuer { get; }
/// <summary>
/// the token audience
/// </summary>
string Audience { get; }
/// <summary>
/// the openId endpoint for validating the token
/// </summary>
string OpenIdEndPoint { get; }
/// <summary>
/// the clientID for getting the token
/// </summary>
string ClientId { get; }
/// <summary>
/// the HS256 encryption key for validating the token
/// </summary>
string HS256Key { get; }
}
When using JWT, Audience
and Issuer
are necessary, to be able to validate that the JWT is issued by the issuer we trust and that it has been created for our application as the audience.
Now that the settings are ready, we can create the authentication scheme provider that will consume these settings and setup each authentication scheme on the AuthenticationBuilder
.
Authentication Scheme Provider
authentication scheme provider is a class we will create, and it will be called in the ConfigureServices
method in startup.cs
. This class is responsible for two things, one to add each authentication scheme into the AuthenticationBuilder
, second to configure the authorization policy to authorize users authenticated by one of the registered authentication schemes. To add the authentication schemes into the AuthenticationBuilder
a simple switch statement can add the scheme based on the specified type. For each type of authentication, a different extension is needed, with options configured to the needs of that authentication scheme.
The following function can be extended with more built in modules, or we can also use the CustomAuthenticationSchemeProvider
specified in the default section of the switch statement to add more schemes into the AuthenticationBuilder
. This way if a type specified in the appsettings.json
does not match the built-in schemes, it will be sent into the CustomAuthenticationSchemeProvider
.
private void AddAuthenticationModule(IAuthenticationModuleOptions module)
{
switch (module.Type)
{
case "WindowsAuthentication":
_authenticationBuilder.AddNegotiate(module.Name,
o => UpdateNegotiateOptions(o, module));
break;
case "JwtAuthentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
case "Auth0Authentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
case "AzureAdAuthentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateAzureAdJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
default:
_customAuthenticationSchemeProvider?.AddAuthenticationModule(_authenticationBuilder, module, module.Name);
break;
}
}
To configure the authorization policy, it is important to register all the scheme names as the default scheme and setup the policy to require the user to be authenticated.
public AuthorizationPolicy BuildAuthorizationPolicy()
{
return new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(_authenticationModules.Select(m => m.Name).ToArray())
.RequireAuthenticatedUser()
.Build();
}
Here is the code snippet on how to setup windows authentication and how to setup JWT authentication. It is important to add the authentication scheme type and name as claims to the identity so later you could identify which authentication scheme has identified the user.
private void UpdateNegotiateOptions(
NegotiateOptions options,
IAuthenticationModuleOptions authenticationModuleOptions)
{
options.Events = new NegotiateEvents
{
OnAuthenticated = c =>
{
AddRequiredClaims(c.Principal.Identity, authenticationModuleOptions);
return Task.CompletedTask;
},
OnAuthenticationFailed = c =>
{
_logger.LogError(c.Exception, "Failed to authenticate user with scheme {@name}",
authenticationModuleOptions.Name);
return Task.CompletedTask;
}
};
}
private void UpdateJwtBearerOptions(
JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
options.TokenValidationParameters =
GetTokenValidationParametersBuilder(options, authenticationModuleOptions).Build();
AddJwtBearerOptionsEvent(options, authenticationModuleOptions);
}
private void UpdateAzureAdJwtBearerOptions(
JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
var tokenValidationParametersBuilder = GetTokenValidationParametersBuilder(options, authenticationModuleOptions);
tokenValidationParametersBuilder.SetLocalAudienceValidation((audiences, securityToken, validationParameters) =>
{
if (audiences.Any(a => !authenticationModuleOptions.Audience.StartsWith(a)))
{
throw new SecurityTokenInvalidAudienceException("Invalid audience");
}
return true;
});
options.TokenValidationParameters = tokenValidationParametersBuilder.Build();
AddJwtBearerOptionsEvent(options, authenticationModuleOptions);
}
private void AddJwtBearerOptionsEvent(JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
options.Events = new JwtBearerEvents
{
OnTokenValidated = c =>
{
c.Principal.Identity.AddAuthenticationModuleClaim(authenticationModuleOptions.Type);
c.Principal.Identity.AddClaim(nameof(authenticationModuleOptions.OpenIdEndPoint),
authenticationModuleOptions.OpenIdEndPoint);
c.Principal.Identity.AddClaim(nameof(authenticationModuleOptions.ClientId),
authenticationModuleOptions.ClientId);
return Task.CompletedTask;
},
OnAuthenticationFailed = c =>
{
_logger.LogError(c.Exception, "Failed to authenticate user with module {@name}",
authenticationModuleOptions.Name);
return Task.CompletedTask;
}
};
}
As the last step we need to wire this up into the startup.
public void ConfigureServices(IServiceCollection services)
{
//disable automatic authentication for out-of-process hosting
services.Configure<IISServerOptions>(options => { options.AutomaticAuthentication = false; });
services.Configure<IISOptions>(options => { options.AutomaticAuthentication = false; });
var authenticationSchemeProvider = new AuthenticationSchemeProvider(
GetAuthenticationModuleOptions(services),
services.AddAuthentication(),
GetCustomAuthenticationSchemeOptionsProvider(),
GetAuthenticationSchemeOptionsProviderLogger(services));
}
private ICustomAuthenticationSchemeProvider GetCustomAuthenticationSchemeOptionsProvider()
{
var customAuthenticationSchemeOptionsProviderType = _customLoadedAssemblies.CustomAssemblies.SelectMany(a =>
a.GetTypes().Where(t => typeof(ICustomAuthenticationSchemeProvider).IsAssignableFrom(t)))
.FirstOrDefault();
return customAuthenticationSchemeOptionsProviderType == null
? null
: (ICustomAuthenticationSchemeProvider) Activator.CreateInstance(
customAuthenticationSchemeOptionsProviderType);
}
private IAuthenticationModuleOptions[] GetAuthenticationModuleOptions(IServiceCollection services)
{
using var scope = services.BuildServiceProvider().CreateScope();
var options = scope.ServiceProvider.GetRequiredService<IOptions<AuthenticationModulesOptions>>().Value;
return options.Modules;
}
private ILogger<AuthenticationSchemeProvider> GetAuthenticationSchemeOptionsProviderLogger(IServiceCollection services)
{
using var scope = services.BuildServiceProvider().CreateScope();
return scope.ServiceProvider.GetRequiredService<ILogger<AuthenticationSchemeProvider>>();
}
Conclusion
There are more schemes that can be added to the built-in schemes to support more authentication methods out of the box but for now this was sufficient based on the requirements. I used IClaimsTransformation
to create and retrieve users from the database and integrate the authentication with the role and claim based system that was already in place. The implementation of the claims transformation is out of the scope of this blog post and will be explained in another blog post if there is interest.
Here are the full classes for the AuthenticationSchemeProvider
.
public class AuthenticationSchemeProvider
{
private readonly IAuthenticationModuleOptions[] _authenticationModules;
private readonly AuthenticationBuilder _authenticationBuilder;
private readonly ICustomAuthenticationSchemeProvider _customAuthenticationSchemeProvider;
private readonly ILogger<AuthenticationSchemeProvider> _logger;
public AuthenticationSchemeProvider(
IAuthenticationModuleOptions[] authenticationModules,
AuthenticationBuilder authenticationBuilder,
ICustomAuthenticationSchemeProvider customAuthenticationSchemeProvider,
ILogger<AuthenticationSchemeProvider> logger)
{
_authenticationModules = authenticationModules ?? throw new ArgumentNullException(nameof(authenticationModules));
_authenticationBuilder = authenticationBuilder ?? throw new ArgumentNullException(nameof(authenticationBuilder));
_customAuthenticationSchemeProvider = customAuthenticationSchemeProvider;
_logger = logger;
}
public void SetupAuthenticationModules()
{
foreach (var authenticationModule in _authenticationModules)
{
AddAuthenticationModule(authenticationModule);
}
}
private void AddAuthenticationModule(IAuthenticationModuleOptions module)
{
switch (module.Type)
{
case "WindowsAuthentication":
_authenticationBuilder.AddNegotiate(module.Name,
o => UpdateNegotiateOptions(o, module));
break;
case "JwtAuthentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
case "Auth0Authentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
case "AzureAdAuthentication":
_authenticationBuilder.AddJwtBearer(module.Name,
o => UpdateAzureAdJwtBearerOptions(o, (IJwtAuthenticationModuleOptions) module));
break;
default:
_customAuthenticationSchemeProvider?.AddAuthenticationModule(_authenticationBuilder, module,
module.Name);
break;
}
}
public AuthorizationPolicy BuildAuthorizationPolicy()
{
return new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(_authenticationModules.Select(m => m.Name).ToArray())
.RequireAuthenticatedUser()
.Build();
}
private void UpdateNegotiateOptions(
NegotiateOptions options,
IAuthenticationModuleOptions authenticationModuleOptions)
{
options.Events = new NegotiateEvents
{
OnAuthenticated = c =>
{
c.Principal.Identity.AddAuthenticationModuleClaim(authenticationModuleOptions.Type);
return Task.CompletedTask;
},
OnAuthenticationFailed = c =>
{
_logger.LogError(c.Exception, "Failed to authenticate user with module {@name}",
authenticationModuleOptions.Name);
return Task.CompletedTask;
}
};
}
private void UpdateJwtBearerOptions(
JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
options.TokenValidationParameters =
GetTokenValidationParametersBuilder(options, authenticationModuleOptions).Build();
AddJwtBearerOptionsEvent(options, authenticationModuleOptions);
}
private void UpdateAzureAdJwtBearerOptions(
JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
var tokenValidationParametersBuilder = GetTokenValidationParametersBuilder(options, authenticationModuleOptions);
tokenValidationParametersBuilder.SetLocalAudienceValidation((audiences, securityToken, validationParameters) =>
{
if (audiences.Any(a => !authenticationModuleOptions.Audience.StartsWith(a)))
{
throw new SecurityTokenInvalidAudienceException("Invalid audience");
}
return true;
});
options.TokenValidationParameters = tokenValidationParametersBuilder.Build();
AddJwtBearerOptionsEvent(options, authenticationModuleOptions);
}
private TokenValidationParametersBuilder GetTokenValidationParametersBuilder(
JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
var tokenValidationParametersBuilder = new TokenValidationParametersBuilder();
if (!string.IsNullOrWhiteSpace(authenticationModuleOptions.HS256Key))
{
tokenValidationParametersBuilder
.SetSymmetricSecurityKey(authenticationModuleOptions.HS256Key)
.SetLocalIssuerValidation(authenticationModuleOptions.Issuer)
.SetLocalAudienceValidation(authenticationModuleOptions.Audience);
}
else if (!string.IsNullOrWhiteSpace(authenticationModuleOptions.OpenIdEndPoint))
{
options.Authority = authenticationModuleOptions.Issuer;
options.Audience = authenticationModuleOptions.Audience;
}
return tokenValidationParametersBuilder;
}
private void AddJwtBearerOptionsEvent(JwtBearerOptions options,
IJwtAuthenticationModuleOptions authenticationModuleOptions)
{
options.Events = new JwtBearerEvents
{
OnTokenValidated = c =>
{
c.Principal.Identity.AddAuthenticationModuleClaim(authenticationModuleOptions.Type);
c.Principal.Identity.AddClaim(nameof(authenticationModuleOptions.OpenIdEndPoint),
authenticationModuleOptions.OpenIdEndPoint);
c.Principal.Identity.AddClaim(nameof(authenticationModuleOptions.ClientId),
authenticationModuleOptions.ClientId);
return Task.CompletedTask;
},
OnAuthenticationFailed = c =>
{
_logger.LogError(c.Exception, "Failed to authenticate user with module {@name}",
authenticationModuleOptions.Name);
return Task.CompletedTask;
}
};
}
}
public class TokenValidationParametersBuilder
{
private bool _saveSigninToken = true;
private string _nameClaimType = ClaimTypes.NameIdentifier;
private SecurityKey _issuerSigningKey;
private bool _validateIssuerSigningKey;
private IssuerValidator _issuerValidator;
private AudienceValidator _audienceValidator;
public TokenValidationParametersBuilder SetSymmetricSecurityKey(string hs256Key)
{
_issuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(hs256Key));
_validateIssuerSigningKey = true;
return this;
}
public TokenValidationParametersBuilder SetLocalIssuerValidation(string issuerOption)
{
SetLocalIssuerValidation((issuer, securityToken, validationParameters) =>
{
if (issuer != issuerOption)
{
throw new SecurityTokenInvalidIssuerException("Invalid issuer");
}
return issuer;
});
return this;
}
public TokenValidationParametersBuilder SetLocalIssuerValidation(IssuerValidator issuerValidator)
{
_issuerValidator = issuerValidator;
return this;
}
public TokenValidationParametersBuilder SetLocalAudienceValidation(string audienceOption)
{
SetLocalAudienceValidation((audiences, securityToken, validationParameters) =>
{
if (audiences.All(a => a != audienceOption))
{
throw new SecurityTokenInvalidAudienceException("Invalid audience");
}
return true;
});
return this;
}
public TokenValidationParametersBuilder SetLocalAudienceValidation(AudienceValidator audienceValidator)
{
_audienceValidator = audienceValidator;
return this;
}
public TokenValidationParameters Build()
{
return new TokenValidationParameters
{
SaveSigninToken = _saveSigninToken,
NameClaimType = _nameClaimType,
IssuerSigningKey = _issuerSigningKey,
ValidateIssuerSigningKey = _validateIssuerSigningKey,
IssuerValidator = _issuerValidator,
AudienceValidator = _audienceValidator
};
}
}
public static class IdentityExtensions
{
public const string AuthenticationModuleClaimType = "AuthenticationModule";
public static void AddAuthenticationModuleClaim(this IIdentity identity, string moduleType)
{
identity.AddClaim(AuthenticationModuleClaimType, moduleType);
}
public static void AddClaim(this IIdentity identity, string type, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var claimsIdentity = (ClaimsIdentity) identity;
claimsIdentity.AddClaim(new Claim(type, value));
}
}
Comments