diff --git a/sdk/identity/identity/CHANGELOG.md b/sdk/identity/identity/CHANGELOG.md index f969e1f1245a..a0d398e93cec 100644 --- a/sdk/identity/identity/CHANGELOG.md +++ b/sdk/identity/identity/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features Added +- Added support for App Service 2019 resource in Managed Identity Credential. - All of our credentials now support a new option on their constructor: `loggingOptions`, which allows configuring the logging options of the HTTP pipelines. - Within the new `loggingOptions` we have also added `allowLoggingAccountIdentifiers`, a property that if set to true logs information specific to the authenticated account after each successful authentication, including: the Client ID, the Tenant ID, the Object ID of the authenticated user, and if possible the User Principal Name. - Added `disableAuthorityValidation`, which allows passing any `authorityHost` regardless of whether it can be validated or not. This is specially useful in private clouds. diff --git a/sdk/identity/identity/src/credentials/managedIdentityCredential/appServiceMsi2019.ts b/sdk/identity/identity/src/credentials/managedIdentityCredential/appServiceMsi2019.ts new file mode 100644 index 000000000000..e63a7823dec4 --- /dev/null +++ b/sdk/identity/identity/src/credentials/managedIdentityCredential/appServiceMsi2019.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + createHttpHeaders, + createPipelineRequest, + PipelineRequestOptions, +} from "@azure/core-rest-pipeline"; +import { AccessToken, GetTokenOptions } from "@azure/core-auth"; +import { TokenResponseParsedBody } from "../../client/identityClient"; +import { credentialLogger } from "../../util/logging"; +import { MSI, MSIConfiguration } from "./models"; +import { mapScopesToResource } from "./utils"; + +const msiName = "ManagedIdentityCredential - AppServiceMSI 2019"; +const logger = credentialLogger(msiName); + +/** + * Formats the expiration date of the received token into the number of milliseconds between that date and midnight, January 1, 1970. + */ +function expiresOnParser(requestBody: TokenResponseParsedBody): number { + // App Service always returns string expires_on values. + return Date.parse(requestBody.expires_on! as string); +} + +/** + * Generates the options used on the request for an access token. + */ +function prepareRequestOptions( + scopes: string | string[], + clientId?: string, + resourceId?: string +): PipelineRequestOptions { + const resource = mapScopesToResource(scopes); + if (!resource) { + throw new Error(`${msiName}: Multiple scopes are not supported.`); + } + + const queryParameters: Record = { + resource, + "api-version": "2019-08-01", + }; + + if (clientId) { + queryParameters.client_id = clientId; + } + + if (resourceId) { + queryParameters.mi_res_id = resourceId; + } + const query = new URLSearchParams(queryParameters); + + // 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(`${msiName}: Missing environment variable: IDENTITY_ENDPOINT`); + } + if (!process.env.IDENTITY_HEADER) { + throw new Error(`${msiName}: Missing environment variable: IDENTITY_HEADER`); + } + + return { + url: `${process.env.IDENTITY_ENDPOINT}?${query.toString()}`, + method: "GET", + headers: createHttpHeaders({ + Accept: "application/json", + "X-IDENTITY-HEADER": process.env.IDENTITY_HEADER, + }), + }; +} + +/** + * Defines how to determine whether the Azure App Service MSI is available, and also how to retrieve a token from the Azure App Service MSI. + */ +export const appServiceMsi2019: MSI = { + async isAvailable({ scopes }): Promise { + 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); + if (!result) { + logger.info( + `${msiName}: Unavailable. The environment variables needed are: IDENTITY_ENDPOINT and IDENTITY_HEADER.` + ); + } + return result; + }, + async getToken( + configuration: MSIConfiguration, + getTokenOptions: GetTokenOptions = {} + ): Promise { + const { identityClient, scopes, clientId, resourceId } = configuration; + + logger.info( + `${msiName}: Using the endpoint and the secret coming form the environment variables: IDENTITY_ENDPOINT=${process.env.IDENTITY_ENDPOINT} and IDENTITY_HEADER=[REDACTED].` + ); + + const request = createPipelineRequest({ + abortSignal: getTokenOptions.abortSignal, + ...prepareRequestOptions(scopes, clientId, resourceId), + // Generally, MSI endpoints use the HTTP protocol, without transport layer security (TLS). + allowInsecureConnection: true, + }); + const tokenResponse = await identityClient.sendTokenRequest(request, expiresOnParser); + return (tokenResponse && tokenResponse.accessToken) || null; + }, +}; diff --git a/sdk/identity/identity/src/credentials/managedIdentityCredential/index.ts b/sdk/identity/identity/src/credentials/managedIdentityCredential/index.ts index 245ae2048d86..598bf7c2b830 100644 --- a/sdk/identity/identity/src/credentials/managedIdentityCredential/index.ts +++ b/sdk/identity/identity/src/credentials/managedIdentityCredential/index.ts @@ -16,6 +16,7 @@ import { MSI } from "./models"; import { arcMsi } from "./arcMsi"; import { tokenExchangeMsi } from "./tokenExchangeMsi"; import { fabricMsi } from "./fabricMsi"; +import { appServiceMsi2019 } from "./appServiceMsi2019"; const logger = credentialLogger("ManagedIdentityCredential"); @@ -123,7 +124,15 @@ export class ManagedIdentityCredential implements TokenCredential { return this.cachedMSI; } - const MSIs = [fabricMsi, appServiceMsi2017, cloudShellMsi, arcMsi, tokenExchangeMsi(), imdsMsi]; + const MSIs = [ + fabricMsi, + appServiceMsi2019, + appServiceMsi2017, + cloudShellMsi, + arcMsi, + tokenExchangeMsi(), + imdsMsi, + ]; for (const msi of MSIs) { if ( diff --git a/sdk/identity/identity/test/internal/node/managedIdentityCredential.spec.ts b/sdk/identity/identity/test/internal/node/managedIdentityCredential.spec.ts index a822c6dd3c3c..fb696c217c8d 100644 --- a/sdk/identity/identity/test/internal/node/managedIdentityCredential.spec.ts +++ b/sdk/identity/identity/test/internal/node/managedIdentityCredential.spec.ts @@ -558,6 +558,86 @@ describe("ManagedIdentityCredential", function () { } }); + it("sends an authorization request correctly in an App Service 2019 environment by client id", async () => { + // Trigger App Service behavior by setting environment variables + process.env.IDENTITY_ENDPOINT = "https://endpoint"; + process.env.IDENTITY_HEADER = "HEADER"; + + const authDetails = await testContext.sendCredentialRequests({ + scopes: ["https://service/.default"], + credential: new ManagedIdentityCredential("client"), + secureResponses: [ + createResponse(200, { + access_token: "token", + expires_on: "06/20/2021 02:57:58 +00:00", + }), + ], + }); + + const authRequest = authDetails.requests[0]; + console.log(`authDetails = ${authDetails}`); + console.log(`authRequest = ${authRequest}`); + const query = new URLSearchParams(authRequest.url.split("?")[1]); + + assert.equal(authRequest.method, "GET"); + assert.equal(query.get("client_id"), "client"); + assert.equal(decodeURIComponent(query.get("resource")!), "https://service"); + assert.ok( + authRequest.url.startsWith(process.env.IDENTITY_ENDPOINT), + "URL does not start with expected host and path" + ); + assert.equal(authRequest.headers["X-IDENTITY-HEADER"], process.env.IDENTITY_HEADER); + assert.ok( + authRequest.url.indexOf(`api-version=2019-08-01`) > -1, + "URL does not have expected version" + ); + if (authDetails.result?.token) { + assert.equal(authDetails.result.expiresOnTimestamp, 1624157878000); + } else { + assert.fail("No token was returned!"); + } + }); + + it("sends an authorization request correctly in an App Service 2019 environment by resource id", async () => { + // Trigger App Service behavior by setting environment variables + process.env.IDENTITY_ENDPOINT = "https://endpoint"; + process.env.IDENTITY_HEADER = "HEADER"; + + const authDetails = await testContext.sendCredentialRequests({ + scopes: ["https://service/.default"], + credential: new ManagedIdentityCredential({ resourceId: "RESOURCE-ID" }), + secureResponses: [ + createResponse(200, { + access_token: "token", + expires_on: "06/20/2021 02:57:58 +00:00", + }), + ], + }); + + const authRequest = authDetails.requests[0]; + console.log(`authDetails = ${authDetails}`); + console.log(`authRequest = ${authRequest}`); + const query = new URLSearchParams(authRequest.url.split("?")[1]); + + assert.equal(authRequest.method, "GET"); + assert.equal(decodeURIComponent(query.get("resource")!), "https://service"); + assert.equal(decodeURIComponent(query.get("mi_res_id")!), "RESOURCE-ID"); + assert.ok( + authRequest.url.startsWith(process.env.IDENTITY_ENDPOINT), + "URL does not start with expected host and path" + ); + assert.equal(authRequest.headers["X-IDENTITY-HEADER"], process.env.IDENTITY_HEADER); + assert.ok( + authRequest.url.indexOf(`api-version=2019-08-01`) > -1, + "URL does not have expected version" + ); + if (authDetails.result?.token) { + assert.equal(authDetails.result.expiresOnTimestamp, 1624157878000); + } else { + assert.fail("No token was returned!"); + } + }); + it("sends an authorization request correctly in an Cloud Shell environment", async () => { // Trigger Cloud Shell behavior by setting environment variables process.env.MSI_ENDPOINT = "https://endpoint";