Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

msal managed identity test #29470

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -2,30 +2,18 @@
// Licensed under the MIT license.

import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { AppTokenProviderParameters, ConfidentialClientApplication } from "@azure/msal-node";
import {
AuthenticationError,
AuthenticationRequiredError,
CredentialUnavailableError,
} from "../../errors";
import { MSI, MSIConfiguration, MSIToken } from "./models";
import { MsalResult, MsalToken, ValidMsalToken } from "../../msal/types";
import { credentialLogger, formatError, formatSuccess } from "../../util/logging";
import { AuthenticationError, CredentialUnavailableError } from "../../errors";
import { MSIConfiguration } from "./models";
import { credentialLogger, formatError } from "../../util/logging";

import { DeveloperSignOnClientId } from "../../constants";
import { IdentityClient } from "../../client/identityClient";
import { TokenCredentialOptions } from "../../tokenCredentialOptions";
import { appServiceMsi2017 } from "./appServiceMsi2017";
import { appServiceMsi2019 } from "./appServiceMsi2019";
import { arcMsi } from "./arcMsi";
import { cloudShellMsi } from "./cloudShellMsi";
import { fabricMsi } from "./fabricMsi";
import { getLogLevel } from "@azure/logger";
import { getMSALLogLevel } from "../../msal/utils";
import { imdsMsi } from "./imdsMsi";
import { tokenExchangeMsi } from "./tokenExchangeMsi";
import { setLogLevel } from "@azure/logger";
import { tracingClient } from "../../util/tracing";
import { MsalClient, createMsalClient } from "../../msal/nodeFlows/msalClient";
import { resolveTenantId } from "../../util/tenantIdUtils";

setLogLevel("verbose");
const logger = credentialLogger("ManagedIdentityCredential");

/**
Expand Down Expand Up @@ -63,18 +51,14 @@ export interface ManagedIdentityCredentialResourceIdOptions extends TokenCredent
* https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview
*/
export class ManagedIdentityCredential implements TokenCredential {
private identityClient: IdentityClient;
private clientId: string | undefined;
private resourceId: string | undefined;
private isEndpointUnavailable: boolean | null = null;
private isAvailableIdentityClient: IdentityClient;
private confidentialApp: ConfidentialClientApplication;
private isAppTokenProviderInitialized: boolean = false;
private msiRetryConfig: MSIConfiguration["retryConfig"] = {
maxRetries: 5,
startDelayInMs: 800,
intervalIncrement: 2,
};
private msalClient: MsalClient;

/**
* Creates an instance of ManagedIdentityCredential with the client ID of a
Expand Down Expand Up @@ -125,107 +109,21 @@ export class ManagedIdentityCredential implements TokenCredential {
if (_options?.retryOptions?.maxRetries !== undefined) {
this.msiRetryConfig.maxRetries = _options.retryOptions.maxRetries;
}
this.identityClient = new IdentityClient(_options);
this.isAvailableIdentityClient = new IdentityClient({
..._options,
retryOptions: {
maxRetries: 0,
},
});

/** authority host validation and metadata discovery to be skipped in managed identity
* since this wasn't done previously before adding token cache support
*/
this.confidentialApp = new ConfidentialClientApplication({
auth: {
authority: "https://login.microsoftonline.com/managed_identity",
clientId: this.clientId ?? DeveloperSignOnClientId,
clientSecret: "dummy-secret",
cloudDiscoveryMetadata:
'{"tenant_discovery_endpoint":"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}',
authorityMetadata:
'{"token_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/common/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/{tenantid}/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/common/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/common/kerberos","tenant_region_scope":null,"cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}',
clientCapabilities: [],
},
system: {
loggerOptions: {
logLevel: getMSALLogLevel(getLogLevel()),
},
},
});
}

private cachedMSI: MSI | undefined;

