One question I often get from by my customers is how to use Azure Active Directroy to protect their Node.js or .NET APIs.

Every single time I answer by redirecting them to this amazing post (Proteger una API en Node.js con Azure Active Directory), written in spanish, by my friend and peer Gisela Torres (0gis0).

Sometimes they come back with more questions:

  • How do we use Terrafrom to register the API and Client in Azure Active Directory?
  • How can we validate the scope in the Node.js API when using JwtStrategy strategy?
  • Can you provide a .NET application sample that performs the same validation and without boilerplate?

In this post what I’m going to do is give an answer to each of those 3 questions, based on Gisela’s code, and also create a PowerShell client to test your APIs:

Terraform script to register the API and Client with Azure Active Directory

This sample creates two Application Registrations. The first one (passport-client) will act as the client and the second one (passport-test-api) will be used to protect the Node.js and .NET APIs.

Create main.tf with the following contents:

  1terraform {
  2  required_version = "> 0.14"
  3  required_providers {
  4    azuread = {
  5      version = ">= 2.6.0"
  6    }
  7    azurerm = {
  8      version = ">= 2.80.0"
  9    }
 10  }
 11}
 12
 13provider "azurerm" {
 14  features {}
 15}
 16
 17data "azurerm_client_config" "current" {}
 18
 19// This is the AAD Application Registration for the API
 20resource "azuread_application" "api" {
 21  display_name    = "passport-test-api"
 22  identifier_uris = ["api://passport-test-api"]
 23
 24
 25  app_role {
 26    allowed_member_types = ["User"]
 27    description          = "ReadOnly roles have limited query access"
 28    display_name         = "ReadOnly"
 29    enabled              = true
 30    id                   = "497406e4-012a-4267-bf18-45a1cb148a01"
 31    value                = "User"
 32  }
 33
 34  // Add access to User.Read.All (Microsoft Graph)
 35  required_resource_access {
 36    resource_app_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
 37
 38    resource_access {
 39      id   = "df021288-bdef-4463-88db-98f22de89214" # User.Read.All
 40      type = "Role"
 41    }
 42  }
 43
 44  api {
 45    mapped_claims_enabled          = true
 46    requested_access_token_version = null
 47
 48    // Add our sample client as a known Application
 49    known_client_applications = [
 50      azuread_application.client.application_id
 51    ]
 52
 53    // This is the scope we'll validate in our APIs
 54    oauth2_permission_scope {
 55      admin_consent_description  = "Allow the application to access example on behalf of the signed-in user."
 56      admin_consent_display_name = "Access example"
 57      enabled                    = true
 58      id                         = "a7ef8bb6-5085-49a1-b803-517b5a439668"
 59      type                       = "User"
 60      value                      = "read"
 61    }
 62  }
 63}
 64
 65// This is the AAD Application Registration for the client.
 66resource "azuread_application" "client" {
 67  display_name = "passport-client"
 68
 69  // We'll be using a PowerShell client
 70  public_client {
 71    redirect_uris = [
 72      "http://localhost/",
 73    ]
 74  }
 75  
 76  api {
 77    known_client_applications      = []
 78    mapped_claims_enabled          = false
 79    requested_access_token_version = null
 80  }
 81
 82  // You can also use Gisela's web client
 83  web {
 84    redirect_uris = [
 85      "http://localhost:8000/give/me/the/code"
 86    ]
 87  }
 88}
 89
 90// Pre authorize our client
 91resource "azuread_application_pre_authorized" "pre_authorized" {
 92  application_object_id = azuread_application.api.object_id
 93  authorized_app_id     = azuread_application.client.application_id
 94  permission_ids        = ["a7ef8bb6-5085-49a1-b803-517b5a439668"]
 95}
 96
 97// You'll need the following output values to configure your application na use the PowerShell client
 98output "tenant_id" {
 99  description = "TENANT_ID"
100  value       = data.azurerm_client_config.current.tenant_id
101}
102
103output "api_client_id" {
104  description = "API CLIENT_ID"
105  value       = azuread_application.api.application_id
106}
107
108output "client_id" {
109  description = "client CLIENT_ID"
110  value       = azuread_application.client.application_id
111}
112
113output "powershell_command" {
114  value     = "./client.ps1 ${data.azurerm_client_config.current.tenant_id} ${azuread_application.client.application_id}"
115}

