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:
When run locally, az cli commands are called to fetch an authentication token.
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:
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.
Grant yourself Storage Blob Data Owner permissions
Ensure az cli is installed on your machine.
az account set --subscription
Update these 2 variables with the information from above
pnpm i
pnpm run build
pnpm start
az account get-access-token --resource https://storage.azure.com/ --query accessToken -o tsv
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>
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.
This example is from my Arkahna subscription:
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();
# 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"]
**/node_modules
**/dist
{
"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"
}
}
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"strict": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}