private async cachedAvailableMSI(
scopes: string | string[],
getTokenOptions?: GetTokenOptions,
): Promise<MSI> {
if (this.cachedMSI) {
return this.cachedMSI;
}

const MSIs = [
arcMsi,
fabricMsi,
appServiceMsi2019,
appServiceMsi2017,
cloudShellMsi,
tokenExchangeMsi(),
imdsMsi,
];

for (const msi of MSIs) {
if (
await msi.isAvailable({
scopes,
identityClient: this.isAvailableIdentityClient,
clientId: this.clientId,
resourceId: this.resourceId,
getTokenOptions,
})
) {
this.cachedMSI = msi;
return msi;
}
}

throw new CredentialUnavailableError(
`${ManagedIdentityCredential.name} - No MSI credential available`,
logger.info("Initializing ManagedIdentityCredential");
logger.info(
`ManagedIdentityCredential clientId: ${this.clientId}, resourceId: ${this.resourceId}`,
);
}

private async authenticateManagedIdentity(
scopes: string | string[],
getTokenOptions?: GetTokenOptions,
): Promise<MSIToken | null> {
const { span, updatedOptions } = tracingClient.startSpan(
`${ManagedIdentityCredential.name}.authenticateManagedIdentity`,
getTokenOptions,
logger.info(
`ManagedIdentityCredential tenantId: ${resolveTenantId(logger, undefined, this.clientId)}`,
);
logger.info(`ManagedIdentityCredential options: ${JSON.stringify(_options)}`);

try {
// Determining the available MSI, and avoiding checking for other MSIs while the program is running.
const availableMSI = await this.cachedAvailableMSI(scopes, updatedOptions);
return availableMSI.getToken(
{
identityClient: this.identityClient,
scopes,
clientId: this.clientId,
resourceId: this.resourceId,
retryConfig: this.msiRetryConfig,
},
updatedOptions,
);
} catch (err: any) {
span.setStatus({
status: "error",
error: err,
});
throw err;
} finally {
span.end();
}
this.msalClient = createMsalClient(
this.clientId ?? DeveloperSignOnClientId,
resolveTenantId(logger, undefined, this.clientId),
_options,
);
}

/**
Expand All @@ -241,65 +139,26 @@ export class ManagedIdentityCredential implements TokenCredential {
scopes: string | string[],
options?: GetTokenOptions,
): Promise<AccessToken> {
let result: AccessToken | null = null;
const { span, updatedOptions } = tracingClient.startSpan(
`${ManagedIdentityCredential.name}.getToken`,
options,
);

try {
// isEndpointAvailable can be true, false, or null,
// If it's null, it means we don't yet know whether
// the endpoint is available and need to check for it.
if (this.isEndpointUnavailable !== true) {
const availableMSI = await this.cachedAvailableMSI(scopes, updatedOptions);
if (availableMSI.name === "tokenExchangeMsi") {
result = await this.authenticateManagedIdentity(scopes, updatedOptions);
} else {
const appTokenParameters: AppTokenProviderParameters = {
correlationId: this.identityClient.getCorrelationId(),
tenantId: options?.tenantId || "managed_identity",
scopes: Array.isArray(scopes) ? scopes : [scopes],
claims: options?.claims,
};

// Added a check to see if SetAppTokenProvider was already defined.
this.initializeSetAppTokenProvider();
const authenticationResult = await this.confidentialApp.acquireTokenByClientCredential({
...appTokenParameters,
});
result = this.handleResult(scopes, authenticationResult || undefined);
}
if (result === null) {
// If authenticateManagedIdentity returns null,
// it means no MSI endpoints are available.
// If so, we avoid trying to reach to them in future requests.
this.isEndpointUnavailable = true;

// It also means that the endpoint answered with either 200 or 201 (see the sendTokenRequest method),
// yet we had no access token. For this reason, we'll throw once with a specific message:
const error = new CredentialUnavailableError(
"The managed identity endpoint was reached, yet no tokens were received.",
);
logger.getToken.info(formatError(scopes, error));
throw error;
}

// Since `authenticateManagedIdentity` didn't throw, and the result was not null,
// We will assume that this endpoint is reachable from this point forward,
// and avoid pinging again to it.
this.isEndpointUnavailable = false;
} else {
// We've previously determined that the endpoint was unavailable,
// either because it was unreachable or permanently unable to authenticate.
const error = new CredentialUnavailableError(
"The managed identity endpoint is not currently available",
);
logger.getToken.info(formatError(scopes, error));
throw error;
}
logger.info(`${ManagedIdentityCredential.name} - getToken called with scopes: ${scopes}`);
logger.info("First, trying with MSAL");
const arrayScopes = Array.isArray(scopes) ? scopes : [scopes];
const token = await this.msalClient.getTokenByManagedIdentity(
arrayScopes,
this.clientId,
updatedOptions,
);

logger.getToken.info(formatSuccess(scopes));
return result;
logger.info("MSAL getTokenByManagedIdentity succeeded");
return token;
} catch (err: any) {
// CredentialUnavailable errors are expected to reach here.
// We intend them to bubble up, so that DefaultAzureCredential can catch them.
Expand Down Expand Up @@ -378,94 +237,4 @@ export class ManagedIdentityCredential implements TokenCredential {
span.end();
}
}

/**
* Handles the MSAL authentication result.
* If the result has an account, we update the local account reference.
* If the token received is invalid, an error will be thrown depending on what's missing.
*/
private handleResult(
scopes: string | string[],
result?: MsalResult,
getTokenOptions?: GetTokenOptions,
): AccessToken {
this.ensureValidMsalToken(scopes, result, getTokenOptions);
logger.getToken.info(formatSuccess(scopes));
return {
token: result.accessToken,
expiresOnTimestamp: result.expiresOn.getTime(),
};
}

/**
* Ensures the validity of the MSAL token
*/
private ensureValidMsalToken(
scopes: string | string[],
msalToken?: MsalToken,
getTokenOptions?: GetTokenOptions,
): asserts msalToken is ValidMsalToken {
const error = (message: string): Error => {
logger.getToken.info(message);
return new AuthenticationRequiredError({
scopes: Array.isArray(scopes) ? scopes : [scopes],
getTokenOptions,
message,
});
};
if (!msalToken) {
throw error("No response");
}
if (!msalToken.expiresOn) {
throw error(`Response had no "expiresOn" property.`);
}
if (!msalToken.accessToken) {
throw error(`Response had no "accessToken" property.`);
}
}

private initializeSetAppTokenProvider(): void {
if (!this.isAppTokenProviderInitialized) {
this.confidentialApp.SetAppTokenProvider(async (appTokenProviderParameters) => {
logger.info(
`SetAppTokenProvider invoked with parameters- ${JSON.stringify(
appTokenProviderParameters,
)}`,
);
const getTokenOptions: GetTokenOptions = {
...appTokenProviderParameters,
};
logger.info(
`authenticateManagedIdentity invoked with scopes- ${JSON.stringify(
appTokenProviderParameters.scopes,
)} and getTokenOptions - ${JSON.stringify(getTokenOptions)}`,
);
const resultToken = await this.authenticateManagedIdentity(
appTokenProviderParameters.scopes,
getTokenOptions,
);

if (resultToken) {
logger.info(`SetAppTokenProvider will save the token in cache`);

const expiresInSeconds = resultToken?.expiresOnTimestamp
? Math.floor((resultToken.expiresOnTimestamp - Date.now()) / 1000)
: 0;
return {
accessToken: resultToken?.token,
expiresInSeconds,
};
} else {
logger.info(
`SetAppTokenProvider token has "no_access_token_returned" as the saved token`,
);
return {
accessToken: "no_access_token_returned",
expiresInSeconds: 0,
};
}
});
this.isAppTokenProviderInitialized = true;
}
}
}