diff --git a/sdk/identity/identity/CHANGELOG.md b/sdk/identity/identity/CHANGELOG.md index f72bb98ced5f..e5130537657f 100644 --- a/sdk/identity/identity/CHANGELOG.md +++ b/sdk/identity/identity/CHANGELOG.md @@ -4,10 +4,13 @@ ### Features Added +- Added the `OnBehalfOfCredential`, which allows users to authenticate through the [On-Behalf-Of authentication flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow). - `ManagedIdentityCredential` now supports token exchange authentication. ### Breaking Changes +- `ClientCertificateCredential` now evaluates the validity of the PEM certificate path on `getToken` and not on the constructor. + #### Breaking Changes from 2.0.0-beta.5 - The property named `selectedCredential` that was added to `ChainedTokenCredential` and `DefaultAzureCredential` has been removed, since customers reported that logging was enough. diff --git a/sdk/identity/identity/package.json b/sdk/identity/identity/package.json index edf163979301..2a7de8818043 100644 --- a/sdk/identity/identity/package.json +++ b/sdk/identity/identity/package.json @@ -22,6 +22,7 @@ "./dist-esm/src/credentials/usernamePasswordCredential.js": "./dist-esm/src/credentials/usernamePasswordCredential.browser.js", "./dist-esm/src/credentials/azurePowerShellCredential.js": "./dist-esm/src/credentials/azurePowerShellCredential.browser.js", "./dist-esm/src/credentials/applicationCredential.js": "./dist-esm/src/credentials/applicationCredential.browser.js", + "./dist-esm/src/credentials/onBehalfOfCredential.js": "./dist-esm/src/credentials/onBehalfOfCredential.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", "./dist-esm/src/extensions/consumer.js": "./dist-esm/src/extensions/consumer.browser.js", diff --git a/sdk/identity/identity/review/identity.api.md b/sdk/identity/identity/review/identity.api.md index 46e790880c93..ff303446caf7 100644 --- a/sdk/identity/identity/review/identity.api.md +++ b/sdk/identity/identity/review/identity.api.md @@ -248,6 +248,33 @@ export class ManagedIdentityCredential implements TokenCredential { getToken(scopes: string | string[], options?: GetTokenOptions): Promise; } +// @public +export class OnBehalfOfCredential implements TokenCredential { + constructor(configuration: OnBehalfOfCredentialSecretConfiguration | OnBehalfOfCredentialCertificateConfiguration, options?: OnBehalfOfCredentialOptions); + getToken(scopes: string | string[], options?: GetTokenOptions): Promise; + } + +// @public +export interface OnBehalfOfCredentialCertificateConfiguration { + certificatePath: string; + clientId: string; + sendCertificateChain?: boolean; + tenantId: string; + userAssertionToken: string; +} + +// @public +export interface OnBehalfOfCredentialOptions extends TokenCredentialOptions, CredentialPersistenceOptions { +} + +// @public +export interface OnBehalfOfCredentialSecretConfiguration { + clientId: string; + clientSecret: string; + tenantId: string; + userAssertionToken: string; +} + // @public export enum RegionalAuthority { AsiaEast = "eastasia", diff --git a/sdk/identity/identity/src/credentials/onBehalfOfCredential.browser.ts b/sdk/identity/identity/src/credentials/onBehalfOfCredential.browser.ts new file mode 100644 index 000000000000..85f654626b74 --- /dev/null +++ b/sdk/identity/identity/src/credentials/onBehalfOfCredential.browser.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential, AccessToken } from "@azure/core-auth"; +import { credentialLogger, formatError } from "../util/logging"; + +const credentialName = "OnBehalfOfCredential"; +const BrowserNotSupportedError = new Error(`${credentialName}: Not supported in the browser.`); +const logger = credentialLogger(credentialName); + +export class OnBehalfOfCredential implements TokenCredential { + constructor() { + logger.info(formatError("", BrowserNotSupportedError)); + throw BrowserNotSupportedError; + } + + public getToken(): Promise { + logger.getToken.info(formatError("", BrowserNotSupportedError)); + throw BrowserNotSupportedError; + } +} diff --git a/sdk/identity/identity/src/credentials/onBehalfOfCredential.ts b/sdk/identity/identity/src/credentials/onBehalfOfCredential.ts new file mode 100644 index 000000000000..1c67df5da7bc --- /dev/null +++ b/sdk/identity/identity/src/credentials/onBehalfOfCredential.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; + +import { MsalOnBehalfOf } from "../msal/nodeFlows/msalOnBehalfOf"; +import { credentialLogger } from "../util/logging"; +import { trace } from "../util/tracing"; +import { MsalFlow } from "../msal/flows"; +import { OnBehalfOfCredentialOptions } from "./onBehalfOfCredentialOptions"; + +const credentialName = "OnBehalfOfCredential"; +const logger = credentialLogger(credentialName); + +/** + * Defines the configuration parameters to authenticate the {@link OnBehalfOfCredential} with a secret. + */ +export interface OnBehalfOfCredentialSecretConfiguration { + /** + * The Azure Active Directory tenant (directory) ID. + */ + tenantId: string; + /** + * The client (application) ID of an App Registration in the tenant. + */ + clientId: string; + /** + * A client secret that was generated for the App Registration. + */ + clientSecret: string; + /** + * The user assertion for the On-Behalf-Of flow. + */ + userAssertionToken: string; +} + +/** + * Defines the configuration parameters to authenticate the {@link OnBehalfOfCredential} with a certificate. + */ +export interface OnBehalfOfCredentialCertificateConfiguration { + /** + * The Azure Active Directory tenant (directory) ID. + */ + tenantId: string; + /** + * The client (application) ID of an App Registration in the tenant. + */ + clientId: string; + /** + * The path to a PEM-encoded public/private key certificate on the filesystem. + */ + certificatePath: string; + /** + * Option to include x5c header for SubjectName and Issuer name authorization. + * Set this option to send base64 encoded public certificate in the client assertion header as an x5c claim + */ + sendCertificateChain?: boolean; + /** + * The user assertion for the On-Behalf-Of flow. + */ + userAssertionToken: string; +} + +/** + * Enables authentication to Azure Active Directory using the [On Behalf Of flow](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow). + */ +export class OnBehalfOfCredential implements TokenCredential { + private msalFlow: MsalFlow; + + /** + * Creates an instance of the {@link OnBehalfOfCredential} with the details + * needed to authenticate against Azure Active Directory with a client + * secret or a path to a PEM certificate, and an user assertion. + * + * Example using the `KeyClient` from [\@azure/keyvault-keys](https://www.npmjs.com/package/\@azure/keyvault-keys): + * + * ```ts + * const tokenCredential = new OnBehalfOfCredential({ + * tenantId, + * clientId, + * clientSecret, // or `certificatePath: "/path/to/certificate.pem" + * userAssertionToken: "access-token" + * }); + * const client = new KeyClient("vault-url", tokenCredential); + * + * await client.getKey("key-name"); + * ``` + * + * @param configuration - Configuration specific to this credential. + * @param options - Optional parameters, generally common across credentials. + */ + constructor( + private configuration: + | OnBehalfOfCredentialSecretConfiguration + | OnBehalfOfCredentialCertificateConfiguration, + private options: OnBehalfOfCredentialOptions = {} + ) { + const { tenantId, clientId, userAssertionToken } = configuration; + const secretConfiguration = configuration as OnBehalfOfCredentialSecretConfiguration; + const certificateConfiguration = configuration as OnBehalfOfCredentialCertificateConfiguration; + if ( + !tenantId || + !clientId || + !(secretConfiguration.clientSecret || certificateConfiguration.certificatePath) || + !userAssertionToken + ) { + throw new Error( + `${credentialName}: tenantId, clientId, clientSecret (or certificatePath) and userAssertionToken are required parameters.` + ); + } + this.msalFlow = new MsalOnBehalfOf({ + ...this.options, + ...this.configuration, + logger, + tokenCredentialOptions: this.options + }); + } + + /** + * Authenticates with Azure Active Directory and returns an access token if successful. + * If authentication fails, a {@link CredentialUnavailableError} will be thrown with the details of the failure. + * + * @param scopes - The list of scopes for which the token will have access. + * @param options - The options used to configure the underlying network requests. + */ + async getToken(scopes: string | string[], options: GetTokenOptions = {}): Promise { + return trace(`${credentialName}.getToken`, options, async (newOptions) => { + const arrayScopes = Array.isArray(scopes) ? scopes : [scopes]; + return this.msalFlow!.getToken(arrayScopes, newOptions); + }); + } +} diff --git a/sdk/identity/identity/src/credentials/onBehalfOfCredentialOptions.ts b/sdk/identity/identity/src/credentials/onBehalfOfCredentialOptions.ts new file mode 100644 index 000000000000..089b888242c5 --- /dev/null +++ b/sdk/identity/identity/src/credentials/onBehalfOfCredentialOptions.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredentialOptions } from "../client/identityClient"; +import { CredentialPersistenceOptions } from "./credentialPersistenceOptions"; + +/** + * Optional parameters for the {@link OnBehalfOfCredential} class. + */ +export interface OnBehalfOfCredentialOptions + extends TokenCredentialOptions, + CredentialPersistenceOptions {} diff --git a/sdk/identity/identity/src/index.ts b/sdk/identity/identity/src/index.ts index 1fdf0046f06c..abcf060c4e98 100644 --- a/sdk/identity/identity/src/index.ts +++ b/sdk/identity/identity/src/index.ts @@ -59,6 +59,13 @@ export { VisualStudioCodeCredentialOptions } from "./credentials/visualStudioCodeCredential"; +export { + OnBehalfOfCredential, + OnBehalfOfCredentialSecretConfiguration, + OnBehalfOfCredentialCertificateConfiguration +} from "./credentials/onBehalfOfCredential"; +export { OnBehalfOfCredentialOptions } from "./credentials/onBehalfOfCredentialOptions"; + export { TokenCachePersistenceOptions } from "./msal/nodeFlows/tokenCachePersistenceOptions"; export { diff --git a/sdk/identity/identity/src/msal/nodeFlows/msalClientCertificate.ts b/sdk/identity/identity/src/msal/nodeFlows/msalClientCertificate.ts index c8fb88139c8f..23033712df84 100644 --- a/sdk/identity/identity/src/msal/nodeFlows/msalClientCertificate.ts +++ b/sdk/identity/identity/src/msal/nodeFlows/msalClientCertificate.ts @@ -1,22 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { readFileSync } from "fs"; +import { readFile } from "fs"; import { createHash } from "crypto"; - +import { promisify } from "util"; import { AccessToken } from "@azure/core-auth"; import { MsalNodeOptions, MsalNode } from "./nodeCommon"; import { formatError } from "../../util/logging"; import { CredentialFlowGetTokenOptions } from "../credentials"; +const readFileAsync = promisify(readFile); + /** * Options that can be passed to configure MSAL to handle client certificates. * @internal */ export interface MSALClientCertificateOptions extends MsalNodeOptions { /** - * Location of the certificate. + * Location of the PEM certificate. */ certificatePath: string; /** @@ -45,60 +47,78 @@ interface CertificateParts { x5c: string; } +/** + * Tries to asynchronously load a certificate from the given path. + * + * @param certificatePath - Path to the certificate. + * @param sendCertificateChain - Option to include x5c header for SubjectName and Issuer name authorization. + * @returns - The certificate parts, or `undefined` if the certificate could not be loaded. + * @internal + */ +export async function parseCertificate( + certificatePath: string, + sendCertificateChain?: boolean +): Promise { + const certificateParts: Partial = {}; + + certificateParts.certificateContents = await readFileAsync(certificatePath, "utf8"); + if (sendCertificateChain) { + certificateParts.x5c = certificateParts.certificateContents; + } + + const certificatePattern = /(-+BEGIN CERTIFICATE-+)(\n\r?|\r\n?)([A-Za-z0-9+/\n\r]+=*)(\n\r?|\r\n?)(-+END CERTIFICATE-+)/g; + const publicKeys: string[] = []; + + // Match all possible certificates, in the order they are in the file. These will form the chain that is used for x5c + let match; + do { + match = certificatePattern.exec(certificateParts.certificateContents); + if (match) { + publicKeys.push(match[3]); + } + } while (match); + + if (publicKeys.length === 0) { + throw new Error("The file at the specified path does not contain a PEM-encoded certificate."); + } + + certificateParts.thumbprint = createHash("sha1") + .update(Buffer.from(publicKeys[0], "base64")) + .digest("hex") + .toUpperCase(); + + return certificateParts as CertificateParts; +} + /** * MSAL client certificate client. Calls to MSAL's confidential application's `acquireTokenByClientCredential` during `doGetToken`. * @internal */ export class MsalClientCertificate extends MsalNode { + private certificatePath: string; private sendCertificateChain?: boolean; constructor(options: MSALClientCertificateOptions) { super(options); this.requiresConfidential = true; + this.certificatePath = options.certificatePath; this.sendCertificateChain = options.sendCertificateChain; - - const parts = this.parseCertificate(options.certificatePath); - this.msalConfig.auth.clientCertificate = { - thumbprint: parts.thumbprint, - privateKey: parts.certificateContents, - x5c: parts.x5c - }; } - private parseCertificate(certificatePath: string): CertificateParts { - const certificateParts: Partial = {}; - - certificateParts.certificateContents = readFileSync(certificatePath, "utf8"); - if (this.sendCertificateChain) { - certificateParts.x5c = certificateParts.certificateContents; - } - - const certificatePattern = /(-+BEGIN CERTIFICATE-+)(\n\r?|\r\n?)([A-Za-z0-9+/\n\r]+=*)(\n\r?|\r\n?)(-+END CERTIFICATE-+)/g; - const publicKeys: string[] = []; - - // Match all possible certificates, in the order they are in the file. These will form the chain that is used for x5c - let match; - do { - match = certificatePattern.exec(certificateParts.certificateContents); - if (match) { - publicKeys.push(match[3]); - } - } while (match); - - if (publicKeys.length === 0) { - const error = new Error( - "The file at the specified path does not contain a PEM-encoded certificate." - ); + // Changing the MSAL configuration asynchronously + async init(options?: CredentialFlowGetTokenOptions): Promise { + try { + const parts = await parseCertificate(this.certificatePath, this.sendCertificateChain); + this.msalConfig.auth.clientCertificate = { + thumbprint: parts.thumbprint, + privateKey: parts.certificateContents, + x5c: parts.x5c + }; + } catch (error) { this.logger.info(formatError("", error)); throw error; } - - certificateParts.thumbprint = createHash("sha1") - .update(Buffer.from(publicKeys[0], "base64")) - .digest("hex") - .toUpperCase(); - - return certificateParts as CertificateParts; + return super.init(options); } protected async doGetToken( diff --git a/sdk/identity/identity/src/msal/nodeFlows/msalClientSecret.ts b/sdk/identity/identity/src/msal/nodeFlows/msalClientSecret.ts index dbea908575b9..809583ec6654 100644 --- a/sdk/identity/identity/src/msal/nodeFlows/msalClientSecret.ts +++ b/sdk/identity/identity/src/msal/nodeFlows/msalClientSecret.ts @@ -11,6 +11,9 @@ import { MsalNodeOptions, MsalNode } from "./nodeCommon"; * @internal */ export interface MSALClientSecretOptions extends MsalNodeOptions { + /** + * A client secret that was generated for the App Registration. + */ clientSecret: string; } diff --git a/sdk/identity/identity/src/msal/nodeFlows/msalOnBehalfOf.ts b/sdk/identity/identity/src/msal/nodeFlows/msalOnBehalfOf.ts new file mode 100644 index 000000000000..89fd9d57569d --- /dev/null +++ b/sdk/identity/identity/src/msal/nodeFlows/msalOnBehalfOf.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AccessToken } from "@azure/core-auth"; +import { formatError } from "../../util/logging"; +import { CredentialFlowGetTokenOptions } from "../credentials"; +import { parseCertificate } from "./msalClientCertificate"; +import { MsalNodeOptions, MsalNode } from "./nodeCommon"; + +/** + * Options that can be passed to configure MSAL to handle On-Behalf-Of authentication requests. + * @internal + */ +export interface MSALOnBehalfOfOptions extends MsalNodeOptions { + /** + * A client secret that was generated for the App Registration. + */ + clientSecret?: string; + /** + * Location of the PEM certificate. + */ + certificatePath?: string; + /** + * Option to include x5c header for SubjectName and Issuer name authorization. + * Set this option to send base64 encoded public certificate in the client assertion header as an x5c claim + */ + sendCertificateChain?: boolean; + /** + * The user assertion for the On-Behalf-Of flow. + */ + userAssertionToken: string; +} + +/** + * MSAL on behalf of flow. Calls to MSAL's confidential application's `acquireTokenOnBehalfOf` during `doGetToken`. + * @internal + */ +export class MsalOnBehalfOf extends MsalNode { + private userAssertionToken: string; + private certificatePath?: string; + private sendCertificateChain?: boolean; + private clientSecret?: string; + + constructor(options: MSALOnBehalfOfOptions) { + super(options); + this.logger.info("Initialized MSAL's On-Behalf-Of flow"); + this.requiresConfidential = true; + this.userAssertionToken = options.userAssertionToken; + this.certificatePath = options.certificatePath; + this.sendCertificateChain = options.sendCertificateChain; + this.clientSecret = options.clientSecret; + } + + // Changing the MSAL configuration asynchronously + async init(options?: CredentialFlowGetTokenOptions): Promise { + if (this.certificatePath) { + try { + const parts = await parseCertificate(this.certificatePath, this.sendCertificateChain); + this.msalConfig.auth.clientCertificate = { + thumbprint: parts.thumbprint, + privateKey: parts.certificateContents, + x5c: parts.x5c + }; + } catch (error) { + this.logger.info(formatError("", error)); + throw error; + } + } else { + this.msalConfig.auth.clientSecret = this.clientSecret; + } + return super.init(options); + } + + protected async doGetToken( + scopes: string[], + options: CredentialFlowGetTokenOptions = {} + ): Promise { + try { + const result = await this.confidentialApp!.acquireTokenOnBehalfOf({ + scopes, + correlationId: options.correlationId, + authority: options.authority, + oboAssertion: this.userAssertionToken + }); + return this.handleResult(scopes, this.clientId, result || undefined); + } catch (err) { + throw this.handleError(scopes, err, options); + } + } +} diff --git a/sdk/identity/identity/test/httpRequests.ts b/sdk/identity/identity/test/httpRequests.ts index efd60d8d3d7e..cc22b88e075c 100644 --- a/sdk/identity/identity/test/httpRequests.ts +++ b/sdk/identity/identity/test/httpRequests.ts @@ -114,8 +114,8 @@ export async function prepareIdentityTests({ } if (replaceLogger) { - AzureLogger.log = (args) => { - logMessages.push(args); + AzureLogger.log = (...args) => { + logMessages.push(args.join(" ")); }; } @@ -171,23 +171,29 @@ export async function prepareIdentityTests({ const providerObject = provider === "http" ? http : https; const totalOptions: http.RequestOptions[] = []; - sandbox.replace( - providerObject, - "request", - (options: string | URL | http.RequestOptions, resolve: any) => { - totalOptions.push(options as http.RequestOptions); + try { + sandbox.replace( + providerObject, + "request", + (options: string | URL | http.RequestOptions, resolve: any) => { + totalOptions.push(options as http.RequestOptions); - const { response, error } = responses.shift()!; - if (error) { - throw error; - } else { - resolve(responseToIncomingMessage(response!)); + const { response, error } = responses.shift()!; + if (error) { + throw error; + } else { + resolve(responseToIncomingMessage(response!)); + } + const request = createRequest(); + spies.push(sandbox.spy(request, "end")); + return request; } - const request = createRequest(); - spies.push(sandbox.spy(request, "end")); - return request; - } - ); + ); + } catch (e) { + console.debug( + "Failed to replace the request. This might be expected if you're running multiple sendCredentialRequests() calls." + ); + } return totalOptions; }; diff --git a/sdk/identity/identity/test/internal/node/clientCertificateCredential.spec.ts b/sdk/identity/identity/test/internal/node/clientCertificateCredential.spec.ts index b6fecb15792d..ac9eb61dd798 100644 --- a/sdk/identity/identity/test/internal/node/clientCertificateCredential.spec.ts +++ b/sdk/identity/identity/test/internal/node/clientCertificateCredential.spec.ts @@ -79,14 +79,19 @@ describe("ClientCertificateCredential (internal)", function() { }); }); - it("throws when given a file that doesn't contain a PEM-formatted certificate", () => { - assert.throws(() => { - new ClientCertificateCredential( - "tenant", - "client", - path.resolve(__dirname, "../src/index.ts") - ); - }); + it("throws when given a file that doesn't contain a PEM-formatted certificate", async function(this: Context) { + const fullPath = path.resolve(__dirname, "../src/index.ts"); + const credential = new ClientCertificateCredential("tenant", "client", fullPath); + + let error: Error | undefined; + try { + await credential.getToken(scope); + } catch (_error) { + error = _error; + } + + assert.ok(error); + assert.deepEqual(error?.message, `ENOENT: no such file or directory, open '${fullPath}'`); }); it("Authenticates silently after the initial request", async function(this: Context) { diff --git a/sdk/identity/identity/test/internal/node/onBehalfOfCredential.spec.ts b/sdk/identity/identity/test/internal/node/onBehalfOfCredential.spec.ts new file mode 100644 index 000000000000..478e311f87f4 --- /dev/null +++ b/sdk/identity/identity/test/internal/node/onBehalfOfCredential.spec.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as path from "path"; +import { assert } from "chai"; +import { isNode } from "@azure/core-util"; +import { OnBehalfOfCredential } from "../../../src"; +import { + createResponse, + IdentityTestContext, + SendCredentialRequests +} from "../../httpRequestsCommon"; +import { prepareIdentityTests, prepareMSALResponses } from "../../httpRequests"; + +describe("OnBehalfOfCredential", function() { + let testContext: IdentityTestContext; + let sendCredentialRequests: SendCredentialRequests; + + beforeEach(async function() { + testContext = await prepareIdentityTests({ replaceLogger: true, logLevel: "verbose" }); + sendCredentialRequests = testContext.sendCredentialRequests; + }); + afterEach(async function() { + if (isNode) { + delete process.env.AZURE_AUTHORITY_HOST; + } + await testContext.restore(); + }); + + it("authenticates with a secret", async () => { + const credential = new OnBehalfOfCredential({ + tenantId: "adfs", + clientId: "client", + clientSecret: "secret", + userAssertionToken: "user-assertion" + }); + + const newMSALClientLogs = () => + testContext.logMessages.filter((message) => + message.match("Initialized MSAL's On-Behalf-Of flow") + ).length; + + const authDetails = await sendCredentialRequests({ + scopes: ["https://test/.default"], + credential, + secureResponses: [ + ...prepareMSALResponses(), + createResponse(200, { + access_token: "token", + expires_on: "06/20/2019 02:57:58 +00:00" + }) + ] + }); + + assert.isNumber(authDetails.result?.expiresOnTimestamp); + + // Just checking that a new MSAL client was created. + // This kind of testing will be important as we improve the On-Behalf-Of Credential. + assert.equal(newMSALClientLogs(), 1); + }); + + it("authenticates with a certificate path", async () => { + const certificatePath = path.join("assets", "fake-cert.pem"); + const credential = new OnBehalfOfCredential({ + tenantId: "adfs", + clientId: "client", + certificatePath, + userAssertionToken: "user-assertion" + }); + + const newMSALClientLogs = () => + testContext.logMessages.filter((message) => + message.match("Initialized MSAL's On-Behalf-Of flow") + ).length; + + const authDetails = await sendCredentialRequests({ + scopes: ["https://test/.default"], + credential, + secureResponses: [ + ...prepareMSALResponses(), + createResponse(200, { + access_token: "token", + expires_on: "06/20/2019 02:57:58 +00:00" + }) + ] + }); + + assert.isNumber(authDetails.result?.expiresOnTimestamp); + + // Just checking that a new MSAL client was created. + // This kind of testing will be important as we improve the On-Behalf-Of Credential. + assert.equal(newMSALClientLogs(), 1); + }); +});