Bye bye Azure Functions, Hello Azure Container Apps: Authentication and CORS issues
In this series of posts, we're looking at the steps we had to go through to migrate the APIs for one of our internal applications from Azure Functions to Azure Container Apps.
Part 1 of the series gives the background, part 2 covers the code migration from Azure Functions to ASP.NET Core and Part 3 talks about the changes we made to our build and deployment scripts.
At the end of part 3, we hit a problem: we could deploy the application, but when we turned authentication on for our container apps, they didn't work. We'd been using the Authentication feature built into the Functions platform (which is also known as EasyAuth) and we had assumed we'd be able to do the same with Azure Container Apps. Unfortunately things weren't quite so simple.
Background - CORS support and Authentication in Azure Functions
In the Azure Functions world, the platform can provide both authentication and CORS support for you. To enable CORS support in Azure Functions is as simple as going to the CORS tab for your Function App in the portal and specifying the domains that are allowed to make calls to your Function.
This will enable the appropriate middleware in the platform to allow your Function to correctly respond to CORS preflight requests and ensure it only responds to Javascript calls from scripts running at the specified domain.
Authentication is a similar story, albeit not quite so simple to configure.
In our case, we're using Azure Active Directory to authenticate users, so we use the Microsoft Identity provider. This requires an App Registration in Azure AD. You can then choose what to do for unauthenticated requests. For our APIs we only want authenticated requests so we specified that authentication is required and unauthenticated requests should result in a 401 Unauthorized response. All of this happens before any of our code gets invoked.
Using these platform features kept the code cleaner; because we get to hand off basic features like this to the hosting platform, we don't need to clutter up our code with them.
Visually, we can draw the result like this:
An important part of this is that the CORS handling is done prior to the authentication. This is because, as per the W3C Specification that includes the CORS protocol, a CORS preflight request never includes credentials. This means that if our authentication step took place first, all the CORS preflight requests would result in a 401 Unauthorized response.
Azure Container Apps doesn't provide CORS support
As we moved to Azure Container Apps, we found that the platform provides authentication in exactly the same way as it does for Azure Functions. However, it doesn't yet provide CORS support.
Fortunately it's easy to add CORS middleware to the ASP.NET Core pipeline. The process is described here - like the majority of middleware, it requires adding the middleware at startup, along with the supporting services in the DI container.
Unfortunately, enabling authentication in the Container app results in the authentication step taking place before the CORS middleware gets to process the request. This means that all preflight requests from the browser fail because (as per the spec) they do not attempt to authenticate. As you might expect, this completely broke our application.
Options
As you can see here and here, we're not the only people to have encountered this issue. CORS support in ACA is on the roadmap, but we have no visibility of when it will come.
We came up with three possible ways to deal with this.
- Do nothing; wait until the ACA platform provides CORS support.
- Stop using the Authentication support provided by the platform and switch to using ASP.NET Core middleware instead.
- Use Dapr's endpoint authorization middleware. In this scenario, we'd also use Dapr's CORS middleware to restore the correct order of processing.
Without any visibility of the target date we didn't want to wait until CORS support is provided by the ACA platform, so we dismissed option 1.
Our preference was for option 3 as it allows us to keep the same separation of concerns we had in the Azure Functions version of our APIs. However, as I described in part 2 of this series, we'd already decided to avoid Dapr as part of the migration process as we didn't want to expand our scope by bringing in another component that we aren't fully familiar with yet.
As a result, we made the same decision here as we did before; take the simplest route - which is option 2 - then once we've completed the migration we'll revisit this functionality and implement option 3.
Adding authentication to ASP.NET Core
There are plenty of examples of how to add authentication to ASP.NET core. From one perspective it's simple - as with CORS support, it's a case of adding the authentication middleware to the pipeline at the correct point, and then adding the necessary supporting services to the DI container. As always, however, the devil is in the detail.
We've got two slightly separate scenarios to deal with. For the API which supports the web app itself, authentication is via Azure Active Directory B2C. For our Admin application, it's basic AAD authentication. The configuration requirements for each are slightly different; let's have a look at each of them in turn.
Adding AAD B2C-based authentication
As mentioned above, we need code in two places for this. Firstly, adding the necessary middleware; this is a simple couple of lines that are added in the method which configures the app:
app.UseAuthentication();
app.UseAuthorization();
But why do we need the authorization middleware when all we've been talking about to date is authentication?
You can read the details on this page, but in essence the authentication middleware is responsible for three things:
- Using its configuration and the incoming request context to construct
AuthenticationTicket
objects representing the user's identity if authentication is successful. - Knowing what to do if the user is forbidden to access the requested resource (e.g. returning a 403 Unauthorized response, or redirecting to an "Unauthorized" page)
- Knowing what to do if an unauthenticated user attempts to access a page which requires authentication (e.g. returning a 401 Unauthorized response, or directing the user to the login page).
In short, the authentication middleware determines whether or not the user is authenticated but isn't responsible for deciding whether a user is allowed to access a resource or not. That's the job of the authorization middleware.
Secondly, we add corresponding services to the DI container. This is a bit more interesting, as this is where we configure our authentication settings. Here's what this looks like for the authentication middleware:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(
bearerOptions =>
{
configuration.Bind("AzureAdB2C", bearerOptions);
bearerOptions.TokenValidationParameters.NameClaimType = "given_name";
},
identityOptions =>
{
configuration.Bind("AzureAdB2C", identityOptions);
});
We can see a couple of interesting things here. The key thing is that we're outsourcing the majority of the settings to the configuration provider. That configuration looks like this:
{
"AzureAdB2C": {
"ClientId": "<The ID of your app registration>",
"Domain": "<The domain name of your AD B2C tenant>",
"Instance": "https://<The name of your B2C tenant>.b2clogin.com",
"SignUpSignInPolicyId": "<The Id of your sign up/in policy>",
"TenantId": "The Id of your B2C tenant",
"AllowWebApiToBeAuthorizedByACL": true
}
}
There are a couple of interesting points here. Firstly, the code is overriding the default NameClaimType
to the type of the claim we want to use for the Name
property of the resulting ClaimsIdentity
.
Secondly, the non-default value we're providing for AllowWebApiToBeAuthorizedByACL
. By default this is set to false
and this assumes that your application will be authorizing users based on roles. To do this, you need a roles
claim in the authentication token and as such, the authentication middleware will throw an exception if it's not present.
If you're not using roles to authorize users (which Microsoft refer to as the Access Control List-based authorization pattern), you disable the exception throwing behaviour by setting AllowWebApiToBeAuthorizedByACL
to true. This essentially tells the middleware that you'll handle the authorization by some other means.
Now, onto the authorization. For this first round of migration we wanted to mimic the previous behaviour we had - rejecting any unauthenticated calls - in the simplest possible way. This is done as follows:
services.AddAuthorization(
options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
});
Rather than providing multiple authorization policies, we are supplying a single policy to be used in all cases which simply rejects unauthenticated requests. All the existing code we had in place to ensure users are only shown data to which they have access remains untouched.
Adding AAD based authentication
Our Admin API uses near-identical code but slightly different configuration:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(
bearerOptions =>
{
configuration.Bind("AzureAd", bearerOptions);
},
identityOptions =>
{
configuration.Bind("AzureAd", identityOptions);
});
As you can see, we're binding to a differently named configuration section, and we don't care about overriding the NameClaimType
for this use case.
There's a bigger difference when we look at the configuration:
{
"AzureAD": {
"Audience": "<The ID of your app registration>",
"ClientId": "<The ID of your app registration>",
"Domain": "<The domain of your AAD tenant",
"Instance": "https://login.microsoftonline.com/",
"TenantId": "<The Tenant ID of your AAD>"
}
}
Config is quite basic here, since everything points at our AAD instance and the app registration we've created in that to represent our app.
Conclusion
It was relatively simple to add the authentication, authorization and CORS support to our code rather than have them managed by the platform. With that said, we'd still have preferred to keep them as they were, provided by the hosting platform - after all, the best code is often the code you don't write.
The approach we took here wasn't our preferred approach, but it was the correct one under the circumstances - adopting Dapr at this point would have led us down another rabbit hole which would likely have slowed down the migration process. Now we're on the other side of the migration, we've already started looking at Dapr to provide authentication and CORS support and I'll hopefully talk about this in a future post.
By the time we finished the work described above, the application was finally fully functional on Azure Container Apps, with our automated build and deployment process working smoothly.
However, we still had the issue that our containerised apps were deployed in North Europe, but our storage, and other resources - App Insights, Key Vault, etc - were still deployed in UK South. In the next post, we'll look at how we approached migration of these resources to North Europe.