I'm working through Les Jackson's The Complete ASP.NET Core 3 API Tutorial, and I'm stuck. I'm in the chapter 14, "Securing Our API".
What we're supposed to be doing, in this chapter, is configuring the API we've constructed to accept JWT tokens from Azure Active Directory, and then to build a client that gets a JWT token from Azure Active Directory and then includes it as a custom header in calls against the API.
At this point, we're still running the API on localhost, hosting it on Azure comes next.
In all of this I'm using my Default Directory in Azure.
Adding Azure AD JWT to the API:
The chapter lists the following steps:
- Register our API in Azure AD
- Expose our API in Azure
- Update our API Manifest
- Add additional configuration elements
- Add new package references
- Update API project source code
I've completed #1 and #2, and when I view my API I see:
Display name: CommandAPI_DEV
Application (client) ID: 1e994557-5ae1-47bf-8ab7-b0ce2f8f3852
Object ID: b8225518-eba3-4a6a-8c30-ae82095a4ba7
Directory (tenant) ID: 9f9fbb85-6a89-4fac-a52a-845135fbe887
Application ID URI: api://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852
Managed application in local directory: CommandAPI_DEV
For #3 I've added to the manifest:
"appRoles": [
{
"allowedMemberTypes": [
"Application"
],
"description": "Daemon apps in this role can consume the web api.",
"displayName": "DaemonAppRole",
"id": "be111a2a-ea62-47a9-8f55-5d8f84af3276",
"isEnabled": true,
"lang": null,
"origin": "Application",
"value": "DaemonAppRole"
}
],
For #4, I've created the following User Secrets:
{
"UserID": "cmddbuser",
"TenantId": "9f9fbb85-6a89-4fac-a52a-845135fbe887",
"ResourceId": "app://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852",
"Password": "pa55w0rd!",
"Instance": "https://login.microsoftonline.com/",
"Domain": "jdegejdege.onmicrosoft.com",
"ClientId": "1e994557-5ae1-47bf-8ab7-b0ce2f8f3852"
}
#5 is just adding the NuGet packages. For #6, I've added to ConfigureServices():
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.Audience = Configuration["ResourceId"];
opt.Authority = $"{Configuration["Instance"]}{Configuration["TenantId"]}";
})
;
Note that this opt.Audience to:
"app://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852"
And opt.Authority to:
"https://login.microsoftonline.com/9f9fbb85-6a89-4fac-a52a-845135fbe887"
where these values are stored in User Secrets, but are copied from the "Essentials" section of the apps page on Azure AD.
Then I've added to Configure():
app.UseAuthentication();
app.UseAuthorization();
And I've added [Authorize]
to one of my controller endpoints.
With all of that, everything the API project works fine.
Building the client:
The chapter lists the following steps:
- Register client application in Azure AD
- Create a client secret in Azure
- Configure client API permissions
- Code up our client app
So I did #1, and when I view the application in Azure AD I see:
Display name: CommandAPI_Client_DEV
Application (client) ID: d32007a5-642d-413a-82d9-4761e3030890
Object ID: 94d13c40-5556-4e12-aa2c-be9619681712
Directory (tenant) ID: 9f9fbb85-6a89-4fac-a52a-845135fbe887
Application ID URI: Add an Application ID URI
Managed application in local directory: CommandAPI_Client_DEV
Then I did #2, creating a client secret. When I did, the UI displayed three fields:
Description: CommandAPI_Client_DEV_secret
Value: NTK*****************************
Secret ID: 954fa788-c8b8-4265-97d7-a1bd83be3bcf
(When I created the secret, it displayed 37 characters. When I go back I just see the first three followed by asterisks.)
Then for #3, I went into API permissions, added a new permission to my API, then granted admin consent.
I did not get a Microsoft authentication popup, but the Azure UI showed the permission's status as "Granted for Default Directory".
For #4 I created a simple console app, putting this in appsettings.json:
{
"Instance": "https://login.microsoftonline.com/{0}",
"TenantId": "9f9fbb85-6a89-4fac-a52a-845135fbe887",
"ClientId": "d32007a5-642d-413a-82d9-4761e3030890",
"ClientSecret": "NTK*****************************",
"BaseAddress": "https://localhost:5001/api/Commands/1",
"ResourceId": "api://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852/.default"
}
Note that "TenantId" matches "Directory (tenant) ID" from the client app's configuration in Azure AD.
"ClientId" matches "Application (client) ID" from the client app's configuration in Azure AD.
"ClientSecret" matches the value field I was given when I created the secret.
And "ResourceId" matches the "Application ID URI" field from the API's configuration in Azure AD.
The client app, then, is pretty straightforward.
Main calls RunAsync():
static void Main(string[] args)
{
Console.WriteLine("Making the call...");
RunAsync().GetAwaiter().GetResult();
}
private static async Task RunAsync()
{
AuthConfig is a simple wrapper for our settings in appsettings.json. config.Authority is
AuthConfig config = AuthConfig.ReadFromJsonFile("appsettings.json");
var authority = String.Format(CultureInfo.InstalledUICulture, config.Instance, config.TenantId);
Console.WriteLine($"Authority: {authority}");
We call into Azure AD to get our JWT:
IConfidentialClientApplication app;
app = ConfidentialClientApplicationBuilder
.Create(config.ClientId)
.WithClientSecret(config.ClientSecret)
.WithAuthority(new Uri(authority))
.Build();
string[] ResourceIds = new string[] { config.ResourceId };
AuthenticationResult result = null;
try
{
result = await app.AcquireTokenForClient(ResourceIds).ExecuteAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Token acquired\n");
Console.WriteLine(result.AccessToken);
Console.ResetColor();
}
catch (MsalClientException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(ex.Message);
Console.ResetColor();
}
if (!string.IsNullOrEmpty(result.AccessToken))
{
Then we set the JWT as a bearer token:
var httpClient = new HttpClient();
var defaultRequestHeaders = httpClient.DefaultRequestHeaders;
if (defaultRequestHeaders.Accept == null ||
!defaultRequestHeaders.Accept.Any(m => m.MediaType == "application/json"))
{
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", result.AccessToken);
And call the secure endpoint of our API:
HttpResponseMessage response = await httpClient.GetAsync(config.BaseAddress);
if (response.IsSuccessStatusCode)
{
Console.ForegroundColor = ConsoleColor.Green;
string json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Failed to call the Web API: {response.StatusCode} ");
string content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Content: {content}");
}
Console.ResetColor();
}
}
And when I do I get a valid JWT, which decodes as:
{
"typ": "JWT",
"alg": "RS256",
"x5t": "l3sQ-50cCH4xBVZLHTGwnSR7680",
"kid": "l3sQ-50cCH4xBVZLHTGwnSR7680"
}.{
"aud": "api://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852",
"iss": "https://sts.windows.net/9f9fbb85-6a89-4fac-a52a-845135fbe887/",
"iat": 1636246957,
"nbf": 1636246957,
"exp": 1636250857,
"aio": "E2ZgYLhnIvUl2 2Z4Jno3ebX7pVvAAA=",
"appid": "d32007a5-642d-413a-82d9-4761e3030890",
"appidacr": "1",
"idp": "https://sts.windows.net/9f9fbb85-6a89-4fac-a52a-845135fbe887/",
"oid": "cc6633e2-df64-41be-bbb2-de750766e40a",
"rh": "0.AUYAhbufn4lqrE-lKoRRNfvoh6UHINMtZDpBgtlHYeMDCJCAAAA.",
"roles": [
"DaemonAppRole"
],
"sub": "cc6633e2-df64-41be-bbb2-de750766e40a",
"tid": "9f9fbb85-6a89-4fac-a52a-845135fbe887",
"uti": "tgVrQAPepkiMGHO04uHdAA",
"ver": "1.0"
}.[Signature]
But when I make the call against the API, httpClient.GetAsync() returns 401, with the WwwAuthenticate header set to:
{
Bearer error="invalid_token",
error_description="The audience 'api://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852' is invalid"
}
Obviously I'm doing something wrong, but what?
CodePudding user response:
The issue seems to be the mismatch between what the token issues sets the aud
field as, which is "api://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852"
and what your secure API is expecting the audience to be, which is "app://1e994557-5ae1-47bf-8ab7-b0ce2f8f3852"
.
You need to set the audience to api...
in step #4 of building the secure API for this to work.