Musings from the Scribes

Managed Identity Authentication with Azure REST APIs and Azure Container Apps

Written by Matthew Nixon | May 6, 2024 7:30:36 AM

As part of an engagement with a client, I had to write guidance around using Managed Identity when interacting directly with Azure REST APIs on Azure Container Apps. Turns out - this wasn’t as easy as I predicted. Nor was the Microsoft documentation for this as comprehensive as I would like.

This process differs from Managed Identity authentication on an Azure VM. A garden path I went naively down…

Read this article if you want the background for what comes next: Managed identities in Azure Container Apps

These steps pertain to testing code that has 2 flows:

  1. When run locally, az cli commands are called to fetch an authentication token.

  2. When run on an Azure Container App/Job instance, the local identity endpoint is invoked and a token is returned.

Pre-requisites for developer testing:

  1. Create Azure Storage and ensure it is not restricted in network access. As this is Enabled from All Networks, take care with PIID or sensitive data as ordinarily you wouldn’t allow storage like this to accessed from any network.

  2. Create a container and put a file in it.

  3. Grant yourself Storage Blob Data Owner permissions

  4. See the demo code at the bottom of this article.
  5. Ensure az cli is installed on your machine.

      1. Ensure you are logged in and the subscription is set to the same as the Storage above. e.g.
        az account set --subscription
  6. Update these 2 variables with the information from above

     
     
  7. Run these commands in the console
    pnpm i
    pnpm run build
    pnpm start

    This will connect to the store by fetching a token from this command:
    az account get-access-token --resource https://storage.azure.com/ --query accessToken -o tsv

    It should list the contents of the container in raw XML, e.g:

    Full Container Object: <?xml version="1.0" encoding="utf-8"?><EnumerationResults ServiceEndpoint="https://arkdemodemotest.blob.core.windows.net/" ContainerName="democontainer"><Blobs><Blob><Name>hi.txt</Name><Properties><Creation-Time>Fri, 12 Apr 2024 06:09:23 GMT</Creation-Time><Last-Modified>Fri, 12 Apr 2024 06:09:23 GMT</Last-Modified><Etag>0x8DC5AB7197BA857</Etag><Content-Length>0</Content-Length><Content-Type>text/plain</Content-Type><Content-Encoding /><Content-Language /><Content-CRC64 /><Content-MD5>1B2M2Y8AsgTpgAmY7PhCfg==</Content-MD5><Cache-Control /><Content-Disposition /><BlobType>BlockBlob</BlobType><AccessTier>Hot</AccessTier><AccessTierInferred>true</AccessTierInferred><LeaseStatus>unlocked</LeaseStatus><LeaseState>available</LeaseState><ServerEncrypted>true</ServerEncrypted></Properties><OrMetadata /></Blob></Blobs><NextMarker /></EnumerationResults>
  8. You’ll notice if NODE_ENV is set to Production, it will attempt to fetch a token from an identity endpoint based on environment variables made available to Azure Container Apps. AZ CLI is of course not available in these containers.

  9. Here is a Manually invoked Azure Container App Job with a managed identity assigned:


    Ensure your User Assigned Managed Identity has the appropriate permissions, same as step 3:



    Here you can see it fetching a token against calling: http://localhost:42356/msi/token?api-version=2019-08-01&resource=https://storage.azure.com/&client_id=07a42cba-009b-4bce-b895-4fd940e4643f



    Take note we must pass in the client_id of the generated Managed Identity, i.e.


    This example is from my Arkahna subscription:

     

Appendix:


index.ts:


import dotenv from "dotenv";
import { exec } from 'child_process';
import { promisify } from 'util';
import { parseStringPromise } from 'xml2js'; 

dotenv.config();

const execAsync = promisify(exec);

// Replace these with your Storage account details
const accountName = 'your_account_here';
const containerName = 'your_container_here';
const managed_identity_client_id = 'client_id_guid_here'

