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 byjwt_payload.aud
andjwt_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:
Comments