[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:
Родитель
7156a37da9
Коммит
6ccd67cb3d
|
@ -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 () => {
|
||||
|
|
Загрузка…
Ссылка в новой задаче