Authenticating Azure B2C in ASP.NET Core

Generally I prefer the holistic approach of .NET, as opposed to the small-core plus ‘lots of libraries that haven’t been tested together’ approach in other ecosystems as it tends to provide a more predictable platform. However one area where I struggle with Microsoft’s approach is authentication. The .NET Core documentation makes it clear they want you to use Identity, and everything else is a second-class citizen. But Identity, with database backed roles, seems like an all-or-nothing proposition, and overkill for a basic solution simply asking ‘who are you?’.

What I want is a signed token with identifying information. I’m using Razor Pages, so this is a postback environment, and I’d like it to be stateless which means the user-agent needs to hold their credentials, which is usually done using cookies. Cookies make me a little nervous since the GDPR rules have come in, however consent isn’t required for strictly necessary cookies such as these.

Strictly necessary cookies — These cookies are essential for you to browse the website and use its features, such as accessing secure areas of the site

To comply with the regulations governing cookies under the GDPR and the ePrivacy Directive you must: Receive users’ consent before you use any cookies except strictly necessary cookies.

Choosing an Authentication Grant

Azure B2C is an authorization server supporting OAuth2 as defined in RFC 6749. RFC 6749 defines four roles. In this case two are obvious: the resource-owner is the end-user and the authorization-server is Azure B2C. The distinction between the other two roles is more subtle.

This is a Razor Pages application so the logic for requesting resources resides on the web-server making the web-server the client. The web-server is also the resource server, as it is where the protected resources reside. Assuming a classic 3-tier architecture, we could say the presentation layer is the client, while the domain and store layers are the resource. In practice, the authorization will be checked at the presentation layer which will return a different presentation if authorization fails.

Regardless, we have a client that can keep secrets. This allows us to use the default (and more secure) OAuth2 grant, Authorization Code.

Authorization Code Grant with AzureB2C

Azure B2C needs it’s own Active Directory instance. Azure calls this a tenant and it’s known by two identifiers: a domain and a GUID. Following the steps in the Create B2C Tenant tutorial will create that instance, and the domain name and GUID will be displayed in the Azure Directory + subscription filter.

Authorization Code grant requires a client id and secret. The client id tells the authorization server which client is requesting access (on behalf of the user). The client secret is used as a password when the client directly communicates with the authorization server. Azure B2C calls these the Application ID and App Key respectively, and these are set in the Applications area of the Azure B2C blade in Azure Portal.

If you want an access token (as opposed to just an ID token), it is also important to add API Access. This is done in the Azure Portal under B2C by setting the App ID Url (typically to api), then going to Api Access, pressing Add, and selecting the application from the top drop-down and everything from the second. This will add a scope of https://tenant-name.onmicrosoft.com/api/user_impersonation

ASP.NET Core

ASP.NET Core’s documentation for authentication would benefit from focusing beyond Identity, by including how authentication works (i.e. different schemes and providers), and providing information on using OpenIDConnect or JwtBearer, two very common approaches. The best resource I can find at present is the AspNetCore source code which includes a lot of samples under the /src/Security path. In this case, I’ve worked from the OpenIdConnectSample project.

The second challenge is configuration. Some documentation suggests you get application information from App Registrations however Azure Portal current indicates this isn’t fully supported, and it’s the same information that comes from the Azure AD B2C – Applications blade. The terminology in that blade is a little confusing as it refers to the client ID as Application ID, and the tenant ID varies depending on which Active Directory you allow your application users to come from. The most common case is to use the directory you created earlier, so the tenant value will be your domain.
The following configuration, with values from Azure, goes into the root level of the appsettings.json.

"AzureAdB2C": {
  "ClientId": "ApplicationID from Azure AD B2C - Applications"
  "ClientSecret": "Key from Azure AD B2C - Applications"
  "Domain": "xxx.onmicrosoft.com (from Directory + subscription filter)",
  "SignUpSignInPolicyId": "Policy name from Azure AD B2C - User flows (policies)",
  "Tenant": "Tenant Name (first part of URL from Directory + subscription filter)",
  "TenantId": "TenantID Guid (from Directory + subscription filter)"
}

This configuration is loaded by the following class

public class AzureAdB2COptions
{
  public string Authority => $"https://{Tenant}.b2clogin.com/tfp/{TenantId}/{SignUpSignInPolicyId}/v2.0/";
  public string ClientId { get; set; }
  public string ClientSecret { get; set; }
  public string Scope => $"https://{Tenant}.onmicrosoft.com/api/user_impersonation";
  public string SignUpSignInPolicyId { get; set; }
  public string Tenant { get; set; }
  public string TenantId { get; set; }
}

Finally, to include this in your ASP.NET Core application, it needs to be configured in Startup.

// in ConfigureServices(IServiceCollection services)
services.AddAuthentication(sharedOptions =>
{
  sharedOptions.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  sharedOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
  var b2cOptions = new AzureAdB2COptions();
  Configuration.Bind("AzureAdB2C", b2cOptions);

  options.Authority = b2cOptions.Authority;
  options.ClientId = b2cOptions.ClientId;
  options.ClientSecret = b2cOptions.ClientSecret;
  options.ResponseType = OpenIdConnectResponseType.Code;
  options.Scope.Add(b2cOptions.Scope);
});

// in Configure(IApplicationBuilder app, IWebHostEnvironment env) before app.UseEndpoints()
app.UseAuthentication();
app.UseAuthorization();