const getAzCliBlobAccessToken = async () => {
    try {
      // Command to get the access token
      const command = 'az account get-access-token --resource https://storage.azure.com/ --query accessToken -o tsv';
      
      // Execute the command
      const { stdout, stderr } = await execAsync(command);
      
      if (stderr) {
        console.error('Error:', stderr);
        return null;
      }
      
      // Output the access token
      if (stdout) {
      const output = stdout as unknown as string;
      const accessToken = output.trim();
      console.log('Access Token:', accessToken);
      return accessToken;
    }
    } catch (error) {
      console.error('Execution error:', error);
      return null;
    }
  };
  
const getAuthToken = async () => {
    // IDENTITY_ENDPOINT and IDENTITY_HEADER are supposed to be provided for us
    // see: https://learn.microsoft.com/en-us/azure/container-apps/managed-identity?tabs=cli%2Chttp
    const url = `${process.env.IDENTITY_ENDPOINT}?api-version=2019-08-01&resource=https://storage.azure.com/&client_id=${managed_identity_client_id}`;
    const identityHeader = process.env.IDENTITY_HEADER || ''
    const headers = {
        'X-IDENTITY-HEADER': identityHeader        
    };

    try {
        console.log(`Calling ${url}`)
        console.log(`X-IDENTITY-HEADER is ${identityHeader}`)
        const response = await fetch(url, { method: 'GET', headers: headers });
        if (!response.ok) {
            throw new Error(`Failed to fetch token: ${response.statusText}`);
        }
        const data = await response.json();
        return data.access_token;
    } catch (error) {
        console.error('Error obtaining auth token:', error);
        return null;
    }
};

const listBlobs = async () => {
    console.log('--------------- Azure REST Auth Demo --------------- ')

    var local = false
    if (typeof process.env.NODE_ENV === 'undefined' || process.env.NODE_ENV !== 'production') {
        local = true
    }        

    var token

    if (!local) {
        console.log('Running in Azure Container App');
        token = await getAuthToken();
    } else {
        token = await getAzCliBlobAccessToken()
    }
    // obviously in a real app don't log tokens to console, this is visibility
    console.log(`token is ${token}`)

    const url = `https://${accountName}.blob.core.windows.net/${containerName}?restype=container&comp=list`;

    try {
        const response = await fetch(url, {
            method: 'GET',
            headers: {
                'Authorization': `Bearer ${token}`,
                'x-ms-version': '2020-02-10',
            },
        });

        if (!response.ok) {
            throw new Error(`Failed to list blobs: ${response.statusText}`);
        }

        // NOTE, this service is XML!
        const xml = await response.text();

        try {
            const result = await parseStringPromise(xml);


        if (result.EnumerationResults) {
            const blobs = result.EnumerationResults.Blobs[0].Blob;
            // Map through the array and extract blob names
            const blobNames = blobs.map((blob: any) => blob.Name[0]);            
            console.log('Full Container Object:', result);
            console.log('Blob Names:', blobNames);
            console.log('Full Container raw xml:', xml);
        } else {
            console.log('No blobs found or unable to parse blobs.');
        }
        } catch (parseError) {
            console.error('Failed to parse XML:', parseError);
            console.log('Raw response:', xml.slice(0, 100)); 
        }        

        console.log('--------------- End --------------- ')
    } catch (error) {
        console.error('Error listing blobs:', error);
    }
};

listBlobs();

Docker file:

# Use an official Node.js runtime as a parent image
FROM node:latest

# Set the working directory in the container to /usr/src/app
WORKDIR /usr/src/app

# Install pnpm
RUN npm install -g pnpm

# Copy the package.json and pnpm-lock.yaml (or pnpm-workspace.yaml) file
COPY package.json pnpm-lock.yaml ./

# Install any needed packages specified in package.json
RUN pnpm install

# Bundle the app source inside the Docker image
COPY . .

# Build the app
RUN pnpm run build

ENV NODE_ENV production
# Define the command to run the app using CMD which defines your runtime
CMD ["pnpm", "start"]

.dockerignore:

**/node_modules
**/dist

package.json:

{
  "name": "restauthdemo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "child_process": "^1.0.2",
    "dotenv": "^16.4.5",
    "util": "^0.12.5",
    "xml2js": "^0.6.2"
  },
  "devDependencies": {
    "@types/node": "^20.12.7",
    "@types/xml2js": "^0.4.14",
    "typescript": "^5.4.5"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "strict": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}