From 3ee9c68d1a80bf7652e41b58ae1bf87ff64a727e Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft <87724641+Robbie-Microsoft@users.noreply.github.com> Date: Fri, 7 Jun 2024 12:00:59 -0400 Subject: [PATCH] Implementation Based on Feature Request (#7151) --- ...-dbe642e7-1446-4b2d-9173-11242e9f5734.json | 7 + .../client/ManagedIdentitySources/AzureArc.ts | 63 ++++++- .../src/error/ManagedIdentityError.ts | 8 + .../src/error/ManagedIdentityErrorCodes.ts | 4 + lib/msal-node/src/utils/Constants.ts | 2 + .../ManagedIdentitySources/AzureArc.spec.ts | 173 +++++++++++++++++- .../test/test_kit/AzureArcSecret.key | 1 - 7 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 change/@azure-msal-node-dbe642e7-1446-4b2d-9173-11242e9f5734.json delete mode 100644 lib/msal-node/test/test_kit/AzureArcSecret.key diff --git a/change/@azure-msal-node-dbe642e7-1446-4b2d-9173-11242e9f5734.json b/change/@azure-msal-node-dbe642e7-1446-4b2d-9173-11242e9f5734.json new file mode 100644 index 0000000000..b6239ac480 --- /dev/null +++ b/change/@azure-msal-node-dbe642e7-1446-4b2d-9173-11242e9f5734.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Implementation Based on Feature Request #7151", + "packageName": "@azure/msal-node", + "email": "rginsburg@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-node/src/client/ManagedIdentitySources/AzureArc.ts b/lib/msal-node/src/client/ManagedIdentitySources/AzureArc.ts index 779b958ea3..82cb15176a 100644 --- a/lib/msal-node/src/client/ManagedIdentitySources/AzureArc.ts +++ b/lib/msal-node/src/client/ManagedIdentitySources/AzureArc.ts @@ -24,6 +24,7 @@ import { import { API_VERSION_QUERY_PARAMETER_NAME, AUTHORIZATION_HEADER_NAME, + AZURE_ARC_SECRET_FILE_MAX_SIZE_BYTES, HttpMethod, METADATA_HEADER_NAME, ManagedIdentityEnvironmentVariableNames, @@ -32,12 +33,18 @@ import { RESOURCE_BODY_OR_QUERY_PARAMETER_NAME, } from "../../utils/Constants"; import { NodeStorage } from "../../cache/NodeStorage"; -import { readFileSync } from "fs"; +import { readFileSync, statSync } from "fs"; import { ManagedIdentityTokenResponse } from "../../response/ManagedIdentityTokenResponse"; import { ManagedIdentityId } from "../../config/ManagedIdentityId"; +import path from "path"; export const ARC_API_VERSION: string = "2019-11-01"; +export const SUPPORTED_AZURE_ARC_PLATFORMS = { + win32: `${process.env["ProgramData"]}\\AzureConnectedMachineAgent\\Tokens\\`, + linux: "/var/opt/azcmagent/tokens/", +}; + /** * Original source of code: https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/src/AzureArcManagedIdentitySource.cs */ @@ -168,10 +175,60 @@ export class AzureArc extends BaseManagedIdentitySource { ); } - const secretFile = wwwAuthHeader.split("Basic realm=")[1]; + const secretFilePath = wwwAuthHeader.split("Basic realm=")[1]; + + // throw an error if the managed identity application is not being run on Windows or Linux + if ( + !SUPPORTED_AZURE_ARC_PLATFORMS.hasOwnProperty(process.platform) + ) { + throw createManagedIdentityError( + ManagedIdentityErrorCodes.platformNotSupported + ); + } + + // get the expected Windows or Linux file path) + const expectedSecretFilePath: string = + SUPPORTED_AZURE_ARC_PLATFORMS[process.platform as string]; + + // throw an error if the file in the file path is not a .key file + const fileName: string = path.basename(secretFilePath); + if (!fileName.endsWith(".key")) { + throw createManagedIdentityError( + ManagedIdentityErrorCodes.invalidFileExtension + ); + } + + /* + * throw an error if the file path from the www-authenticate header does not match the + * expected file path for the platform (Windows or Linux) the managed identity application + * is running on + */ + if (expectedSecretFilePath + fileName !== secretFilePath) { + throw createManagedIdentityError( + ManagedIdentityErrorCodes.invalidFilePath + ); + } + + let secretFileSize; + // attempt to get the secret file's size, in bytes + try { + secretFileSize = await statSync(secretFilePath).size; + } catch (e) { + throw createManagedIdentityError( + ManagedIdentityErrorCodes.unableToReadSecretFile + ); + } + // throw an error if the secret file's size is greater than 4096 bytes + if (secretFileSize > AZURE_ARC_SECRET_FILE_MAX_SIZE_BYTES) { + throw createManagedIdentityError( + ManagedIdentityErrorCodes.invalidSecret + ); + } + + // attempt to read the contents of the secret file let secret; try { - secret = readFileSync(secretFile, "utf-8"); + secret = readFileSync(secretFilePath, "utf-8"); } catch (e) { throw createManagedIdentityError( ManagedIdentityErrorCodes.unableToReadSecretFile diff --git a/lib/msal-node/src/error/ManagedIdentityError.ts b/lib/msal-node/src/error/ManagedIdentityError.ts index 2bac44c366..73a5cc1036 100644 --- a/lib/msal-node/src/error/ManagedIdentityError.ts +++ b/lib/msal-node/src/error/ManagedIdentityError.ts @@ -12,8 +12,16 @@ export { ManagedIdentityErrorCodes }; * ManagedIdentityErrorMessage class containing string constants used by error codes and messages. */ export const ManagedIdentityErrorMessages = { + [ManagedIdentityErrorCodes.invalidFileExtension]: + "The file path in the WWW-Authenticate header does not contain a .key file.", + [ManagedIdentityErrorCodes.invalidFilePath]: + "The file path in the WWW-Authenticate header is not in a valid Windows or Linux Format.", [ManagedIdentityErrorCodes.invalidManagedIdentityIdType]: "More than one ManagedIdentityIdType was provided.", + [ManagedIdentityErrorCodes.invalidSecret]: + "The secret in the file on the file path in the WWW-Authenticate header is greater than 4096 bytes.", + [ManagedIdentityErrorCodes.platformNotSupported]: + "The platform is not supported by Azure Arc. Azure Arc only supports Windows and Linux.", [ManagedIdentityErrorCodes.missingId]: "A ManagedIdentityId id was not provided.", [ManagedIdentityErrorCodes.MsiEnvironmentVariableUrlMalformedErrorCodes diff --git a/lib/msal-node/src/error/ManagedIdentityErrorCodes.ts b/lib/msal-node/src/error/ManagedIdentityErrorCodes.ts index 67a6129def..09a10f53c5 100644 --- a/lib/msal-node/src/error/ManagedIdentityErrorCodes.ts +++ b/lib/msal-node/src/error/ManagedIdentityErrorCodes.ts @@ -5,9 +5,13 @@ import { ManagedIdentityEnvironmentVariableNames } from "../utils/Constants"; +export const invalidFileExtension = "invalid_file_extension"; +export const invalidFilePath = "invalid_file_path"; export const invalidManagedIdentityIdType = "invalid_managed_identity_id_type"; +export const invalidSecret = "invalid_secret"; export const missingId = "missing_client_id"; export const networkUnavailable = "network_unavailable"; +export const platformNotSupported = "platform_not_supported"; export const unableToCreateAzureArc = "unable_to_create_azure_arc"; export const unableToCreateCloudShell = "unable_to_create_cloud_shell"; export const unableToCreateSource = "unable_to_create_source"; diff --git a/lib/msal-node/src/utils/Constants.ts b/lib/msal-node/src/utils/Constants.ts index eef876c2a0..dbc9105efa 100644 --- a/lib/msal-node/src/utils/Constants.ts +++ b/lib/msal-node/src/utils/Constants.ts @@ -161,6 +161,8 @@ export const LOOPBACK_SERVER_CONSTANTS = { TIMEOUT_MS: 5000, }; +export const AZURE_ARC_SECRET_FILE_MAX_SIZE_BYTES = 4096; // 4 KB + export const MANAGED_IDENTITY_MAX_RETRIES = 3; export const MANAGED_IDENTITY_RETRY_DELAY = 1000; export const MANAGED_IDENTITY_HTTP_STATUS_CODES_TO_RETRY_ON = [ diff --git a/lib/msal-node/test/client/ManagedIdentitySources/AzureArc.spec.ts b/lib/msal-node/test/client/ManagedIdentitySources/AzureArc.spec.ts index 88b477a782..d6f190c535 100644 --- a/lib/msal-node/test/client/ManagedIdentitySources/AzureArc.spec.ts +++ b/lib/msal-node/test/client/ManagedIdentitySources/AzureArc.spec.ts @@ -27,7 +27,10 @@ import { ManagedIdentityErrorCodes, createManagedIdentityError, } from "../../../src/error/ManagedIdentityError"; -import { ARC_API_VERSION } from "../../../src/client/ManagedIdentitySources/AzureArc"; +import { + ARC_API_VERSION, + SUPPORTED_AZURE_ARC_PLATFORMS, +} from "../../../src/client/ManagedIdentitySources/AzureArc"; import * as fs from "fs"; import { ManagedIdentityEnvironmentVariableNames, @@ -37,11 +40,18 @@ import { jest.mock("fs"); describe("Acquires a token successfully via an Azure Arc Managed Identity", () => { + let originalPlatform: string; + beforeAll(() => { process.env[ManagedIdentityEnvironmentVariableNames.IDENTITY_ENDPOINT] = "fake_IDENTITY_ENDPOINT"; process.env[ManagedIdentityEnvironmentVariableNames.IMDS_ENDPOINT] = "fake_IMDS_ENDPOINT"; + + originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "linux", + }); }); afterAll(() => { @@ -51,6 +61,10 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = delete process.env[ ManagedIdentityEnvironmentVariableNames.IMDS_ENDPOINT ]; + + Object.defineProperty(process, "platform", { + value: originalPlatform, + }); }); afterEach(() => { @@ -104,7 +118,7 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ); }); - test("attempts to acquire a token, a 401 and www-authenticate header are returned form the azure arc managed identity, then retries the network request with the www-authenticate header", async () => { + test("attempts to acquire a token, a 401 and www-authenticate header are returned from the azure arc managed identity, then retries the network request with the www-authenticate header", async () => { const networkClient: ManagedIdentityNetworkClient = new ManagedIdentityNetworkClient(MANAGED_IDENTITY_RESOURCE_ID); @@ -127,10 +141,15 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = // and the WWW-Authentication header the first time the network request is executed .mockReturnValueOnce( networkErrorClient.getSendGetRequestAsyncReturnObject( - "Basic realm=lib/msal-node/test/test_kit/AzureArcSecret.key" + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}AzureArcSecret.key` // Linux ) ); + const statSyncSpy: jest.SpyInstance = jest + .spyOn(fs, "statSync") + .mockReturnValueOnce({ + size: 4000, + } as fs.Stats); const readFileSyncSpy: jest.SpyInstance = jest .spyOn(fs, "readFileSync") .mockReturnValueOnce(TEST_TOKENS.ACCESS_TOKEN); @@ -146,6 +165,7 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ); expect(sendGetRequestAsyncSpy).toHaveBeenCalledTimes(2); + expect(statSyncSpy).toHaveBeenCalledTimes(1); expect(readFileSyncSpy).toHaveBeenCalledTimes(1); expect(sendGetRequestAsyncSpy).toHaveBeenNthCalledWith( @@ -171,7 +191,7 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = }); describe("Errors", () => { - test("throws an error when a user assigned managed identity is used", async () => { + test("throws an error if a user assigned managed identity is used", async () => { const managedIdentityApplication: ManagedIdentityApplication = new ManagedIdentityApplication(userAssignedClientIdConfig); expect(managedIdentityApplication.getManagedIdentitySource()).toBe( @@ -189,7 +209,126 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ); }); - test("throws an error when the www-authenticate header is missing", async () => { + test("throws an error if the www-authenticate header has been returned from the azure arc managed identity, but the file in the file path is not a .key file", async () => { + const managedIdentityApplication: ManagedIdentityApplication = + new ManagedIdentityApplication({ + system: { + networkClient: new ManagedIdentityNetworkErrorClient( + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}AzureArcSecret.txt` // Linux + ), + // managedIdentityIdParams will be omitted for system assigned + }, + }); + expect(managedIdentityApplication.getManagedIdentitySource()).toBe( + ManagedIdentitySourceNames.AZURE_ARC + ); + + await expect( + managedIdentityApplication.acquireToken( + managedIdentityRequestParams + ) + ).rejects.toMatchObject( + createManagedIdentityError( + ManagedIdentityErrorCodes.invalidFileExtension + ) + ); + + jest.restoreAllMocks(); + }); + + test("throws an error if the www-authenticate header has been returned from the azure arc managed identity, but the managed identity application is not being run on Windows or Linux", async () => { + const managedIdentityApplication: ManagedIdentityApplication = + new ManagedIdentityApplication({ + system: { + networkClient: new ManagedIdentityNetworkErrorClient( + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}AzureArcSecret.key` // Linux + ), + // managedIdentityIdParams will be omitted for system assigned + }, + }); + expect(managedIdentityApplication.getManagedIdentitySource()).toBe( + ManagedIdentitySourceNames.AZURE_ARC + ); + + Object.defineProperty(process, "platform", { + value: "darwin", + }); + + await expect( + managedIdentityApplication.acquireToken( + managedIdentityRequestParams + ) + ).rejects.toMatchObject( + createManagedIdentityError( + ManagedIdentityErrorCodes.platformNotSupported + ) + ); + + Object.defineProperty(process, "platform", { + value: "linux", + }); + jest.restoreAllMocks(); + }); + + test("throws an error if the www-authenticate header has been returned from the azure arc managed identity, but the path of the secret file from the www-authenticate header is not in the expected Windows or Linux formats", async () => { + const managedIdentityApplication: ManagedIdentityApplication = + new ManagedIdentityApplication({ + system: { + networkClient: new ManagedIdentityNetworkErrorClient( + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}this_will_throw_because_file_path_must_match_exactly/AzureArcSecret.key` // Linux + ), + // managedIdentityIdParams will be omitted for system assigned + }, + }); + expect(managedIdentityApplication.getManagedIdentitySource()).toBe( + ManagedIdentitySourceNames.AZURE_ARC + ); + + await expect( + managedIdentityApplication.acquireToken( + managedIdentityRequestParams + ) + ).rejects.toMatchObject( + createManagedIdentityError( + ManagedIdentityErrorCodes.invalidFilePath + ) + ); + + jest.restoreAllMocks(); + }); + + test("throws an error if the www-authenticate header has been returned from the azure arc managed identity, but the size of the secret file from the www-authenticate header is greater than 4096 bytes", async () => { + const managedIdentityApplication: ManagedIdentityApplication = + new ManagedIdentityApplication({ + system: { + networkClient: new ManagedIdentityNetworkErrorClient( + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}AzureArcSecret.key` // Linux + ), + // managedIdentityIdParams will be omitted for system assigned + }, + }); + expect(managedIdentityApplication.getManagedIdentitySource()).toBe( + ManagedIdentitySourceNames.AZURE_ARC + ); + + jest.spyOn(fs, "statSync").mockReturnValueOnce({ + size: 4097, + } as fs.Stats); + + await expect( + managedIdentityApplication.acquireToken( + managedIdentityRequestParams + ) + ).rejects.toMatchObject( + createManagedIdentityError( + ManagedIdentityErrorCodes.invalidSecret + ) + ); + + jest.restoreAllMocks(); + }); + + test("throws an error if the www-authenticate header is missing", async () => { const managedIdentityApplication: ManagedIdentityApplication = new ManagedIdentityApplication({ system: { @@ -215,7 +354,7 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ); }); - test("throws an error when the www-authenticate header is in an unsupported format", async () => { + test("throws an error if the www-authenticate header is in an unsupported format", async () => { const managedIdentityApplication: ManagedIdentityApplication = new ManagedIdentityApplication({ system: { @@ -240,12 +379,12 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ); }); - test("throws an error when the secret file cannot be found", async () => { + test("throws an error if the secret file cannot be found/read", async () => { const managedIdentityApplication: ManagedIdentityApplication = new ManagedIdentityApplication({ system: { networkClient: new ManagedIdentityNetworkErrorClient( - "Basic realm=invalid/secret/file/location.key" + `Basic realm=${SUPPORTED_AZURE_ARC_PLATFORMS.linux}AzureArcSecret.key` // Linux ), // managedIdentityIdParams will be omitted for system assigned }, @@ -254,6 +393,24 @@ describe("Acquires a token successfully via an Azure Arc Managed Identity", () = ManagedIdentitySourceNames.AZURE_ARC ); + jest.spyOn(fs, "statSync").mockImplementationOnce(() => { + throw new Error(); + }); + + await expect( + managedIdentityApplication.acquireToken( + managedIdentityRequestParams + ) + ).rejects.toMatchObject( + createManagedIdentityError( + ManagedIdentityErrorCodes.unableToReadSecretFile + ) + ); + + jest.spyOn(fs, "statSync").mockReturnValueOnce({ + size: 4000, + } as fs.Stats); + jest.spyOn(fs, "readFileSync").mockImplementationOnce(() => { throw new Error(); }); diff --git a/lib/msal-node/test/test_kit/AzureArcSecret.key b/lib/msal-node/test/test_kit/AzureArcSecret.key deleted file mode 100644 index 3343e06906..0000000000 --- a/lib/msal-node/test/test_kit/AzureArcSecret.key +++ /dev/null @@ -1 +0,0 @@ -thisIs.an.accessT0ken \ No newline at end of file