Protect and call an ASP.NET Core minimal Web API with Azure AD
I recently wanted to create a simple ASP.NET Core Web API (using minimal APIs and .NET 7), secure it using Azure AD, and then write both a console app and PowerShell scripts that could call the API.
Although there are a bunch of tutorials out there that walk you through this, I ran into a few gotchas along the way, so wanted to record the steps I took to eventually get it working (if only for my own benefit).
The approach I took is mostly based on this tutorial but with the slight difference of using minimal APIs and an extra step at the end to enable fetching access tokens with the Azure CLI.
We'll be using two projects - a .NET 7 minimal web API, and a .NET 7 console application (the "confidential client") which is going to call the Web API.
Step 1 - Create an App Registration in Azure AD for the Web API
In the Azure Portal, search for "App Registrations" and create a new App Registration. We're actually going to create two, but this first one is for the Web API, so give it a name like "My Web API".
Leave "Supported account type" set to the default of "Accounts in this organizational directory only", and click "Register".
There are two main changes that we need to make.
First, we need to edit the manifest which can be done by selecting "Manifest" in the App Registration left-hand menu, and then find the place in the JSON that defines the appRoles
:
"appRoles": []
Basically you just need to paste in a new DaemonAppRole
into this section, so it now looks like this (you can use the same GUID, it's not really important):
"appRoles": [
{
"allowedMemberTypes": [
"Application"
],
"description": "Daemon apps in this role can consume the web api.",
"displayName": "DaemonAppRole",
"id": "4fb105fa-aaf2-4a13-9fc4-24dbbd50ffd5",
"isEnabled": true,
"lang": null,
"origin": "Application",
"value": "DaemonAppRole"
}
],
The other change we need to make is to expose and API. Again, there is a left-hand menu option to do this in the portal, and its simply a matter of clicking "Set" next to "Application ID URI".
This will generate an Application ID URI that looks something like this: api://50bad0fb-43f8-4599-a38e-faf49e40c7d0
.
The GUID is actually your "Application (client) ID", which can also be found in the Overview tab of the portal. That tab also will show the "Directory (tenant) ID", which you'll need later.
Note that the confidential client app will not use scopes, so we don't need to define any (although we will later on to support the Azure CLI generating tokens).
Step 2 - Create an App Registration for the Client App
Now we need to create another App Registration, and this one represents the client application, which in our example is a console app, but could also be a PowerShell script, or a LINQPad script. Note that the client is going to be a "confidential" client, which means we can trust it to keep a secret.
Again in the portal create a new App Registration, and call this one something like "My Web API Client", with the same defaults as the other one.
We need to make three changes to this App Registration.
First, we need to add a secret, which can be done in the "Certificates & secrets" section. Add a "new client secret", and choose an appropriate expiry time. Note that you'll want to copy the secret value and store it somewhere safe after generating it, as you won't be able to access it again afterwards.
Second, go to API permissions and click "Add a permission". In the dialog, select "My APIs" and then choose the Web API app registration you just created. When you are asked what permissions it needs, navigate to "Application" and select "DaemonAppRole", and then add the permission.
Third, you need to click the "Grant admin consent for (my tenant)" link at the top of the "Configured permissions" table. After doing so, you should see a green checkmark in the Status column next to the DaemonAppRole
permission.
Step 3 - Configure the Web API
For my web API project, I started with the "minimal APIs" template from .NET 7 which you can use with dotnet new web
.
We then need to reference the Microsoft.Identity.Web NuGet package which we can do with dotnet add package Microsoft.Identity.Web
.
And now, in Program.cs
, add the following lines before the call to builder.Build();
:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization();
You'll need these using statements:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
Next, we need to update appsettings.json
by adding the following JSON. Obviously you'll need to replace the values in here with your own (the ones shown here are made up dummy values). The Tenant ID and Client ID can be found on the Overview tab for the App Registration you created for the Web API.
The Domain can be found by navigating to the main "Azure Active Directory" page in the Azure portal and looking at the Primary domain.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "mydomain.onmicrosoft.com",
"TenantId": "750c540a-687c-4160-9d86-9433b49c9997",
"ClientId": "50bad0fb-43f8-4599-a38e-faf49e40c7d0"
}
Gotcha: I think the main reason I had problems getting everything working was that my
appsettings.json
also contained some additional "Authentication" settings which had been created when I was experimenting with thedotnet user-jwts create
command. It seems these can interfere with the way that theAddMicrosoftIdentityWebApi
extension method configures things. Deleting them got me up and running.
The final thing we need to do is require authorization on the endpoints we want to secure. You can do this on a per-endpoint basis like this:
app.MapGet("/", () => "Hello World!").RequireAuthorization();
And if you're using groups you can require authorization for the entire group of endpoints like this:
var weather = app.MapGroup("/weather").RequireAuthorization();
Step 4 - Calling the Web API from a confidential client app
We need to start by gathering up a bunch of settings that we'll need. First, we need the "scope", which consists of the "Application ID URI" from the Web API app registration followed by /.default
. (Note that it simply won't work if you try to use any other scopes other than the default one).
Second, we need the tenant ID, as well as the client ID of the client app registration. We also need the secret that we configured for that app registation. Obviously, avoid hard-coding this, and retrieve it using a secure technique.
And finally of course we need the URL for the web API that we're calling. In this example we're just running locally so it's on localhost
.
// the GUID in the scope is from the Web API app registration
var scope = "api://50bad0fb-43f8-4599-a38e-faf49e40c7d0/.default";
var scopes = new[] { scope };
var tenantId = "750c540a-687c-4160-9d86-9433b49c9997";
// this GUID is the client id of the Client app registration
var clientId = "0d87ef2f-5291-4d41-8d6d-3efe2453b376";
// retrieve the secret securely (this example is from a LINQPad script)
var clientSecret = Util.GetPassword("client app registration secret");
var url = "https://localhost:7283/myAPI";
Next, we need to add a reference to the Azure.Identity
NuGet package.
And now we can use ConfidentialClientApplicationBuilder
to get a token.
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantId)
.WithClientSecret(clientSecret)
.Build();
var result = await app.AcquireTokenForClient(scopes)
.ExecuteAsync();
// note possible error: AADSTS70011 Invalid scope.
// The scope has to be of the form "https://resourceUrl/.default"
And having acquired a token, then we can pass the bearer token in the authorization header in the call to our web API like this:
var client = new HttpClient();
Console.WriteLine($"Calling {url}");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", result.AccessToken);
string json = await client.GetStringAsync(url);
Note that whenever you fetch access tokens, you should cache and reuse them until they expire, rather than fetching a new one for every request. ConfidentialClientApplicationBuilder
includes support for caching, and you can read recommendations for how to configure it here.
Bonus Step 5 - Fetching access tokens with the Azure CLI
The final thing I wanted to be able to do was call my Web API from a PowerShell script, and for that I thought it would be nice to see if I could use the az account get-access-token
command from the Azure CLI to fetch the token. Unfortunately this didn't work immediately, because you need to grant the Azure CLI permission to access the Web API app registration.
Here's the steps to set it up.
First, go to the App Registration for the Web API and in the "Expose an API" tab, you need to add a new scope. The name doesn't really matter - I called mine user_access
.
Then on the same tab, in the "Authorized Client Applications", select "Add a client application". For the client ID, you need to use 04b07795-8ddb-461a-bbee-02f9e1bf7b46
, which is the client ID of the Azure CLI, and then select the scope you created as an "Authorized scope".
Now in PowerShell, I can get an access token for the Web API, by providing my tenant ID and the scope for the Web API like this (obviously update this with your own tenant ID and web API client ID):
$TENANT = "750c540a-687c-4160-9d86-9433b49c9997"
$TOKEN = az account get-access-token --tenant $TENANT `
--scope "api://50bad0fb-43f8-4599-a38e-faf49e40c7d0/.default" `
--query "accessToken" -o tsv
If you want you can inspect the access token in jwt.io to see the information it includes.
And now we can construct a call to our WebAPI by passing in the token as a header:
$HEADERS = @{
Authorization="Bearer $TOKEN"
}
$RESP = Invoke-RestMethod -Method Get `
-Uri "https://localhost:7283/myAPI/" -Headers $HEADERS
Next steps
In this article I have walked through the process of configuring the App Registrations in the Azure Portal. Obviously if you used this setup in production you'd want to automate the creation of the App Registrations, which can be done with the az ad app
Azure CLI command (and presumably is possible to automate with Bicep, although I've not tried it). Maybe I'll follow this up in the future with some example automation code.
Summary
In this post, I went through the steps to secure a minimal ASP.NET Core Web API using Azure AD and calling it from a confidential client such as a console app. As I mentioned at the start, it took me longer than I hoped to get this working - it feels things are still more difficult than they ought to be (or at least easy to get wrong and hard to troubleshoot). Feel free to let me know in the comments whether you know of easier or better ways to set all this up.