Introduction
This post is about how to deal with OAuth2 and the usage of JWT (JSON Web Tokens) in a non-trivial system consisting of rich clients and web APIs built using Microsoft .NET 6.
Suppose we have the following architecture:
--> Web API 2
/
Client app --> Web API 1 --
\
--> Web API 3
So we have a “front-end” or “composition” API that is dependent on other APIs. The challenge here is that all (or some) of these APIs require a user token, so we must enable API 1 to call API 2 and API 3 on behalf of a user.
My first take on this was to create a token in the client app and let API 1 validate the token and then forward it to other APIs. But that doesn’t work as a general solution if different APIs require different scopes since you cannot mix scopes for different resources in the same token. (Well, you can, but the validation will fail.)
It actually turned out that, using the Microsoft Identity libraries, it is not that hard to have the front API create new tokens on behalf of users and cache them. I will describe how to develop and configure such a solution in this post. An important prerequisite is that Azure Active Directory is used for identity.
Back-end API
Application Registration
To protect the back-end API (API 2 and 3 above) we must first add an app registration in Azure Active Directory and configure it accordingly:
Authentication Section
Redirect URIs: (none)
Front-channel logout URL: (none)
Implicit grant and hybrid flows:
- Access tokens: false
- ID tokens: false
Allow public client flows: No
Expose an API Section
Scopes defined by this API
Scopes | Who can consent | Admin consent display name | User consent display name | State |
---|---|---|---|---|
api://{application/client ID}/access_as_user | Admins and users | Access the API on behalf of a user | Access the API on your behalf | Enabled |
Authorized client applications: Add the applications that are authorized to access this API, in our case API 1.
Code
Codewise, the template code is what we need. In order to use that, we need the following NuGet packages:
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.AspNetCore.Authentication.OpenIdConnect
Microsoft.Identity.Web
During start-up, we need to add some service registrations:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
We also need to configure the request pipeline:
// Must be before app.MapControllers():
app.UseAuthentication();
app.UseAuthorization();
In appsettings.json, we have:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "(tenant GUID or domain, e.g. yourcompany.onmicrosoft.com)",
"ClientId": "(client ID GUID of this API in Azure Active Directory app registration)"
},
"RequiredScope": "access_as_user"
With this, no authentication or authorization is actually required. We can put the [Authorize]
attribute on the appropriate endpoints or controllers, but to reduce the risk of forgetting that somewhere, I prefer to have it by default. We can do that during startup:
services.AddAuthorization(options =>
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireScope(Configuration.GetValue<string>("RequiredScope"))
.Build());
// The above means that endpoints without [Authorize] attribute etc get this policy.
Then, use the [AllowAnonymous]
attribute where authentication and authorization is not required.
Front-End API
This the tricky one. What is needed is fairly well documented once you find it: Scenario: A web API that calls web APIs.
Application Registration
We need to create one app registration for the front-end API:
Authentication Section
Redirect URIs: (none)
Front-channel logout URL: (none)
Implicit grant and hybrid flows:
- Access tokens: false
- ID tokens: false
Allow public client flows: No
Expose an API Section
Scopes defined by this API
Scopes | Who can consent | Admin consent display name | User consent display name | State |
---|---|---|---|---|
api://{application/client ID}/access_as_user | Admins and users | Access the API on behalf of a user | Access the API on your behalf | Enabled |
Authorized client applications: Add the applications that are authorized to access this API, in our case the client app.
Code
We need to enable token acquisition to call downstream APIs (API 2 and 3) and we do that during service registration:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddInMemoryTokenCaches();
For this to work, we must configure a client secret or certificate:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "(tenant GUID or domain, e.g. yourcompany.onmicrosoft.com)",
"ClientId": "(client ID GUID of this API)"
// To call an API use either ClientSecret or ClientCertificates:
"ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
"ClientCertificates": [
{
"SourceType": "KeyVault",
"KeyVaultUrl": "https://msidentitywebsamples.vault.azure.net",
"KeyVaultCertificateName": "MicrosoftIdentitySamplesCert"
}
]
}
In each endpoint that calls the downstream API, we can inject a Microsoft.Identity.Web.ITokenAcquisition
instance and use that for getting a token:
var token = await tokenAcquisition.GetAccessTokenForUserAsync(new[] { $"api://{downstream API client ID}/{downstream API scope}" });
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
// or
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
There are even more clever techniques that might work for documented in A web API that calls web APIs: Call an API.
Client App
The client app in my example is a rich client (WPF).
Application Registration
Authentication Section
Mobile and desktop applications
Redirect URIs
- https://login.microsoftonline.com/common/oauth2/nativeclient
- msal{client ID}://auth
- ms-appx-web://microsoft.aad.brokerplugin/{client ID}
(Notice that the Application (client) ID is part of the last two.)
Allow public client flows: No
Code
Here, we reference the Microsoft.Identity.Client
NuGet library and set target framework to net6.0-windows10.0.17763.0
or higher.
In App.xml.cs, add a method that creates and returns an Microsoft.Identity.Client.IPublicClientApplication that can be registered in the dependency injection container:
private static Microsoft.Identity.Client.IPublicClientApplication CreateIdentityClient()
{
var builder = Microsoft.Identity.Client.PublicClientApplicationBuilder.Create("{client ID}")
.WithAuthority(Microsoft.Identity.Client.AzureCloudInstance.AzurePublic, "{tenant, e.g. mycompany.onmicrosoft.com)
.WithDefaultRedirectUri()
.WithBroker();
return builder.Build();
}
To create a token, use something along the lines of:
AuthenticationResult authenticationResult;
var scopes = new[] { $"api://{API client ID}/{API scope}" };
var account = PublicClientApplication.OperatingSystemAccount;
try
{
authenticationResult = await publicClientApplication
.AcquireTokenSilent(scopes, account)
.ExecuteAsync();
}
catch (MsalUiRequiredException ex)
{
logger.LogError(ex, "Could not acquire token silently.");
// A MsalUiRequiredException happened on AcquireTokenSilent.
// This indicates you need to call AcquireTokenInteractive to acquire a token
authenticationResult = await publicClientApplication.AcquireTokenInteractive(api.Scopes)
.WithAccount(account)
.WithPrompt(Prompt.SelectAccount)
.ExecuteAsync();
}