Skip to content

Commit

Permalink
[Identity] Reverting ClientSecretCredential and UsernamePasswordCrede…
Browse files Browse the repository at this point in the history
…ntial to v1 only for browsers, and fixing Karma browser bundles (Azure#14910)

This PR makes it so `ClientSecretCredential` behaves like how it did on v1 only for browsers. It also ensures browser bundles can be used when built with Karma.

---

## ClientSecretCredential (and also UsernamePasswordCredential)

Since the inception of Identity, we've been relying on `ClientSecretCredential` to be available in browsers to make it easier for us to automate bowser tests.

Whether `ClientSecretCredential` should continue being available on browsers in the future, is something yet to be decided. We started a conversation on the matter here: Azure#14879 

Our current plan is to switch master back to v1, as you can see here: Azure#14909

Since the v2 changes to `ClientSecretCredential` won't be reverted, that means that as soon as we move master back to v1, all of our current clients will immediately start working with the latest `ClientSecretCredential`.

`ClientSecretCredential` was rewritten to use MSAL, specifically `@azure/msal-node`, which is not intended to work on browsers.

This PR makes it so `ClientSecretCredential` is kept as it was on v1 only for browsers. This will:

- Allow us to keep our current browser recordings.
- Allow us to continue using this credential for browser tests.
- On v1, dropping this credential on browsers would be a breaking change, so this technically reduces the differences with v1.

**Important:**
Keep in mind that while we've always supported this credential in browsers, it can only work if browser security is disabled. This isn't recommended outside of our tests, and it's not something we should be advertising.

(Later we also did the same to `UsernamePasswordCredential`).

---

## Browser bundles

While my initial change was scoped to one credential, I asked Harsha to test this PR and he found that the browser bundles would not work with Identity v2. We decided to fix it right away since this PR wouldn't be very useful with broken browser bundles.
  • Loading branch information
sadasant authored and vindicatesociety committed Apr 26, 2021
1 parent 58ac5f6 commit be4db4f
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 9 deletions.
4 changes: 3 additions & 1 deletion sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
"./dist-esm/src/credentials/environmentCredential.js": "./dist-esm/src/credentials/environmentCredential.browser.js",
"./dist-esm/src/credentials/managedIdentityCredential/index.js": "./dist-esm/src/credentials/managedIdentityCredential/index.browser.js",
"./dist-esm/src/credentials/clientCertificateCredential.js": "./dist-esm/src/credentials/clientCertificateCredential.browser.js",
"./dist-esm/src/credentials/clientSecretCredential.js": "./dist-esm/src/credentials/clientSecretCredential.browser.js",
"./dist-esm/src/credentials/deviceCodeCredential.js": "./dist-esm/src/credentials/deviceCodeCredential.browser.js",
"./dist-esm/src/credentials/defaultAzureCredential.js": "./dist-esm/src/credentials/defaultAzureCredential.browser.js",
"./dist-esm/src/credentials/authorizationCodeCredential.js": "./dist-esm/src/credentials/authorizationCodeCredential.browser.js",
"./dist-esm/src/credentials/interactiveBrowserCredential.js": "./dist-esm/src/credentials/interactiveBrowserCredential.browser.js",
"./dist-esm/src/credentials/visualStudioCodeCredential.js": "./dist-esm/src/credentials/visualStudioCodeCredential.browser.js",
"./dist-esm/src/credentials/usernamePasswordCredential.js": "./dist-esm/src/credentials/usernamePasswordCredential.browser.js",
"./dist-esm/src/util/authHostEnv.js": "./dist-esm/src/util/authHostEnv.browser.js",
"./dist-esm/src/tokenCache/TokenCachePersistence.js": "./dist-esm/src/tokenCache/TokenCachePersistence.browser.js"
},
Expand Down Expand Up @@ -43,7 +45,7 @@
"test:browser": "npm run clean && npm run build:test && npm run unit-test:browser && npm run integration-test:browser",
"test:node": "npm run clean && npm run build:test && npm run unit-test:node && npm run integration-test:node",
"test": "npm run clean && npm run build:test && npm run unit-test && npm run integration-test",
"unit-test:browser": "echo skipped",
"unit-test:browser": "karma start --single-run",
"unit-test:node": "mocha --require source-map-support/register --reporter ../../../common/tools/mocha-multi-reporter.js --timeout 180000 --full-trace \"test-dist/**/*.js\"",
"unit-test": "npm run unit-test:node && npm run unit-test:browser",
"docs": "typedoc --excludePrivate --excludeNotExported --excludeExternals --stripInternal --mode file --out ./dist/docs ./src"
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/identity/src/client/identityClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
createPipelineFromOptions,
isNode
} from "@azure/core-http";
import { INetworkModule, NetworkRequestOptions, NetworkResponse } from "@azure/msal-node";
import { INetworkModule, NetworkRequestOptions, NetworkResponse } from "@azure/msal-common";
import { SpanStatusCode } from "@azure/core-tracing";
import { AbortController, AbortSignalLike } from "@azure/abort-controller";
import { AuthenticationError, AuthenticationErrorName } from "./errors";
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 qs from "qs";
import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-http";
import { TokenCredentialOptions, IdentityClient } from "../client/identityClient";
import { createSpan } from "../util/tracing";
import { SpanStatusCode } from "@azure/core-tracing";
import { credentialLogger, formatError, formatSuccess } from "../util/logging";
import { getIdentityTokenEndpointSuffix } from "../util/identityTokenEndpoint";

