DEV Community

loading...
The 425 Show

Secure APIs using Node.js, Azure AD, Cosmos DB and the Azure SDKs

christosmatskas profile image Christos Matskas ・8 min read

With the recent announcement that Cosmos DB now supports RBAC and Azure AD authentication, I was too excited to pass on the opportunity to build an API that takes advantage of these new capabilities. By adding support for Azure AD authentication, we can now use the Azure SDKs to securely access Cosmos DB data without having to provide any keys or secrets in our code. Instead, when instantiating a new CosmosClient object we can pass a TokenProvider curtesy of the Azure.Identity library and let the token provider handle the authentication to our Cosmos DB resource. Secretless apps is the future and I love how all this is powered by Azure AD behind the scenes.

Finally, to make our API more secure, we’ll add Azure AD authentication so that only authorized calls can call our API endpoints. Let’s build this

Prerequisites

To follow along with this blog or run the code sample you will need the following:

  • An active Azure Subscription (get one for free here)
  • An Azure Active Directory (you can use the one in your Azure subscription or get a free one using the Microsoft 365 Developer program)
  • VS Code
  • Node.js and NPM (install from here
  • TypeScript (install globally from here)

Create the project

Open the command prompt of your choice and type the following

Mkir securenodeapiwithaad
Cd securenodeapiwithaad
Npm init -y
Enter fullscreen mode Exit fullscreen mode

Next, type code . to open our project in VS Code and using the **Explorer **create a tsconfig.json file and add the following:

{
    "compilerOptions": {
        "target":"ES5",
        "sourceMap": true,
        "module": "commonjs",
        "outDir": "out"
    },
    "exclude": [
        "node_modules"
    ]
}
Enter fullscreen mode Exit fullscreen mode

This tells VS Code how to deal with TypeScript and where to output our compiled code (in the out directory). Make sure to create a new out directory at the root of the project so that the output files can be transpiled into.

We can now add our first (and only) file where our API code will reside. In the Solution view add a new file index.ts at the root of the project.

Finally, we need to create a launch.json using the instructions found here. Although this is not needed to run the Node.js code, I love the fact that VS can launch and debug our code. So if you, like me, want to be able to step through your code, add a launch.json. If all’s done correctly, your launch.json should look similar to this

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "pwa-node",
            "request": "launch",
            "name": "Launch API",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}\\index.ts",
            "preLaunchTask": "tsc: build - tsconfig.json",
            "outFiles": [
                "${workspaceFolder}/out/**/*.js"
            ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

This is a Node.js project so we should add some NPM packages! Open the command prompt at the same directory as the project and type the following:

npm i @azure/identity
npm i @azure/cosmos
npm i express
npm i jsonwebtoken
npm i jwks-rsa,
npm i uuid
npm i @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Open index.ts and paste all the code as listed below:

import jwt = require('jsonwebtoken');
import jwksClient = require('jwks-rsa');
import {
    AzureCliCredential,
    ChainedTokenCredential,
    ManagedIdentityCredential,
    VisualStudioCodeCredential
} from "@azure/identity";
import express = require('express');
import { CosmosClient, Container, Item } from "@azure/cosmos";

const SERVER_PORT = process.env.PORT || 8000;
const jwtKeyDiscoveryEndpoint = "https://login.microsoftonline.com/common/discovery/keys";
const cosmosEndpoint = "https://msi-auth-test.documents.azure.com";
const credential = new ChainedTokenCredential(
    new AzureCliCredential(),
    new ManagedIdentityCredential()
);
let accessToken;
const cosmosClient = new CosmosClient({ 
    endpoint: cosmosEndpoint, 
    aadCredentials: credential 
});

const validateJwt = (req, res, next) => {
    const authHeader = req.headers.authorization;
    if (authHeader) {
        const token = authHeader.split(' ')[1];

        const validationOptions = {
            audience: config.auth.clientId,
            issuer: `${config.auth.authority}/v2.0`
        }

        jwt.verify(token, getSigningKeys, validationOptions, (err, payload) => {
            accessToken = payload;
            if (err) {
                console.log(err);
                return res.sendStatus(403);
            }
            next();
        });
    } else {
        res.sendStatus(401);
    }
};

const getSigningKeys = (header, callback) => {
    var client = jwksClient({
        jwksUri: jwtKeyDiscoveryEndpoint
    });

    client.getSigningKey(header.kid, function (err, key) {
        var signingKey = key.getPublicKey();
        callback(null, signingKey);
    });
};

function confirmRequestHasTheRightScope(scopes:Array<string>): boolean{
    const tokenScopes:Array<string> = accessToken.scp.split(" ");
    scopes.forEach(scope => {
        if(!tokenScopes.includes(scope)){
            return false;
        }
    });
    return true;
}

const config = {
    auth: {
        clientId: "<your Azure AD App Registration Client ID",
        tenantId: "<your  Azure AD Tenant ID>",
        authority: "https://login.microsoftonline.com/<your Azure AD Tenant ID>",
    }
};

// Create Express App and Routes
const app = express();

app.get('/', (req, res)=>{
    var data = {
        "endpoint1": "/getvolcanodata?volcanoname=<name>",
        "endpoint2": "/getCosmosData"
    };
    res.send(data); 
})

app.get('/getCosmosData', validateJwt, async (req, res) => {
    const data = await getCosmosData();
    res.status(200).send(data);
});

app.get('/getVolcanoData', validateJwt, async(req, res)=> {
    const data = await getVolcanoDataByName(req.query.volcanoname.toString());
    res.status(200).send(data);
});

app.listen(SERVER_PORT, () => console.log(`Secure Node Web API listening on port ${SERVER_PORT}!`))

async function getVolcanoDataByName(volcanoName: string): Promise<Array<string>> {
    const container = cosmosClient.database('VolcanoList').container('Volcano');
    const results = await container.items
        .query({
            query: "SELECT * FROM Volcano f WHERE  f.VolcanoName = @volcanoName",
            parameters: [{ name: "@volcanoName", value: volcanoName }]
        })
        .fetchAll();
    return results.resources;
}

async function getCosmosData(): Promise<Array<any>> {
    try {
        let data: any[] = [];
        const container = cosmosClient.database('VolcanoList').container('Volcano');
        const results = await container.items.readAll().fetchAll();
        //get the first 10 items
        let index = 0;
        while (index < 10) {
            data.push(results.resources[index]);
            index++;
        };
        return data;
    }
    catch (error) {
        console.error(error);
    }
    return [];
};
Enter fullscreen mode Exit fullscreen mode

Let’s kick off the debugger and run the API in VS Code to ensure that there are no errors in our code.

Alt Text

At the moment, we have 3 endpoints:

  1. Home: “/”
  2. Get data for a specific volcano: “/getvolcanodata?volcanoname=”
  3. Get data for all the volcanos: “/getCosmosData”

Endpoint 2 and 3 expect a valid Access Token so calling them without one or without a valid one will result in a 401 or 403 error message respectively.

Configure the Azure Service Principal

Since we’re still running on our local environment (outside of Azure) where Managed Identities are not supported, we need to provide the Azure.Identity with some credential so that we can authenticate and access our Cosmos DB data securely. The current code expects an AzureCLICredential. We can use a service principal account (an Azure AD service account) to sign in to our local Azure CLI. But first we need to create one.
In the Azure CLI type the following:

az login
az ad sp create-for-rbac
Enter fullscreen mode Exit fullscreen mode

This should result to the following:

{
  "appId": "1b47786f-2a57-0000-0000-d224665dc135",
  "displayName": "azure-cli-2021-04-21-23-00-17",
  "name": "http://azure-cli-2021-04-21-23-00-17",
  "password": "very secret password",
  "tenant": "e801a3ad-3690-0000-0000-1d77cb360b07"
}
Enter fullscreen mode Exit fullscreen mode

Make a note of these values, especially the appId and password values as we’ll use these credentials later to sign in to our local Azure CLI. Before we move on to the next task, let’s grab the **Object ID **of our SP while we’re at it. In the CLI, type

az ad sp show --id <appId>
Enter fullscreen mode Exit fullscreen mode

Next, we need to configure this SP with the right RBAC permissions in Cosmos DB. First open a text editor and create a new json file. Name it role-definition.json. Paste the following text

{
    "RoleName": "CMReadOnlyRole",
    "Type": "CustomRole",
    "AssignableScopes": ["/"],
    "Permissions": [{
        "DataActions": [
            "Microsoft.DocumentDB/databaseAccounts/readMetadata",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
            "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed"
        ]
    }]
}
Enter fullscreen mode Exit fullscreen mode

This file contains a Read Only role definition that we will create and assign to our service principal. Save the file. Navigate to this directory in your shell and type the following:

resourceGroupName='<myResourceGroup>'
accountName='<myCosmosAccount>'
az cosmosdb sql role definition create --account-name $accountName --resource-group $resourceGroupName --body @role-definition.json
Enter fullscreen mode Exit fullscreen mode

Once that’s completed, verify that the role was created successfully:

az cosmosdb sql role definition list --account-name $accountName --resource-group $resourceGroupName
Enter fullscreen mode Exit fullscreen mode

The result should look like this. Make a not of the role ID as we’ll need it for the assignment.

[
  {
    "assignableScopes": [
      "/subscriptions/d8011108-23b2-40d8-8bc4-1f3f77abe795/resourceGroups/identity/providers/Microsoft.DocumentDB/databaseAccounts/az-fun-demo-cm"
    ],
    "id": "/subscriptions/d8011108-23b2-40d8-8bc4-1f3f77abe795/resourceGroups/identity/providers/Microsoft.DocumentDB/databaseAccounts/az-fun-demo-cm/sqlRoleDefinitions/664d69f9-a68f-4dde-8c59-8609fdb65ae4",
    "name": "664d69f9-a68f-4dde-8c59-8609fdb65ae4",
    "permissions": [
      {
        "dataActions": [
          "Microsoft.DocumentDB/databaseAccounts/readMetadata",
          "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
          "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery",
          "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed"
        ],
        "notDataActions": []
      }
    ],
    "resourceGroup": "identity",
    "roleName": "CMReadOnlyRole",
    "sqlRoleDefinitionGetResultsType": "CustomRole",
    "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions"
  }
]
Enter fullscreen mode Exit fullscreen mode

To make it all work need to assign this role to our service principal. In the CLI, type the following:

readOnlyRoleDefinitionId = '<roleDefinitionId>'  // the is the guid of the role definition
principalId = '<aadPrincipalId>' // this is the object ID of our Service Principal
az cosmosdb sql role assignment create --account-name $accountName --resource-group $resourceGroupName --scope "/" --principal-id $principalId --role-definition-id $readOnlyRoleDefinitionId
Enter fullscreen mode Exit fullscreen mode

The result of this command should look like this:

{\ Finished …
  "id": "/subscriptions/d8011108-23b2-40d8-8bc4-1f3f77abe795/resourceGroups/identity/providers/Microsoft.DocumentDB/databaseAccounts/az-fun-demo-cm/sqlRoleAssignments/24a3c9c0-9193-46fa-a861-04ac918f4214",
  "name": "24a3c9c0-9193-46fa-a861-04ac918f4214",
  "principalId": "ebb15145-0390-4c65-b4d8-74ef8891bda9",
  "resourceGroup": "identity",
  "roleDefinitionId": "/subscriptions/d8011108-23b2-40d8-8bc4-1f3f77abe795/resourceGroups/identity/providers/Microsoft.DocumentDB/databaseAccounts/az-fun-demo-cm/sqlRoleDefinitions/664d69f9-a68f-4dde-8c59-8609fdb65ae4",
  "scope": "/subscriptions/d8011108-23b2-40d8-8bc4-1f3f77abe795/resourceGroups/identity/providers/Microsoft.DocumentDB/databaseAccounts/az-fun-demo-cm",
  "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments"
}
Enter fullscreen mode Exit fullscreen mode

Finally, type the following to login to our local Azure CLI with the Service Principal account we created above:

az login --service-principal -u <appId> -p <password> --tenant <tenantId>
Enter fullscreen mode Exit fullscreen mode

This should result to the following json output

[
  {
    "cloudName": "AzureCloud",
    "homeTenantId": "e801a3ad-0000-0000-0000-1d77cb360b07",
    "id": "d8011108-0000-0000-0000-1f3f77abe795",
    "isDefault": true,
    "managedByTenants": [],
    "name": "Visual Studio Enterprise",
    "state": "Enabled",
    "tenantId": "e801a3ad-0000-0000-0000-1d77cb360b07",
    "user": {
      "name": "1b47786f-0000-0000-0000-d224665dc135",
      "type": "servicePrincipal"
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

At this point we have everything we need to authenticate with the service principal and for our code to use these credentials in order to call into Cosmos DB – without any secrets!!!

Create the Azure AD App Registrations

For our API to be secure, we need an App Registration in Azure AD. This will be used by our API to validate incoming tokens from client/front-end apps that call into the API. By configuring this we will have a secure end-to-end channel for retrieving data from the API since we don’t want anyone to come in and call our API endpoints.

In Azure AD, navigate to the App Registration tab and create a new one. Give it a name and press Register. Then go to the Expose an API and add a new claim: access_cosmos_data. Copy the Client ID and Tenant ID and update your code accordingly.

We also need to create a client app registration so that we can acquire an access token and call our API securely. In the App Registrations, create a new App Registration. Give it a name and press Register.

In the Authentication tab, press Add a platform, select Web and type `http://localhost’. In the Secrets and Certificates tab, create a new Secret and make sure you copy the value as you won’t be able to get the secret once you navigate away. In the API Permissions tab, add a new permission, select My APIs and select the App Registration you created for your API. Check the permission checkbox and press Add permission.

That’s all we need from Azure AD!

Let’s test it

To test that the API works as expected, we can use Postman. We need to use the values from the second AAD App Registration to populate the Authorization section in Postman to get an access token for the API. Make sure to start the API in VS Code and test that everything’s working as expected. If all the steps were followed successfully, we should be treated with a bunch of CosmosDB data as per the example below:

Alt Text

Source Code

You can get find a fully working sample in this GitHub repo

Discussion (2)

pic
Editor guide
Collapse
wparad profile image
Warren Parad

That seems like it adds a lot of complexity to the service. Additionally, while the identity part is there, you are verifying the authenticity of the caller identity, you never make sure that the user should actually have access to read the data in the database. You probably want to add an application IAM permissions layer to your app.

It's so much simpler to integrate a working auth solution that contains everything you need rather than trying to build it up, and potentially not including critical security components. Depending on the end goal, there are many different auth solutions.

Collapse
christosmatskas profile image
Christos Matskas Author

It seems like you didn’t understand the blog. The whole point is to remove the need to use secrets or keys from your solution while having the ability to use token claims for fine tuning authorization within the API. A working AuthN solution would work but you lose a lot of control within your app. For us, security is paramount and our goal is to help developers write more robust, secure software. Thanks for reading