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

[identity] add support for app service 2019 #20789

Merged
merged 8 commits into from
Mar 22, 2022
Merged
2 changes: 2 additions & 0 deletions sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features Added

- Added support for App Service 2019 resource in Managed Identity Credential.

### Breaking Changes

### Bugs Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<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);
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<AccessToken | null> {
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].`
);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sadasant Can we go over the log warnings and info if they are appropriate for this? Also do we need any logs for clientId ?

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;
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,86 @@ describe("ManagedIdentityCredential", function () {
}
});

it("sends an authorization request correctly in an App Service 2019 environment by client id", async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Follow up with the crew in standup - do we need to remove support for App service 2017 version?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok turns out we should not remove 2017! So this PR is good 🙂

// 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";
Expand Down