Skip to content

Commit

Permalink
[Identity] Support exchanging k8s token to AAD token (#16688)
Browse files Browse the repository at this point in the history
This is a simplified version of what @chlowell did on this PR: Azure/azure-sdk-for-python#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
  • Loading branch information
sadasant authored Sep 8, 2021
1 parent 7156a37 commit 6ccd67c
Show file tree
Hide file tree
Showing 16 changed files with 534 additions and 111 deletions.
5 changes: 5 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

- `ManagedIdentityCredential` now supports token exchange authentication.

### Breaking Changes

#### Breaking Changes from 2.0.0-beta.5
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/identity/src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@

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
// convert it into a JavaScript-formatted date
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"
Expand All @@ -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 {
Expand All @@ -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
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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}`
);
}

Expand All @@ -82,39 +87,46 @@ 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.`
);
}

const requestOptions = {
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" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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 {
Expand All @@ -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
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -85,7 +101,7 @@ export const fabricMsi: MSI = {

return msiGenericGetToken(
identityClient,
prepareRequestOptions(resource, clientId),
prepareRequestOptions(scopes, clientId),
expiresInParser,
getTokenOptions
);
Expand Down
Loading

0 comments on commit 6ccd67c

Please sign in to comment.