diff --git a/sdk/identity/identity/package.json b/sdk/identity/identity/package.json index f05afe7fcc85..24d08ab3eac3 100644 --- a/sdk/identity/identity/package.json +++ b/sdk/identity/identity/package.json @@ -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" }, @@ -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" diff --git a/sdk/identity/identity/src/client/identityClient.ts b/sdk/identity/identity/src/client/identityClient.ts index e5229868a8a1..e75bc8ac1f9b 100644 --- a/sdk/identity/identity/src/client/identityClient.ts +++ b/sdk/identity/identity/src/client/identityClient.ts @@ -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"; diff --git a/sdk/identity/identity/src/credentials/clientSecretCredential.browser.ts b/sdk/identity/identity/src/credentials/clientSecretCredential.browser.ts new file mode 100644 index 000000000000..b4b2c308c7a2 --- /dev/null +++ b/sdk/identity/identity/src/credentials/clientSecretCredential.browser.ts @@ -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 { + 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(); + } + } +} diff --git a/sdk/identity/identity/src/credentials/usernamePasswordCredential.browser.ts b/sdk/identity/identity/src/credentials/usernamePasswordCredential.browser.ts new file mode 100644 index 000000000000..e76b83b68863 --- /dev/null +++ b/sdk/identity/identity/src/credentials/usernamePasswordCredential.browser.ts @@ -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 { + 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(); + } + } +} diff --git a/sdk/identity/identity/src/msal/utils.ts b/sdk/identity/identity/src/msal/utils.ts index 6fc75ce7162b..4d2aa19d4432 100644 --- a/sdk/identity/identity/src/msal/utils.ts +++ b/sdk/identity/identity/src/msal/utils.ts @@ -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"; @@ -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; } diff --git a/sdk/identity/identity/test/internal/identityClient.spec.ts b/sdk/identity/identity/test/internal/identityClient.spec.ts index c3a26cd08ddf..f0e274015dde 100644 --- a/sdk/identity/identity/test/internal/identityClient.spec.ts +++ b/sdk/identity/identity/test/internal/identityClient.spec.ts @@ -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; }); }); @@ -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; }); }); diff --git a/sdk/identity/identity/test/public/browser/clientSecretCredential.spec.ts b/sdk/identity/identity/test/public/browser/clientSecretCredential.spec.ts new file mode 100644 index 000000000000..873cb520c453 --- /dev/null +++ b/sdk/identity/identity/test/public/browser/clientSecretCredential.spec.ts @@ -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"); + }); +}); diff --git a/sdk/identity/identity/test/public/browser/usernamePasswordCredential.spec.ts b/sdk/identity/identity/test/public/browser/usernamePasswordCredential.spec.ts new file mode 100644 index 000000000000..2bd1945bd1e3 --- /dev/null +++ b/sdk/identity/identity/test/public/browser/usernamePasswordCredential.spec.ts @@ -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" + ); + } + }); +});