[Identity] Support exchanging k8s token to AAD token (#16688)

This is a simplified version of what @chlowell did on this PR: https://github.com/Azure/azure-sdk-for-python/pull/19902

This is based on what I understood. I’ll make sure to circle back with Charles before I get this PR out of draft.

Fixes #15800
This commit is contained in:
Daniel Rodríguez 2021-09-08 12:25:56 -05:00 коммит произвёл GitHub
Родитель 7156a37da9
Коммит 6ccd67cb3d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 533 добавлений и 110 удалений

Просмотреть файл

@ -4,6 +4,8 @@
### Features Added
- `ManagedIdentityCredential` now supports token exchange authentication.
### Breaking Changes
#### Breaking Changes from 2.0.0-beta.5
@ -14,9 +16,12 @@
- `ClientSecretCredential`, `ClientCertificateCredential` and `UsernamePasswordCredential` now throw if the required parameters are not provided (even in JavaScript).
- Fixed a bug introduced on 2.0.0-beta.5 that caused the `ManagedIdentityCredential` to fail authenticating in Arc environments. Since our new core disables unsafe requests by default, we had to change the security settings for the first request of the Arc MSI, which retrieves the file path where the authentication value is stored since this request generally happens through an HTTP endpoint.
- Fixed bug on the `AggregateAuthenticationError`, which caused an inconsistent error message on the `ChainedTokenCredential`, `DefaultAzureCredential` and `ApplicationCredential`.
### Other Changes
- The errors thrown by the `ManagedIdentityCredential` have been improved.
## 2.0.0-beta.5 (2021-08-10)
### Features Added

Просмотреть файл

@ -164,7 +164,7 @@ export class AggregateAuthenticationError extends Error {
constructor(errors: any[], errorMessage?: string) {
const errorDetail = errors.join("\n");
super(`${errorMessage}\n\n${errorDetail}`);
super(`${errorMessage}\n${errorDetail}`);
this.errors = errors;
// Ensure that this type reports the correct name

Просмотреть файл

@ -80,7 +80,10 @@ export class ChainedTokenCredential implements TokenCredential {
}
if (!token && errors.length > 0) {
const err = new AggregateAuthenticationError(errors);
const err = new AggregateAuthenticationError(
errors,
"ChainedTokenCredential authentication failed."
);
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message

Просмотреть файл

@ -3,12 +3,12 @@
import { createHttpHeaders, PipelineRequestOptions } from "@azure/core-rest-pipeline";
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { IdentityClient } from "../../client/identityClient";
import { credentialLogger } from "../../util/logging";
import { MSI } from "./models";
import { msiGenericGetToken } from "./utils";
import { MSI, MSIConfiguration } from "./models";
import { mapScopesToResource, msiGenericGetToken } from "./utils";
const logger = credentialLogger("ManagedIdentityCredential - AppServiceMSI 2017");
const msiName = "ManagedIdentityCredential - AppServiceMSI 2017";
const logger = credentialLogger(msiName);
function expiresInParser(requestBody: any): number {
// Parse a date format like "06/20/2019 02:57:58 +00:00" and
@ -16,7 +16,15 @@ function expiresInParser(requestBody: any): number {
return Date.parse(requestBody.expires_on);
}
function prepareRequestOptions(resource: string, clientId?: string): PipelineRequestOptions {
function prepareRequestOptions(
scopes: string | string[],
clientId?: string
): PipelineRequestOptions {
const resource = mapScopesToResource(scopes);
if (!resource) {
throw new Error(`${msiName}: Multiple scopes are not supported.`);
}
const queryParameters: any = {
resource,
"api-version": "2017-09-01"
@ -30,10 +38,10 @@ function prepareRequestOptions(resource: string, clientId?: string): PipelineReq
// This error should not bubble up, since we verify that this environment variable is defined in the isAvailable() method defined below.
if (!process.env.MSI_ENDPOINT) {
throw new Error("Missing environment variable: MSI_ENDPOINT");
throw new Error(`${msiName}: Missing environment variable: MSI_ENDPOINT`);
}
if (!process.env.MSI_SECRET) {
throw new Error("Missing environment variable: MSI_SECRET");
throw new Error(`${msiName}: Missing environment variable: MSI_SECRET`);
}
return {
@ -47,27 +55,34 @@ function prepareRequestOptions(resource: string, clientId?: string): PipelineReq
}
export const appServiceMsi2017: MSI = {
async isAvailable(): Promise<boolean> {
async isAvailable(scopes): Promise<boolean> {
const resource = mapScopesToResource(scopes);
if (!resource) {
logger.info(`${msiName}: Unavailable. Multiple scopes are not supported.`);
return false;
}
const env = process.env;
const result = Boolean(env.MSI_ENDPOINT && env.MSI_SECRET);
if (!result) {
logger.info("The Azure App Service MSI 2017 is unavailable.");
logger.info(
`${msiName}: Unavailable. The environment variables needed are: MSI_ENDPOINT and MSI_SECRET.`
);
}
return result;
},
async getToken(
identityClient: IdentityClient,
resource: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
const { identityClient, scopes, clientId } = configuration;
logger.info(
`Using the endpoint and the secret coming form the environment variables: MSI_ENDPOINT=${process.env.MSI_ENDPOINT} and MSI_SECRET=[REDACTED].`
`${msiName}: Using the endpoint and the secret coming form the environment variables: MSI_ENDPOINT=${process.env.MSI_ENDPOINT} and MSI_SECRET=[REDACTED].`
);
return msiGenericGetToken(
identityClient,
prepareRequestOptions(resource, clientId),
prepareRequestOptions(scopes, clientId),
expiresInParser,
getTokenOptions
);

Просмотреть файл

@ -8,19 +8,24 @@ import {
} from "@azure/core-rest-pipeline";
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { readFile } from "fs";
import { MSI } from "./models";
import { MSI, MSIConfiguration } from "./models";
import { credentialLogger } from "../../util/logging";
import { IdentityClient } from "../../client/identityClient";
import { msiGenericGetToken } from "./utils";
import { mapScopesToResource, msiGenericGetToken } from "./utils";
import { azureArcAPIVersion } from "./constants";
import { AuthenticationError } from "../../client/errors";
const logger = credentialLogger("ManagedIdentityCredential - ArcMSI");
const msiName = "ManagedIdentityCredential - Azure Arc MSI";
const logger = credentialLogger(msiName);
// Azure Arc MSI doesn't have a special expiresIn parser.
const expiresInParser = undefined;
function prepareRequestOptions(resource?: string): PipelineRequestOptions {
function prepareRequestOptions(scopes: string | string[]): PipelineRequestOptions {
const resource = mapScopesToResource(scopes);
if (!resource) {
throw new Error(`${msiName}: Multiple scopes are not supported.`);
}
const queryParameters: any = {
resource,
"api-version": azureArcAPIVersion
@ -30,7 +35,7 @@ function prepareRequestOptions(resource?: string): PipelineRequestOptions {
// This error should not bubble up, since we verify that this environment variable is defined in the isAvailable() method defined below.
if (!process.env.IDENTITY_ENDPOINT) {
throw new Error("Missing environment variable: IDENTITY_ENDPOINT");
throw new Error(`${msiName}: Missing environment variable: IDENTITY_ENDPOINT`);
}
return createPipelineRequest({
@ -69,7 +74,7 @@ async function filePathRequest(
}
throw new AuthenticationError(
response.status,
`To authenticate with Azure Arc MSI, status code 401 is expected on the first request.${message}`
`${msiName}: To authenticate with Azure Arc MSI, status code 401 is expected on the first request. ${message}`
);
}
@ -82,24 +87,31 @@ async function filePathRequest(
}
export const arcMsi: MSI = {
async isAvailable(): Promise<boolean> {
async isAvailable(scopes): Promise<boolean> {
const resource = mapScopesToResource(scopes);
if (!resource) {
logger.info(`${msiName}: Unavailable. Multiple scopes are not supported.`);
return false;
}
const result = Boolean(process.env.IMDS_ENDPOINT && process.env.IDENTITY_ENDPOINT);
if (!result) {
logger.info("The Azure Arc MSI is unavailable.");
logger.info(
`${msiName}: The environment variables needed are: IMDS_ENDPOINT and IDENTITY_ENDPOINT`
);
}
return result;
},
async getToken(
identityClient: IdentityClient,
resource?: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
logger.info(`Using the Azure Arc MSI to authenticate.`);
const { identityClient, scopes, clientId } = configuration;
logger.info(`${msiName}: Authenticating.`);
if (clientId) {
throw new Error(
"User assigned identity is not supported by the Azure Arc Managed Identity Endpoint. To authenticate with the system assigned identity omit the client id when constructing the ManagedIdentityCredential, or if authenticating with the DefaultAzureCredential ensure the AZURE_CLIENT_ID environment variable is not set."
`${msiName}: User assigned identity is not supported by the Azure Arc Managed Identity Endpoint. To authenticate with the system assigned identity, omit the client id when constructing the ManagedIdentityCredential, or if authenticating with the DefaultAzureCredential ensure the AZURE_CLIENT_ID environment variable is not set.`
);
}
@ -107,14 +119,14 @@ export const arcMsi: MSI = {
disableJsonStringifyOnBody: true,
deserializationMapper: undefined,
abortSignal: getTokenOptions.abortSignal,
...prepareRequestOptions(resource),
...prepareRequestOptions(scopes),
allowInsecureConnection: true
};
const filePath = await filePathRequest(identityClient, requestOptions);
if (!filePath) {
throw new Error("Azure Arc MSI failed to find the token file.");
throw new Error(`${msiName}: Failed to find the token file.`);
}
const key = await readFileAsync(filePath, { encoding: "utf-8" });

Просмотреть файл

@ -3,17 +3,25 @@
import { createHttpHeaders, PipelineRequestOptions } from "@azure/core-rest-pipeline";
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { MSI } from "./models";
import { MSI, MSIConfiguration } from "./models";
import { credentialLogger } from "../../util/logging";
import { IdentityClient } from "../../client/identityClient";
import { msiGenericGetToken } from "./utils";
import { mapScopesToResource, msiGenericGetToken } from "./utils";
const logger = credentialLogger("ManagedIdentityCredential - CloudShellMSI");
const msiName = "ManagedIdentityCredential - CloudShellMSI";
const logger = credentialLogger(msiName);
// Cloud Shell MSI doesn't have a special expiresIn parser.
const expiresInParser = undefined;
function prepareRequestOptions(resource: string, clientId?: string): PipelineRequestOptions {
function prepareRequestOptions(
scopes: string | string[],
clientId?: string
): PipelineRequestOptions {
const resource = mapScopesToResource(scopes);
if (!resource) {
throw new Error(`${msiName}: Multiple scopes are not supported.`);
}
const body: any = {
resource
};
@ -24,7 +32,7 @@ function prepareRequestOptions(resource: string, clientId?: string): PipelineReq
// This error should not bubble up, since we verify that this environment variable is defined in the isAvailable() method defined below.
if (!process.env.MSI_ENDPOINT) {
throw new Error("Missing environment variable: MSI_ENDPOINT");
throw new Error(`${msiName}: Missing environment variable: MSI_ENDPOINT`);
}
const params = new URLSearchParams(body);
return {
@ -40,26 +48,31 @@ function prepareRequestOptions(resource: string, clientId?: string): PipelineReq
}
export const cloudShellMsi: MSI = {
async isAvailable(): Promise<boolean> {
async isAvailable(scopes): Promise<boolean> {
const resource = mapScopesToResource(scopes);
if (!resource) {
logger.info(`${msiName}: Unavailable. Multiple scopes are not supported.`);
return false;
}
const result = Boolean(process.env.MSI_ENDPOINT);
if (!result) {
logger.info("The Azure Cloud Shell MSI is unavailable.");
logger.info(`${msiName}: Unavailable. The environment variable MSI_ENDPOINT is needed.`);
}
return result;
},
async getToken(
identityClient: IdentityClient,
resource: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
const { identityClient, scopes, clientId } = configuration;
logger.info(
`Using the endpoint coming form the environment variable MSI_ENDPOINT=${process.env.MSI_ENDPOINT}, and using the Cloud Shell to proceed with the authentication.`
`${msiName}: Using the endpoint coming form the environment variable MSI_ENDPOINT = ${process.env.MSI_ENDPOINT}.`
);
return msiGenericGetToken(
identityClient,
prepareRequestOptions(resource, clientId),
prepareRequestOptions(scopes, clientId),
expiresInParser,
getTokenOptions
);

Просмотреть файл

@ -3,20 +3,28 @@
import { createHttpHeaders, PipelineRequestOptions } from "@azure/core-rest-pipeline";
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { MSI } from "./models";
import { MSI, MSIConfiguration } from "./models";
import { credentialLogger } from "../../util/logging";
import { IdentityClient } from "../../client/identityClient";
import { msiGenericGetToken } from "./utils";
import { mapScopesToResource, msiGenericGetToken } from "./utils";
import { azureFabricVersion } from "./constants";
const logger = credentialLogger("ManagedIdentityCredential - Fabric MSI");
const msiName = "ManagedIdentityCredential - Fabric MSI";
const logger = credentialLogger(msiName);
function expiresInParser(requestBody: any): number {
// Parses a string representation of the seconds since epoch into a number value
return Number(requestBody.expires_on);
}
function prepareRequestOptions(resource: string, clientId?: string): PipelineRequestOptions {
function prepareRequestOptions(
scopes: string | string[],
clientId?: string
): PipelineRequestOptions {
const resource = mapScopesToResource(scopes);
if (!resource) {
throw new Error(`${msiName}: Multiple scopes are not supported.`);
}
const queryParameters: any = {
resource,
"api-version": azureFabricVersion
@ -58,24 +66,32 @@ function prepareRequestOptions(resource: string, clientId?: string): PipelineReq
//
export const fabricMsi: MSI = {
async isAvailable(): Promise<boolean> {
async isAvailable(scopes): Promise<boolean> {
const resource = mapScopesToResource(scopes);
if (!resource) {
logger.info(`${msiName}: Unavailable. Multiple scopes are not supported.`);
return false;
}
const env = process.env;
const result = Boolean(
env.IDENTITY_ENDPOINT && env.IDENTITY_HEADER && env.IDENTITY_SERVER_THUMBPRINT
);
if (!result) {
logger.info("The Azure App Service Fabric MSI is unavailable.");
logger.info(
`${msiName}: Unavailable. The environment variables needed are: IDENTITY_ENDPOINT, IDENTITY_HEADER and IDENTITY_SERVER_THUMBPRINT`
);
}
return result;
},
async getToken(
identityClient: IdentityClient,
resource: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
const { identityClient, scopes, clientId } = configuration;
logger.info(
[
`${msiName}:`,
"Using the endpoint and the secret coming from the environment variables:",
`IDENTITY_ENDPOINT=${process.env.IDENTITY_ENDPOINT},`,
"IDENTITY_HEADER=[REDACTED] and",
@ -85,7 +101,7 @@ export const fabricMsi: MSI = {
return msiGenericGetToken(
identityClient,
prepareRequestOptions(resource, clientId),
prepareRequestOptions(scopes, clientId),
expiresInParser,
getTokenOptions
);

Просмотреть файл

@ -14,27 +14,40 @@ import { IdentityClient } from "../../client/identityClient";
import { credentialLogger } from "../../util/logging";
import { createSpan } from "../../util/tracing";
import { imdsApiVersion, imdsEndpointPath, imdsHost } from "./constants";
import { MSI } from "./models";
import { msiGenericGetToken } from "./utils";
import { MSI, MSIConfiguration } from "./models";
import { mapScopesToResource, msiGenericGetToken } from "./utils";
import { AuthenticationError } from "../../client/errors";
const logger = credentialLogger("ManagedIdentityCredential - IMDS");
const msiName = "ManagedIdentityCredential - IMDS";
const logger = credentialLogger(msiName);
function expiresInParser(requestBody: any): number {
if (requestBody.expires_on) {
// Use the expires_on timestamp if it's available
const expires = +requestBody.expires_on * 1000;
logger.info(`IMDS using expires_on: ${expires} (original value: ${requestBody.expires_on})`);
logger.info(
`${msiName}: IMDS using expires_on: ${expires} (original value: ${requestBody.expires_on})`
);
return expires;
} else {
// If these aren't possible, use expires_in and calculate a timestamp
const expires = Date.now() + requestBody.expires_in * 1000;
logger.info(`IMDS using expires_in: ${expires} (original value: ${requestBody.expires_in})`);
logger.info(
`${msiName}: IMDS using expires_in: ${expires} (original value: ${requestBody.expires_in})`
);
return expires;
}
}
function prepareRequestOptions(resource?: string, clientId?: string): PipelineRequestOptions {
function prepareRequestOptions(
scopes: string | string[],
clientId?: string
): PipelineRequestOptions {
const resource = mapScopesToResource(scopes);
if (!resource) {
throw new Error(`${msiName}: Multiple scopes are not supported.`);
}
const queryParameters: any = {
resource,
"api-version": imdsApiVersion
@ -67,11 +80,16 @@ export const imdsMsiRetryConfig = {
export const imdsMsi: MSI = {
async isAvailable(
scopes: string | string[],
identityClient: IdentityClient,
resource: string,
clientId?: string,
getTokenOptions?: GetTokenOptions
): Promise<boolean> {
const resource = mapScopesToResource(scopes);
if (!resource) {
logger.info(`${msiName}: Unavailable. Multiple scopes are not supported.`);
return false;
}
const { span, updatedOptions: options } = createSpan(
"ManagedIdentityCredential-pingImdsEndpoint",
getTokenOptions
@ -105,18 +123,19 @@ export const imdsMsi: MSI = {
request.allowInsecureConnection = true;
try {
logger.info(`Pinging the Azure IMDS endpoint`);
logger.info(`${msiName}: Pinging the Azure IMDS endpoint`);
await identityClient.sendRequest(request);
} catch (err) {
if (
(err.name === "RestError" && err.code === RestError.REQUEST_SEND_ERROR) ||
err.name === "AbortError" ||
err.code === "ENETUNREACH" || // Network unreachable
err.code === "ECONNREFUSED" || // connection refused
err.code === "EHOSTDOWN" // host is down
) {
// If the request failed, or Node.js was unable to establish a connection,
// or the host was down, we'll assume the IMDS endpoint isn't available.
logger.info(`The Azure IMDS endpoint is unavailable`);
logger.info(`${msiName}: The Azure IMDS endpoint is unavailable`);
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
@ -126,13 +145,13 @@ export const imdsMsi: MSI = {
}
// If we received any response, the endpoint is available
logger.info(`The Azure IMDS endpoint is available`);
logger.info(`${msiName}: The Azure IMDS endpoint is available`);
return true;
} catch (err) {
// createWebResource failed.
// This error should bubble up to the user.
logger.info(
`Error when creating the WebResource for the Azure IMDS endpoint: ${err.message}`
`${msiName}: Error when creating the WebResource for the Azure IMDS endpoint: ${err.message}`
);
span.setStatus({
code: SpanStatusCode.ERROR,
@ -144,13 +163,13 @@ export const imdsMsi: MSI = {
}
},
async getToken(
identityClient: IdentityClient,
resource: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
const { identityClient, scopes, clientId } = configuration;
logger.info(
`Using the Azure IMDS endpoint coming from the environment variable MSI_ENDPOINT=${process.env.MSI_ENDPOINT}, and using the cloud shell to proceed with the authentication.`
`${msiName}: Using the Azure IMDS endpoint coming from the environment variable MSI_ENDPOINT=${process.env.MSI_ENDPOINT}, and using the cloud shell to proceed with the authentication.`
);
let nextDelayInMs = imdsMsiRetryConfig.startDelayInMs;
@ -158,7 +177,7 @@ export const imdsMsi: MSI = {
try {
return await msiGenericGetToken(
identityClient,
prepareRequestOptions(resource, clientId),
prepareRequestOptions(scopes, clientId),
expiresInParser,
getTokenOptions
);
@ -174,7 +193,7 @@ export const imdsMsi: MSI = {
throw new AuthenticationError(
404,
`Failed to retrieve IMDS token after ${imdsMsiRetryConfig.maxRetries} retries.`
`${msiName}: Failed to retrieve IMDS token after ${imdsMsiRetryConfig.maxRetries} retries.`
);
}
};

Просмотреть файл

@ -9,11 +9,11 @@ import { AuthenticationError, CredentialUnavailableError } from "../../client/er
import { credentialLogger, formatSuccess, formatError } from "../../util/logging";
import { appServiceMsi2017 } from "./appServiceMsi2017";
import { createSpan } from "../../util/tracing";
import { mapScopesToResource } from "./utils";
import { cloudShellMsi } from "./cloudShellMsi";
import { imdsMsi } from "./imdsMsi";
import { MSI } from "./models";
import { arcMsi } from "./arcMsi";
import { tokenExchangeMsi } from "./tokenExchangeMsi";
const logger = credentialLogger("ManagedIdentityCredential");
@ -66,7 +66,7 @@ export class ManagedIdentityCredential implements TokenCredential {
private cachedMSI: MSI | undefined;
private async cachedAvailableMSI(
resource: string,
scopes: string | string[],
clientId?: string,
getTokenOptions?: GetTokenOptions
): Promise<MSI> {
@ -76,10 +76,10 @@ export class ManagedIdentityCredential implements TokenCredential {
// "fabricMsi" can't be added yet because our HTTPs pipeline doesn't allow skipping the SSL verification step,
// which is necessary since Service Fabric only provides self-signed certificates on their Identity Endpoint.
const MSIs = [appServiceMsi2017, cloudShellMsi, arcMsi, imdsMsi];
const MSIs = [appServiceMsi2017, cloudShellMsi, arcMsi, tokenExchangeMsi(), imdsMsi];
for (const msi of MSIs) {
if (await msi.isAvailable(this.identityClient, resource, clientId, getTokenOptions)) {
if (await msi.isAvailable(scopes, this.identityClient, clientId, getTokenOptions)) {
this.cachedMSI = msi;
return msi;
}
@ -93,7 +93,6 @@ export class ManagedIdentityCredential implements TokenCredential {
clientId?: string,
getTokenOptions?: GetTokenOptions
): Promise<AccessToken | null> {
const resource = mapScopesToResource(scopes);
const { span, updatedOptions } = createSpan(
"ManagedIdentityCredential-authenticateManagedIdentity",
getTokenOptions
@ -101,9 +100,16 @@ export class ManagedIdentityCredential implements TokenCredential {
try {
// Determining the available MSI, and avoiding checking for other MSIs while the program is running.
const availableMSI = await this.cachedAvailableMSI(resource, clientId, updatedOptions);
const availableMSI = await this.cachedAvailableMSI(scopes, clientId, updatedOptions);
return availableMSI.getToken(this.identityClient, resource, clientId, updatedOptions);
return availableMSI.getToken(
{
identityClient: this.identityClient,
scopes,
clientId
},
updatedOptions
);
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
@ -192,7 +198,7 @@ export class ManagedIdentityCredential implements TokenCredential {
// we can safely assume the credential is unavailable.
if (err.code === "ENETUNREACH") {
const error = new CredentialUnavailableError(
"ManagedIdentityCredential is unavailable. Network unreachable."
`ManagedIdentityCredential is unavailable. Network unreachable. Message: ${err.message}`
);
logger.getToken.info(formatError(scopes, error));
@ -203,7 +209,7 @@ export class ManagedIdentityCredential implements TokenCredential {
// we can safely assume the credential is unavailable.
if (err.code === "EHOSTUNREACH") {
const error = new CredentialUnavailableError(
"ManagedIdentityCredential is unavailable. No managed identity endpoint found."
`ManagedIdentityCredential is unavailable. No managed identity endpoint found. Message: ${err.message}`
);
logger.getToken.info(formatError(scopes, error));
@ -214,7 +220,7 @@ export class ManagedIdentityCredential implements TokenCredential {
// and it means that the endpoint is working, but that no identity is available.
if (err.statusCode === 400) {
throw new CredentialUnavailableError(
"The managed identity endpoint is indicating there's no available identity"
`ManagedIdentityCredential: The managed identity endpoint is indicating there's no available identity. Message: ${err.message}`
);
}

Просмотреть файл

@ -4,19 +4,32 @@
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { IdentityClient } from "../../client/identityClient";
/**
* @internal
*/
export type MSIExpiresInParser = (requestBody: any) => number;
/**
* @internal
*/
export interface MSIConfiguration {
identityClient: IdentityClient;
scopes: string | string[];
clientId?: string;
}
/**
* @internal
*/
export interface MSI {
isAvailable(
scopes: string | string[],
identityClient?: IdentityClient,
resource?: string,
clientId?: string,
getTokenOptions?: GetTokenOptions
): Promise<boolean>;
getToken(
identityClient: IdentityClient,
resource: string,
clientId?: string,
configuration: MSIConfiguration,
getTokenOptions?: GetTokenOptions
): Promise<AccessToken | null>;
}

Просмотреть файл

@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import fs from "fs";
import { createHttpHeaders, PipelineRequestOptions } from "@azure/core-rest-pipeline";
import { AccessToken, GetTokenOptions } from "@azure/core-auth";
import { promisify } from "util";
import { credentialLogger } from "../../util/logging";
import { MSI, MSIConfiguration } from "./models";
import { msiGenericGetToken } from "./utils";
import { DefaultAuthorityHost } from "../../constants";
const msiName = "ManagedIdentityCredential - Token Exchange";
const logger = credentialLogger(msiName);
const readFileAsync = promisify(fs.readFile);
function expiresInParser(requestBody: any): number {
// Parses a string representation of the seconds since epoch into a number value
return Number(requestBody.expires_on);
}
function prepareRequestOptions(
scopes: string | string[],
clientAssertion: string,
clientId?: string
): PipelineRequestOptions {
const bodyParams: any = {
scope: Array.isArray(scopes) ? scopes.join(" ") : scopes,
client_assertion: clientAssertion,
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_id: clientId,
grant_type: "client_credentials"
};
const urlParams = new URLSearchParams(bodyParams);
const url = new URL(
`${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
process.env.AZURE_AUTHORITY_HOST ?? DefaultAuthorityHost
);
return {
url: url.toString(),
method: "POST",
body: urlParams.toString(),
headers: createHttpHeaders({
Accept: "application/json"
})
};
}
export function tokenExchangeMsi(): MSI {
const azureFederatedTokenFilePath = process.env.AZURE_FEDERATED_TOKEN_FILE;
let azureFederatedTokenFileContent: string | undefined = undefined;
let cacheDate: number | undefined = undefined;
// Only reads from the assertion file once every 5 minutes
async function readAssertion(): Promise<string> {
// Cached assertions expire after 5 minutes
if (cacheDate !== undefined && Date.now() - cacheDate >= 1000 * 60 * 5) {
azureFederatedTokenFileContent = undefined;
}
if (!azureFederatedTokenFileContent) {
const file = await readFileAsync(azureFederatedTokenFilePath!, "utf8");
const value = file.trim();
if (!value) {
throw new Error(
`No content on the file ${azureFederatedTokenFilePath}, indicated by the environment variable AZURE_FEDERATED_TOKEN_FILE`
);
} else {
azureFederatedTokenFileContent = value;
cacheDate = Date.now();
}
}
return azureFederatedTokenFileContent;
}
return {
async isAvailable(_scopes, _identityClient, clientId): Promise<boolean> {
const env = process.env;
const result = Boolean(
(clientId || env.AZURE_CLIENT_ID) && env.AZURE_TENANT_ID && azureFederatedTokenFilePath
);
if (!result) {
logger.info(
`${msiName}: Unavailable. The environment variables needed are: AZURE_CLIENT_ID (or the client ID sent through the parameters), AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE`
);
}
return result;
},
async getToken(
configuration: MSIConfiguration,
getTokenOptions: GetTokenOptions = {}
): Promise<AccessToken | null> {
const { identityClient, scopes, clientId } = configuration;
logger.info(`${msiName}: Using the client assertion coming from environment variables.`);
let assertion: string;
try {
assertion = await readAssertion();
} catch (err) {
throw new Error(
`${msiName}: Failed to read ${azureFederatedTokenFilePath}, indicated by the environment variable AZURE_FEDERATED_TOKEN_FILE`
);
}
return msiGenericGetToken(
identityClient,
prepareRequestOptions(scopes, assertion, clientId || process.env.AZURE_CLIENT_ID),
expiresInParser,
getTokenOptions
);
}
};
}

Просмотреть файл

@ -7,13 +7,19 @@ import { IdentityClient } from "../../client/identityClient";
import { DefaultScopeSuffix } from "./constants";
import { MSIExpiresInParser } from "./models";
export function mapScopesToResource(scopes: string | string[]): string {
/**
* Most MSIs send requests to the IMDS endpoint, or a similar endpoint. These are GET requests that require sending a `resource` parameter on the query.
* This resource can be derived from the scopes received through the getToken call, as long as only one scope is received.
* Multiple scopes assume that the resulting token will have access to multiple resources, which won't be the case.
*
* For that reason, when we encounter multiple scopes, we return undefined.
* It's up to the individual MSI implementations to throw the errors (which helps us provide less generic errors).
*/
export function mapScopesToResource(scopes: string | string[]): string | undefined {
let scope = "";
if (Array.isArray(scopes)) {
if (scopes.length !== 1) {
throw new Error(
"To convert to a resource string the specified array must be exactly length 1"
);
return;
}
scope = scopes[0];

Просмотреть файл

@ -126,9 +126,6 @@ export async function prepareIdentityTests({
sendPromise: () => Promise<T | null>,
{ response }: { response: TestResponse }
): Promise<T | null> {
if ((https.request as any).restore) {
(https.request as any).restore.restore();
}
const request = createRequest();
sandbox.replace(
https,

Просмотреть файл

@ -21,34 +21,37 @@ import {
SendCredentialRequests
} from "../../httpRequestsCommon";
import { prepareIdentityTests } from "../../httpRequests";
import { AzureAuthorityHosts, DefaultAuthorityHost, DefaultTenantId } from "../../../src/constants";
describe("ManagedIdentityCredential", function() {
let envCopy: string = "";
let testContext: IdentityTestContext;
let sendCredentialRequests: SendCredentialRequests;
let envCopy: string = "";
beforeEach(async function() {
envCopy = JSON.stringify(process.env);
delete process.env.AZURE_CLIENT_ID;
delete process.env.AZURE_TENANT_ID;
delete process.env.AZURE_CLIENT_SECRET;
delete process.env.IDENTITY_ENDPOINT;
delete process.env.IDENTITY_HEADER;
delete process.env.MSI_ENDPOINT;
delete process.env.MSI_SECRET;
delete process.env.IDENTITY_SERVER_THUMBPRINT;
delete process.env.IMDS_ENDPOINT;
delete process.env.AZURE_AUTHORITY_HOST;
delete process.env.AZURE_POD_IDENTITY_AUTHORITY_HOST;
delete process.env.AZURE_FEDERATED_TOKEN_FILE;
testContext = await prepareIdentityTests({});
sendCredentialRequests = testContext.sendCredentialRequests;
});
afterEach(async function() {
const env = JSON.parse(envCopy);
process.env.IDENTITY_ENDPOINT = env.IDENTITY_ENDPOINT;
process.env.IDENTITY_HEADER = env.IDENTITY_HEADER;
process.env.MSI_ENDPOINT = env.MSI_ENDPOINT;
process.env.MSI_SECRET = env.MSI_SECRET;
process.env.IDENTITY_SERVER_THUMBPRINT = env.IDENTITY_SERVER_THUMBPRINT;
process.env.IMDS_ENDPOINT = env.IMDS_ENDPOINT;
process.env.AZURE_POD_IDENTITY_AUTHORITY_HOST = env.AZURE_POD_IDENTITY_AUTHORITY_HOST;
// Useful for record mode.
process.env.AZURE_CLIENT_ID = env.AZURE_CLIENT_ID;
process.env.AZURE_TENANT_ID = env.AZURE_TENANT_ID;
process.env.AZURE_CLIENT_SECRET = env.AZURE_CLIENT_SECRET;
await testContext.restore();
});
@ -236,7 +239,7 @@ describe("ManagedIdentityCredential", function() {
it("IMDS MSI skips verification if the AZURE_POD_IDENTITY_AUTHORITY_HOST environment variable is available", async function() {
process.env.AZURE_POD_IDENTITY_AUTHORITY_HOST = "token URL";
assert.ok(await imdsMsi.isAvailable());
assert.ok(await imdsMsi.isAvailable("https://endpoint/.default"));
});
it("IMDS MSI works even if the AZURE_POD_IDENTITY_AUTHORITY_HOST ends with a slash", async function() {
@ -369,7 +372,7 @@ describe("ManagedIdentityCredential", function() {
assert.equal(authDetails.result!.token, "token");
});
it("sends an authorization request correctly in an Azure Arc environment", async function() {
it("sends an authorization request correctly in an Azure Arc environment", async function(this: Mocha.Context) {
// Trigger Azure Arc behavior by setting environment variables
process.env.IMDS_ENDPOINT = "http://endpoint";
@ -482,4 +485,201 @@ describe("ManagedIdentityCredential", function() {
assert.fail("No token was returned!");
}
});
describe("File Exchange MSI", () => {
it("sends an authorization request correctly if token file path is available", async function(this: Mocha.Context) {
// Keep in mind that in this test we're also testing:
// - Parametrized client ID.
// - Non-default AZURE_TENANT_ID.
// - Non-default AZURE_AUTHORITY_HOST.
// - Support for single scopes.
const testTitle = this.test?.title || Date.now().toString();
const tempDir = mkdtempSync(join(tmpdir(), testTitle));
const tempFile = join(tempDir, testTitle);
const expectedAssertion = "{}";
writeFileSync(tempFile, expectedAssertion, { encoding: "utf8" });
// Trigger token file path by setting environment variables
process.env.AZURE_TENANT_ID = "my-tenant-id";
process.env.AZURE_FEDERATED_TOKEN_FILE = tempFile;
process.env.AZURE_AUTHORITY_HOST = AzureAuthorityHosts.AzureGovernment;
const parameterClientId = "client";
const authDetails = await sendCredentialRequests({
scopes: ["https://service/.default"],
credential: new ManagedIdentityCredential(parameterClientId),
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
const authRequest = authDetails.requests[0];
const body = new URLSearchParams(authRequest.body);
assert.strictEqual(
authRequest.url,
`${AzureAuthorityHosts.AzureGovernment}/${"my-tenant-id"}/oauth2/v2.0/token`
);
assert.strictEqual(authRequest.method, "POST");
assert.strictEqual(decodeURIComponent(body.get("client_id")!), parameterClientId);
assert.strictEqual(decodeURIComponent(body.get("client_assertion")!), expectedAssertion);
assert.strictEqual(
decodeURIComponent(body.get("client_assertion_type")!),
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
);
assert.strictEqual(decodeURIComponent(body.get("scope")!), "https://service/.default");
assert.strictEqual(authDetails.result!.token, "token");
});
it("reads from the token file again only after 5 minutes have passed", async function(this: Mocha.Context) {
// Keep in mind that in this test we're also testing:
// - Client ID on environment variable.
// - Default AZURE_TENANT_ID.
// - Default AZURE_AUTHORITY_HOST.
// - Support for multiple scopes.
const testTitle = this.test?.title || Date.now().toString();
const tempDir = mkdtempSync(join(tmpdir(), testTitle));
const tempFile = join(tempDir, testTitle);
const expectedAssertion = "{}";
writeFileSync(tempFile, expectedAssertion, { encoding: "utf8" });
// Trigger token file path by setting environment variables
process.env.AZURE_CLIENT_ID = "client-id";
process.env.AZURE_TENANT_ID = DefaultTenantId;
process.env.AZURE_FEDERATED_TOKEN_FILE = tempFile;
const credential = new ManagedIdentityCredential();
let authDetails = await sendCredentialRequests({
scopes: ["https://service/.default", "https://service2/.default"],
credential,
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
let authRequest = authDetails.requests[0];
let body = new URLSearchParams(authRequest.body);
assert.strictEqual(
authRequest.url,
`${DefaultAuthorityHost}/${DefaultTenantId}/oauth2/v2.0/token`
);
assert.strictEqual(authRequest.method, "POST");
assert.strictEqual(decodeURIComponent(body.get("client_id")!), process.env.AZURE_CLIENT_ID);
assert.strictEqual(decodeURIComponent(body.get("client_assertion")!), expectedAssertion);
assert.strictEqual(
decodeURIComponent(body.get("client_assertion_type")!),
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
);
assert.strictEqual(
decodeURIComponent(body.get("scope")!),
"https://service/.default https://service2/.default"
);
assert.strictEqual(authDetails.result!.token, "token");
const newExpectedAssertion = '{ "different": true }';
writeFileSync(tempFile, newExpectedAssertion, { encoding: "utf8" });
// A new credential means we read the file again
testContext.sandbox.restore();
authDetails = await sendCredentialRequests({
scopes: ["https://service/.default", "https://service2/.default"],
credential: new ManagedIdentityCredential("client"),
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
authRequest = authDetails.requests[0];
body = new URLSearchParams(authRequest.body);
assert.strictEqual(decodeURIComponent(body.get("client_assertion")!), newExpectedAssertion);
// If we stick to the original credential...
// Less than 5 minutes means we don't read the file again.
testContext.sandbox.restore();
testContext.sandbox.useFakeTimers();
authDetails = await sendCredentialRequests({
scopes: ["https://service/.default", "https://service2/.default"],
credential,
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
authRequest = authDetails.requests[0];
body = new URLSearchParams(authRequest.body);
assert.strictEqual(decodeURIComponent(body.get("client_assertion")!), expectedAssertion);
// More than 5 minutes means we read the file again.
testContext.sandbox.restore();
testContext.sandbox.useFakeTimers();
testContext.sandbox.clock.tick(1000 * 60 * 5);
authDetails = await sendCredentialRequests({
scopes: ["https://service/.default", "https://service2/.default"],
credential,
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
authRequest = authDetails.requests[0];
body = new URLSearchParams(authRequest.body);
assert.strictEqual(decodeURIComponent(body.get("client_assertion")!), newExpectedAssertion);
});
it("the provided client ID overrides the AZURE_CLIENT_ID environment variable", async function(this: Mocha.Context) {
const testTitle = this.test?.title || Date.now().toString();
const tempDir = mkdtempSync(join(tmpdir(), testTitle));
const tempFile = join(tempDir, testTitle);
const expectedAssertion = "{}";
writeFileSync(tempFile, expectedAssertion, { encoding: "utf8" });
// Trigger token file path by setting environment variables
process.env.AZURE_TENANT_ID = DefaultTenantId;
process.env.AZURE_FEDERATED_TOKEN_FILE = tempFile;
process.env.AZURE_CLIENT_ID = "client-id";
const parameterClientId = "client";
const authDetails = await sendCredentialRequests({
scopes: ["https://service/.default"],
credential: new ManagedIdentityCredential(parameterClientId),
secureResponses: [
createResponse(200, {
access_token: "token",
expires_on: 1
})
]
});
const authRequest = authDetails.requests[0];
const body = new URLSearchParams(authRequest.body);
assert.strictEqual(
authRequest.url,
`${DefaultAuthorityHost}/${DefaultTenantId}/oauth2/v2.0/token`
);
assert.strictEqual(authRequest.method, "POST");
assert.strictEqual(decodeURIComponent(body.get("client_id")!), parameterClientId);
});
});
});

Просмотреть файл

@ -48,6 +48,12 @@ describe("ChainedTokenCredential", function() {
const error = await getError<AggregateAuthenticationError>(
chainedTokenCredential.getToken("scope")
);
assert.equal(error.errors.length, 2);
assert.deepEqual(error.errors.length, 2);
assert.deepEqual(
error.message,
`ChainedTokenCredential authentication failed.
CredentialUnavailableError: unavailable.
CredentialUnavailableError: unavailable.`
);
});
});

Просмотреть файл

@ -82,12 +82,8 @@ describe("ApplicationCredential", function() {
const credential = new ApplicationCredential();
const error = await getError(credential.getToken(scope));
assert.equal(error.name, "AggregateAuthenticationError");
console.log(`${error.message}`);
assert.ok(
error.message.indexOf(
`CredentialUnavailableError: EnvironmentCredential is unavailable. No underlying credential could be used.\nCredentialUnavailableError: ManagedIdentityCredential authentication failed.`
) > -1
);
assert.ok(error.message.indexOf(`CredentialUnavailableError: EnvironmentCredential`) > -1);
assert.ok(error.message.indexOf(`CredentialUnavailableError: ManagedIdentityCredential`) > -1);
});
it("throws an AuthenticationError when getToken is called and ApplicationCredential authentication failed", async () => {