const logger = credentialLogger("ClientSecretCredential");

// This credential is exported on browser bundles for development purposes.
// For this credential to work in browsers, browsers would need to have security features disabled.
// Please do not disable your browser security features.

/**
* Enables authentication to Azure Active Directory using a client secret
* that was generated for an App Registration. More information on how
* to configure a client secret can be found here:
*
* https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#add-credentials-to-your-web-application
*
*/
export class ClientSecretCredential implements TokenCredential {
private identityClient: IdentityClient;
private tenantId: string;
private clientId: string;
private clientSecret: string;

/**
* Creates an instance of the ClientSecretCredential with the details
* needed to authenticate against Azure Active Directory with a client
* secret.
*
* @param tenantId - The Azure Active Directory tenant (directory) ID.
* @param clientId - The client (application) ID of an App Registration in the tenant.
* @param clientSecret - A client secret that was generated for the App Registration.
* @param options - Options for configuring the client which makes the authentication request.
*/
constructor(
tenantId: string,
clientId: string,
clientSecret: string,
options?: TokenCredentialOptions
) {
this.identityClient = new IdentityClient(options);
this.tenantId = tenantId;
this.clientId = clientId;
this.clientSecret = clientSecret;
}

/**
* Authenticates with Azure Active Directory and returns an access token if
* successful. If authentication cannot be performed at this time, this method may
* return null. If an error occurs during authentication, an {@link AuthenticationError}
* containing failure details will be thrown.
*
* @param scopes - The list of scopes for which the token will have access.
* @param options - The options used to configure any requests this
* TokenCredential implementation might make.
*/
public async getToken(
scopes: string | string[],
options?: GetTokenOptions
): Promise<AccessToken | null> {
const { span, updatedOptions: newOptions } = createSpan(
"ClientSecretCredential-getToken",
options
);
try {
const urlSuffix = getIdentityTokenEndpointSuffix(this.tenantId);
const webResource = this.identityClient.createWebResource({
url: `${this.identityClient.authorityHost}/${this.tenantId}/${urlSuffix}`,
method: "POST",
disableJsonStringifyOnBody: true,
deserializationMapper: undefined,
body: qs.stringify({
response_type: "token",
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
scope: typeof scopes === "string" ? scopes : scopes.join(" ")
}),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
abortSignal: options && options.abortSignal,
spanOptions: newOptions.tracingOptions && newOptions.tracingOptions.spanOptions,
tracingContext: newOptions.tracingOptions && newOptions.tracingOptions.tracingContext
});

const tokenResponse = await this.identityClient.sendTokenRequest(webResource);
logger.getToken.info(formatSuccess(scopes));
return (tokenResponse && tokenResponse.accessToken) || null;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
});
logger.getToken.info(formatError(scopes, err));
throw err;
} finally {
span.end();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import qs from "qs";
import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-http";
import { TokenCredentialOptions, IdentityClient } from "../client/identityClient";
import { createSpan } from "../util/tracing";
import { SpanStatusCode } from "@azure/core-tracing";
import { credentialLogger, formatSuccess, formatError } from "../util/logging";
import { getIdentityTokenEndpointSuffix } from "../util/identityTokenEndpoint";
import { checkTenantId } from "../util/checkTenantId";

const logger = credentialLogger("UsernamePasswordCredential");

/**
* Enables authentication to Azure Active Directory with a user's
* username and password. This credential requires a high degree of
* trust so you should only use it when other, more secure credential
* types can't be used.
*/
export class UsernamePasswordCredential implements TokenCredential {
private identityClient: IdentityClient;
private tenantId: string;
private clientId: string;
private username: string;
private password: string;

/**
* Creates an instance of the UsernamePasswordCredential with the details
* needed to authenticate against Azure Active Directory with a username
* and password.
*
* @param tenantIdOrName - The Azure Active Directory tenant (directory) ID or name.
* @param clientId - The client (application) ID of an App Registration in the tenant.
* @param username - The user account's e-mail address (user name).
* @param password - The user account's account password
* @param options - Options for configuring the client which makes the authentication request.
*/
constructor(
tenantIdOrName: string,
clientId: string,
username: string,
password: string,
options?: TokenCredentialOptions
) {
checkTenantId(logger, tenantIdOrName);

this.identityClient = new IdentityClient(options);
this.tenantId = tenantIdOrName;
this.clientId = clientId;
this.username = username;
this.password = password;
}

/**
* Authenticates with Azure Active Directory and returns an access token if
* successful. If authentication cannot be performed at this time, this method may
* return null. If an error occurs during authentication, an {@link AuthenticationError}
* containing failure details will be thrown.
*
* @param scopes - The list of scopes for which the token will have access.
* @param options - The options used to configure any requests this
* TokenCredential implementation might make.
*/
public async getToken(
scopes: string | string[],
options?: GetTokenOptions
): Promise<AccessToken | null> {
const { span, updatedOptions: newOptions } = createSpan(
"UsernamePasswordCredential-getToken",
options
);
try {
const urlSuffix = getIdentityTokenEndpointSuffix(this.tenantId);
const webResource = this.identityClient.createWebResource({
url: `${this.identityClient.authorityHost}/${this.tenantId}/${urlSuffix}`,
method: "POST",
disableJsonStringifyOnBody: true,
deserializationMapper: undefined,
body: qs.stringify({
response_type: "token",
grant_type: "password",
client_id: this.clientId,
username: this.username,
password: this.password,
scope: typeof scopes === "string" ? scopes : scopes.join(" ")
}),
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
abortSignal: options && options.abortSignal,
spanOptions: newOptions.tracingOptions && newOptions.tracingOptions.spanOptions,
tracingContext: newOptions.tracingOptions && newOptions.tracingOptions.tracingContext
});

const tokenResponse = await this.identityClient.sendTokenRequest(webResource);
logger.getToken.info(formatSuccess(scopes));
return (tokenResponse && tokenResponse.accessToken) || null;
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
});
logger.getToken.info(formatError(scopes, err));
throw err;
} finally {
span.end();
}
}
}
9 changes: 4 additions & 5 deletions sdk/identity/identity/src/msal/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as msalNode from "@azure/msal-node";
import * as msalCommon from "@azure/msal-common";
import { AccessToken, GetTokenOptions } from "@azure/core-http";
import { v4 as uuidv4 } from "uuid";
Expand Down Expand Up @@ -84,16 +83,16 @@ export const defaultLoggerCallback: (logger: CredentialLogger) => msalCommon.ILo
return;
}
switch (level) {
case msalNode.LogLevel.Error:
case msalCommon.LogLevel.Error:
logger.info(`MSAL Browser V2 error: ${message}`);
return;
case msalNode.LogLevel.Info:
case msalCommon.LogLevel.Info:
logger.info(`MSAL Browser V2 info message: ${message}`);
return;
case msalNode.LogLevel.Verbose:
case msalCommon.LogLevel.Verbose:
logger.info(`MSAL Browser V2 verbose message: ${message}`);
return;
case msalNode.LogLevel.Warning:
case msalCommon.LogLevel.Warning:
logger.info(`MSAL Browser V2 warning: ${message}`);
return;
}
Expand Down
15 changes: 13 additions & 2 deletions sdk/identity/identity/test/internal/identityClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,14 @@ describe("IdentityClient", function() {
"secret",
mockHttp.tokenCredentialOptions
);

await assertRejects(credential.getToken("https://test/.default"), (error) => {
assert.equal(error.name, "CredentialUnavailableError");
// Keep in mind that this credential has different implementations in Node and in browsers.
if (isNode) {
assert.equal(error.name, "CredentialUnavailableError");
} else {
assert.equal(error.name, "AuthenticationError");
}
return true;
});
});
Expand Down Expand Up @@ -123,7 +129,12 @@ describe("IdentityClient", function() {
);

await assertRejects(credential.getToken("https://test/.default"), (error) => {
assert.equal(error.name, "CredentialUnavailableError");
// Keep in mind that this credential has different implementations in Node and in browsers.
if (isNode) {
assert.equal(error.name, "CredentialUnavailableError");
} else {
assert.equal(error.name, "AuthenticationError");
}
return true;
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { ClientSecretCredential } from "../../../src";
import { MockAuthHttpClient, assertClientCredentials } from "../../authTestUtils";

describe("ClientSecretCredential", function() {
it("sends an authorization request with the given credentials", async () => {
const mockHttpClient = new MockAuthHttpClient();

const credential = new ClientSecretCredential(
"tenant",
"client",
"secret",
mockHttpClient.tokenCredentialOptions
);

await credential.getToken("scope");

const authRequest = mockHttpClient.requests[0];
assertClientCredentials(authRequest, "tenant", "client", "secret");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import assert from "assert";
import { UsernamePasswordCredential } from "../../../src";
import { MockAuthHttpClient } from "../../authTestUtils";

describe("UsernamePasswordCredential", function() {
it("sends an authorization request with the given username and password", async () => {
const mockHttpClient = new MockAuthHttpClient();

const credential = new UsernamePasswordCredential(
"tenant",
"client",
"user@domain.com",
"p4s$w0rd",
mockHttpClient.tokenCredentialOptions
);

await credential.getToken("scope");

const authRequest = await mockHttpClient.requests[0];
if (!authRequest) {
assert.fail("No authentication request was intercepted");
} else {
assert.strictEqual(
authRequest.url.startsWith(`https://authority/tenant`),
true,
"Request body doesn't contain expected tenantId"
);
assert.strictEqual(
authRequest.body.indexOf(`client_id=client`) > -1,
true,
"Request body doesn't contain expected clientId"
);
assert.strictEqual(
authRequest.body.indexOf(`username=user%40domain.com`) > -1,
true,
"Request body doesn't contain expected username"
);
assert.strictEqual(
authRequest.body.indexOf(`password=p4s%24w0rd`) > -1,
true,
"Request body doesn't contain expected username"
);
}
});
});

0 comments on commit be4db4f

Please sign in to comment.