Deploy the Application Registrations:

Run the following commands:

1terraform init
2terraform apply

Add scope validation to Gisela’s passport / passport-jwt Node.js sample.

As mentioned by Gisela, the JwtStrategy expects configuration options (via a jwtOptions object) and also a callback function that can be used to validate the user, scope, etc…

Validating the scope:

I’ve modified the verify function in order to validate the scope against the value configured in the SCOPE environment variable.

 1const verify = (jwt_payload, done) => {
 2    console.log(`Signature is valid for the JSON Web Token (JWT), let's check other things...`);
 3    console.log(jwt_payload);
 4
 5    let tokenScope = `${jwt_payload.aud}/${jwt_payload.scp}`
 6    if (jwt_payload && jwt_payload.sub && process.env.SCOPE == tokenScope) {
 7        return done(null, jwt_payload);
 8    }
 9
10    return done(null, false);
11};

The full scope (tokenScope) is composed by jwt_payload.aud and jwt_payload.scp

Full Node.js API:

Now that you’ve learned how to validate the scope, the full Node.js API should look like this:

 1const express = require('express'),
 2    app = express();
 3
 4require('dotenv').config();
 5
 6//Modules to use passport
 7const passport = require('passport'),
 8    JwtStrategy = require('passport-jwt').Strategy,
 9    ExtractJwt = require('passport-jwt').ExtractJwt,
10    jwks = require('jwks-rsa');
11
12let jwtOptions = {
13    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
14    // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint.
15    secretOrKeyProvider: jwks.passportJwtSecret({
16        jwksUri: `https://login.microsoftonline.com/${process.env.TENANT_ID}/discovery/v2.0/keys`,
17    }),
18    algorithms: ['RS256'],
19    audience: process.env.AUDIENCE,
20    issuer: `https://sts.windows.net/${process.env.TENANT_ID}/`
21};
22
23const verify = (jwt_payload, done) => {
24    console.log(`Signature is valid for the JSON Web Token (JWT), let's check other things...`);
25    console.log(jwt_payload);
26
27    let tokenScope = `${jwt_payload.aud}/${jwt_payload.scp}`
28    if (jwt_payload && jwt_payload.sub && process.env.SCOPE == tokenScope) {
29        return done(null, jwt_payload);
30    }
31
32    return done(null, false);
33};
34
35passport.use(new JwtStrategy(jwtOptions, verify));
36
37app.get("/protected", passport.authorize('jwt', { session: false }), function (req, res) {
38    res.json({ message: "This message is protected" });
39});
40
41app.listen(1000, () => {
42    console.log(`API running on port 1000!`);
43});

Run the Node.js application:

Run the follwoing command:

1node main.ts

Create a PowerShell script to test the protected APIs.

In order to test the APIs we will create a PowerShell client.

Create a client.ps1 file with the following contents:

 1param(
 2    [Parameter(Mandatory=$true)]
 3    [string]
 4    $tenantId,
 5    [Parameter(Mandatory=$true)]
 6    [string]
 7    $clientId
 8)
 9
10if ((get-module MSAL.PS) -eq $null)
11{
12    echo "installing MSAL.PS"
13    Install-Module -Name MSAL.PS -Scope CurrentUser -AcceptLicense -Force 
14    # If you encounter this error:
15    # WARNING: The specified module 'MSAL.PS' with PowerShellGetFormatVersion '2.0' is not supported by the current version of PowerShellGet. 
16    # Get the latest version of the PowerShellGet module to install this module, 'MSAL.PS'
17    # Install as Admin:
18    # Install-PackageProvider NuGet -Force
19    # Install-Module PowerShellGet -Force
20}
21
22$scope = "api://passport-test-api/read"
23$redirectUri = "http://localhost"
24$url = "http://localhost:1000/protected"
25$token = Get-MsalToken -TenantId $tenantId -ClientId $clientId -Interactive -Scope $scope -RedirectUri $redirectUri
26
27echo "Please Complete Azure AD Login"
28echo ""
29echo "ID Token:"
30echo $($token.IDToken)
31echo ""
32echo "Bearer Token:"
33echo $($token.AccessToken)
34echo ""
35echo "Protect API Call:"
36curl -k -i -H "Authorization: Bearer $($token.AccessToken)" $url

