This article will describe how to integrate authentication into a Beef solution; in which Beef in an of itself does not enable directly, but leverages the capabilities such as Azure Active Directory B2C to perform. Equally, it could be any Identity platform of your choosing.
For the purposes of this artice AAD B2C will be used. Review Microsoft's documentation on how to set up and configure in Azure as this will not be covered here.
The authentication process primarily takes place within the API itself. This capability is added within the Startup.cs
leveraging the standard ASP.NET Core authentication capabilities, as further described.
Within the ConfigureServices
method a call to AddAuthentication
is required to configure. For AAD B2C AddAzureADB2CBearer
is used to load in the appropriate configuration (Microsoft.AspNetCore.Authentication.AzureADB2C.UI
NuGet package is required).
public void ConfigureServices(IServiceCollection services)
{
// Add authentication using Azure AD B2C.
services.AddAuthentication(AzureADB2CDefaults.BearerAuthenticationScheme)
.AddAzureADB2CBearer(options => _config.Bind("AzureAdB2C", options));
...
The Bind
method loads the configuration from the application settings; for Beef this is the webapisettings.json
file. The "AzureAdB2C"
represents the node within the underlying JSON that contains the corresponding configuration:
{
"AzureAdB2C": {
"Domain": "Xxxx.onmicrosoft.com", // Azure AD B2C domain name
"Instance": "https://Xxxx.b2clogin.com/tfp/", // Instance name, the domain name Xxxx is duplicated here
"ClientId": "12345678-097e-4786-b489-123dabeff688", // Application (client) identifier
"SignUpSignInPolicyId": "B2C_1_SignUpSignIn" // SignUpSignIn policy name
}, ...
Where Swagger UI is being used and support for entering in the bearer token is required, then AddSecurityDefinition
and OperationFilter
are required, as follows:
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Xxxx API", Version = "v1" });
var xmlName = $"{Assembly.GetEntryAssembly()!.GetName().Name}.xml";
var xmlFile = Path.Combine(AppContext.BaseDirectory, xmlName);
if (File.Exists(xmlFile))
c.IncludeXmlComments(xmlFile);
c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: bearer {token}\"",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey
});
c.OperationFilter<SecurityRequirementsOperationFilter>();
});
The next step is to add the authentication into the HTTP request pipeline. The sequencing of this is important, as follows: UseRouting
, UseAuthentication
, UseAuthorization
, UseExecutionContext
and UseEndpoints
.
For Beef the ExecutionContext
plays a key role for housing the user details, namely the Username
(optionally UserId
). For the likes of authorization SetRoles
can also be used. To enable additional capabilities a custom ExecutionContext
can be created (inheriting base) similar to that demonstrated within the Cdr.Banking sample.
The UseExecutionContext
also represents an opportuntity to perform further authentication validation, such as verifying the issuer (iss
) and audience (aud
) claims for example.
Note: the Username
is set to emails#oid
claims as the emails
value may not be unique and is mutable, whereas the oid
is unique and immutable. This may also be appropriate in your scenario especially where the Username
is used for the likes of auditing.
public void Configure(IApplicationBuilder app, IHttpClientFactory clientFactory)
{
...
// Use routing, authentication and authorization.
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// Add execution context set up to the pipeline (must be after UseAuth* as needs claims from user).
app.UseExecutionContext((ctx, ec) =>
{
if (ctx.User.Identity.IsAuthenticated)
{
if (Guid.TryParse(ctx.User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value, out var oid))
fec.UserId = oid;
else
throw new Beef.AuthenticationException("Token must have an 'oid' (object identifier GUID) claim.");
fec.Username = $"{ctx.User.FindFirst("emails")?.Value ?? throw new Beef.AuthenticationException("Token must have an 'emails' claim.")}#{fec.UserId}";
}
else
fec.Username = "Anonymous";
});
// Finally add the controllers.
app.UseEndpoints(endpoints => endpoints.MapControllers());
Disclaimer: the example above is for illustrative purposes only; it is the responsibility of the developer to fully implement the claims verification that is applicable to their specific use case.
For the authentication to occur within an API invocation the AuthorizeAttribute
must be specified. The output of this attribute is controlled by the code generation configuration.
The following XML elements support the WebApiAuthorize
attribute, with two options Authorize
or AllowAnonymous
. The value is inherited from its parent within the hierarchy where not explicitly defined (overridden):
Note: Where no WebApiAuthorize
attribute is specified and cannot be inferred via parental inheritence, it will default to AllowAnonymous
.
To support the intra-domain integration testing the bearer token must be passed from the test to the API otherwise all requests will fail with an authentication error.
The AgentTester
has a static RegisterBeforeRequest
method that enables the HttpRequestMessage
to be modified prior to the request being made.
Refer to the Microsoft documentation for further AAD B2C configuration and troubleshooting.
The following code demonstrates the creation of the bearer token by calling the OAuth endpoint passing the username and password. The resulting token is then added to the HTTP request header. Note that the username comes from the ExecutionContext.Current.Username
which is set within each executing test (see next section).
[SetUpFixture]
public class FixtureSetUp
{
private static readonly KeyedLock<string> _lock = new KeyedLock<string>();
private static readonly Dictionary<string, string> _userTokens = new Dictionary<string, string>();
[OneTimeSetUp]
public void OneTimeSetUp()
{
TestSetUp.RegisterSetUp(async (count, _) =>
{
return await DatabaseExecutor.RunAsync(
count == 0 ? DatabaseExecutorCommand.ResetAndDatabase : DatabaseExecutorCommand.ResetAndData,
AgentTester.Configuration["ConnectionStrings:Database"],
typeof(DatabaseExecutor).Assembly, typeof(Database.Program).Assembly, Assembly.GetExecutingAssembly()).ConfigureAwait(false) == 0;
});
AgentTester.StartupTestServer<Startup>(environmentVariablesPrefix: "AppName_");
AgentTester.DefaultExpectNoEvents = true;
AgentTester.RegisterBeforeRequest(BeforeRequet);
}
private static void BeforeRequet(HttpRequestMessage r)
{
var username = ExecutionContext.Current.Username;
if (username.Equals("Anonymous", System.StringComparison.OrdinalIgnoreCase))
return;
// Cache the token for a user to minimise web calls (perf improvement).
_lock.Lock(username, () =>
{
if (!_userTokens.TryGetValue(username, out string? token))
{
var data = new NameValueCollection
{
{ "grant_type", "password" },
{ "client_id", "12345678-097e-4786-b489-123dabeff688" }, // Application (client) identifier
{ "scope", $"openid 12345678-097e-4786-b489-123dabeff688 offline_access" },
{ "username", $"{username}@domain.com" }, // Appends domain to user (if applicable)
{ "password", "password" } // Assumes all test users have same password
};
// The 'Xxxx' represents your AAD B2C domain
using var webClient = new WebClient();
var bytes = webClient.UploadValues("https://Xxxx.b2clogin.com/Xxxx.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1_ROPC_Auth", "POST", data);
var body = Encoding.UTF8.GetString(bytes);
token = (string)JObject.Parse(body)["access_token"]!;
_userTokens.Add(username, token);
}
r.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
});
}
}
The username can be specified for each test, either using the TestSetUpAttribute
or specifiying when invoking the AgentTester.Create. This in turn will set the ExecutionContext.Current.Username
.
[Test, TestSetUp("username")]
public void A110_GetMe_NotFound()
{
AgentTester.Create<ClaimantAgent, Claimant>()
.ExpectStatusCode(HttpStatusCode.NotFound)
.ExpectErrorType(Beef.ErrorType.NotFoundError)
.Run((a) => a.Agent.GetMeAsync());
AgentTester.Create<ClaimantAgent, Claimant>("username2")
.ExpectStatusCode(HttpStatusCode.NotFound)
.ExpectErrorType(Beef.ErrorType.NotFoundError)
.Run((a) => a.Agent.GetMeAsync());
}