The PowerShell client application uses the MSAL.PS module to get an AAD token and then call the protected API

Test the Node.js API:

To get the command to test the API run:

1terraform output powershell_command

The returned value should look like this:

1./client.ps1 <tenant_id> <application_id>"

Now run the given command and login with your credentials. The console output should show the following information:

  • ID Token
  • Bearer Token
  • The This message is protected message returned by the API.

Congratulations you just called a protected Node.js API using a PowerShell client.

Create a .NET API protected with AAD.

Run the following commands to create the .NET API:

1mkdir dotnet-sample
2cd dotnet-sample
3dotnet new web 
4dotnet add package Microsoft.Identity.Web -v 1.18.0

Also replace the applicationUrl in the dotnet-sample section of the Properties/launchSettings.json file with:

1"applicationUrl": "http://localhost:1000",

This will make the .NET application use the same port (1000) as the Node.js application.

Replace the contents of Program.cs with:

 1using Microsoft.AspNetCore.Hosting;
 2using Microsoft.AspNetCore;
 3using Microsoft.AspNetCore.Builder;
 4using Microsoft.Extensions.DependencyInjection;
 5using System.Text.Json;
 6using Microsoft.Identity.Web;
 7using Microsoft.Extensions.Configuration;
 8using System.IO;
 9using Microsoft.AspNetCore.Authentication.JwtBearer;
10using System.Collections.Generic;
11
12var config = new ConfigurationBuilder()
13                    .SetBasePath(Directory.GetCurrentDirectory())
14                    .AddJsonFile("appsettings.json")
15                    .AddEnvironmentVariables()
16                    .Build();
17
18WebHost.CreateDefaultBuilder().
19ConfigureServices(s =>
20{
21    s.AddSingleton(new JsonSerializerOptions()
22    {
23        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
24        PropertyNameCaseInsensitive = true,
25    });
26
27    s.AddMicrosoftIdentityWebApiAuthentication(config);
28
29    s.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
30    {
31        options.TokenValidationParameters.ValidAudiences = new List<string>() { config["Audience"] };
32    });
33}).
34Configure(app =>
35{
36    app.UseRouting();
37    app.UseAuthentication();
38    app.UseAuthorization();
39
40    app.UseEndpoints(e =>
41    {
42        e.MapGet("/protected",
43            async c =>
44            {
45                var serializerOptions = e.ServiceProvider.GetRequiredService<JsonSerializerOptions>();
46                var data = new { message = "This message is protected" };
47
48                c.Response.ContentType = "application/json";
49                await JsonSerializer.SerializeAsync(c.Response.Body, data, serializerOptions);
50            })
51            .RequireAuthorization()
52            .RequireScope("read");
53    });
54}).Build().Run();

.NET Top level programs are amazing!

Check the code to understand where does the audience and scope validation is performed.

Replace the contents of the appsettings.json file with:

 1{
 2  "Logging": {
 3    "LogLevel": {
 4      "Default": "Information",
 5      "Microsoft": "Warning",
 6      "Microsoft.Hosting.Lifetime": "Information"
 7    }
 8  },
 9  "AllowedHosts": "*",
10  "AzureAd": {
11    "Instance": "https://login.microsoftonline.com/",
12    "ClientId": "<web api client id>",
13    "Domain": "<tenant domain (i.e contoso.onmicrosoft.com)>",
14    "TenantId": "<tenant id>"
15  },
16  "Audience": "api://passport-test-api"
17}

Note: set the proper values for ClientId, Domain and TenantId in the AzureAd section.

Run the .NET application:

Make sure you stop the Node.js application.

Run the following command:

1dotnet run

Test the .NET API:

To get the command to test the API run:

1terraform output powershell_command

The returned value should look like this:

1./client.ps1 <tenant_id> <application_id>"

Now run the given command and login with your credentials. The console output should show the following information:

  • ID Token
  • Bearer Token
  • The This message is protected message returned by the API.

Congratulations you just called a protected .NET API using a PowerShell client.

Hope it helps!!!

Please find the complete samples here

References: