Skip to content

Commit 6b099a2

Browse files
authored
[identity] DeviceCodeCredential uses MSALClient (Azure#29405)
### Packages impacted by this PR @azure/identity ### Issues associated with this PR Resolves Azure#29377 ### Describe the problem that is addressed by this PR This PR makes the following changes to the Identity library: 1. Migrates DeviceCodeCredential to the MSALClient flow 2. Skips silent authentication for client credential based flows 3. Allows passing disableAutomaticAuthentication as an option param instead of during client construction The reason this is important and exciting is because DeviceCodeCredential is the first PublicClientApplication based flow that is being migrated to MSALClient!
1 parent b2b8254 commit 6b099a2

File tree

6 files changed

+251
-62
lines changed

6 files changed

+251
-62
lines changed

sdk/identity/identity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010

1111
### Bug Fixes
1212

13+
- `ClientSecretCredential`, `ClientCertificateCredential`, and `ClientAssertionCredential` no longer try silent authentication unnecessarily as per the MSAL guidelines. For more information please refer to [the Entra documentation on token caching](https://learn.microsoft.com/entra/identity-platform/msal-acquire-cache-tokens#recommended-call-pattern-for-public-client-applications). [#29405](https://github.com/Azure/azure-sdk-for-js/pull/29405)
14+
1315
### Other Changes
1416

17+
- `DeviceCodeCredential` migrated to use MSALClient internally instead of MSALNode flow. This is an internal refactoring and should not result in any behavioral changes. [#29405](https://github.com/Azure/azure-sdk-for-js/pull/29405)
18+
1519
## 4.2.0 (2024-04-30)
1620

1721
### Features Added

sdk/identity/identity/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "js",
44
"TagPrefix": "js/identity/identity",
5-
"Tag": "js/identity/identity_58e656fd32"
5+
"Tag": "js/identity/identity_72abb85c88"
66
}

sdk/identity/identity/src/credentials/deviceCodeCredential.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"
55
import {
66
processMultiTenantRequest,
77
resolveAdditionallyAllowedTenantIds,
8+
resolveTenantId,
89
} from "../util/tenantIdUtils";
9-
import { DeviceCodeCredentialOptions, DeviceCodeInfo } from "./deviceCodeCredentialOptions";
10+
import {
11+
DeviceCodeCredentialOptions,
12+
DeviceCodeInfo,
13+
DeviceCodePromptCallback,
14+
} from "./deviceCodeCredentialOptions";
1015
import { AuthenticationRecord } from "../msal/types";
11-
import { MsalDeviceCode } from "../msal/nodeFlows/msalDeviceCode";
12-
import { MsalFlow } from "../msal/flows";
1316
import { credentialLogger } from "../util/logging";
1417
import { ensureScopes } from "../util/scopeUtils";
1518
import { tracingClient } from "../util/tracing";
19+
import { MsalClient, createMsalClient } from "../msal/nodeFlows/msalClient";
20+
import { DeveloperSignOnClientId } from "../constants";
1621

1722
const logger = credentialLogger("DeviceCodeCredential");
1823

@@ -31,8 +36,9 @@ export function defaultDeviceCodePromptCallback(deviceCodeInfo: DeviceCodeInfo):
3136
export class DeviceCodeCredential implements TokenCredential {
3237
private tenantId?: string;
3338
private additionallyAllowedTenantIds: string[];
34-
private msalFlow: MsalFlow;
3539
private disableAutomaticAuthentication?: boolean;
40+
private msalClient: MsalClient;
41+
private userPromptCallback: DeviceCodePromptCallback;
3642

3743
/**
3844
* Creates an instance of DeviceCodeCredential with the details needed
@@ -59,10 +65,11 @@ export class DeviceCodeCredential implements TokenCredential {
5965
this.additionallyAllowedTenantIds = resolveAdditionallyAllowedTenantIds(
6066
options?.additionallyAllowedTenants,
6167
);
62-
this.msalFlow = new MsalDeviceCode({
68+
const clientId = options?.clientId ?? DeveloperSignOnClientId;
69+
const tenantId = resolveTenantId(logger, options?.tenantId, clientId);
70+
this.userPromptCallback = options?.userPromptCallback ?? defaultDeviceCodePromptCallback;
71+
this.msalClient = createMsalClient(clientId, tenantId, {
6372
...options,
64-
logger,
65-
userPromptCallback: options?.userPromptCallback || defaultDeviceCodePromptCallback,
6673
tokenCredentialOptions: options || {},
6774
});
6875
this.disableAutomaticAuthentication = options?.disableAutomaticAuthentication;
@@ -93,7 +100,7 @@ export class DeviceCodeCredential implements TokenCredential {
93100
);
94101

95102
const arrayScopes = ensureScopes(scopes);
96-
return this.msalFlow.getToken(arrayScopes, {
103+
return this.msalClient.getTokenByDeviceCode(arrayScopes, this.userPromptCallback, {
97104
...newOptions,
98105
disableAutomaticAuthentication: this.disableAutomaticAuthentication,
99106
});
@@ -120,8 +127,11 @@ export class DeviceCodeCredential implements TokenCredential {
120127
options,
121128
async (newOptions) => {
122129
const arrayScopes = Array.isArray(scopes) ? scopes : [scopes];
123-
await this.msalFlow.getToken(arrayScopes, newOptions);
124-
return this.msalFlow.getActiveAccount();
130+
await this.msalClient.getTokenByDeviceCode(arrayScopes, this.userPromptCallback, {
131+
...newOptions,
132+
disableAutomaticAuthentication: false, // this method should always allow user interaction
133+
});
134+
return this.msalClient.getActiveAccount();
125135
},
126136
);
127137
}

sdk/identity/identity/src/msal/nodeFlows/msalClient.ts

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,44 @@ import {
1313
getKnownAuthorities,
1414
getMSALLogLevel,
1515
handleMsalError,
16+
msalToPublic,
1617
publicToMsal,
1718
} from "../utils";
1819

1920
import { AuthenticationRequiredError } from "../../errors";
20-
import { CertificateParts } from "../types";
21+
import { AuthenticationRecord, CertificateParts } from "../types";
2122
import { IdentityClient } from "../../client/identityClient";
2223
import { MsalNodeOptions } from "./msalNodeCommon";
2324
import { calculateRegionalAuthority } from "../../regionalAuthority";
2425
import { getLogLevel } from "@azure/logger";
2526
import { resolveTenantId } from "../../util/tenantIdUtils";
27+
import { DeviceCodePromptCallback } from "../../credentials/deviceCodeCredentialOptions";
2628

2729
/**
2830
* The logger for all MsalClient instances.
2931
*/
3032
const msalLogger = credentialLogger("MsalClient");
3133

34+
export interface GetTokenWithSilentAuthOptions extends GetTokenOptions {
35+
/**
36+
* Disables automatic authentication. If set to true, the method will throw an error if the user needs to authenticate.
37+
*
38+
* @remarks
39+
*
40+
* This option will be set to `false` when the user calls `authenticate` directly on a credential that supports it.
41+
*/
42+
disableAutomaticAuthentication?: boolean;
43+
}
44+
3245
/**
3346
* Represents a client for interacting with the Microsoft Authentication Library (MSAL).
3447
*/
3548
export interface MsalClient {
49+
getTokenByDeviceCode(
50+
arrayScopes: string[],
51+
userPromptCallback: DeviceCodePromptCallback,
52+
options?: GetTokenWithSilentAuthOptions,
53+
): Promise<AccessToken>;
3654
/**
3755
* Retrieves an access token by using a client certificate.
3856
*
@@ -74,12 +92,21 @@ export interface MsalClient {
7492
clientSecret: string,
7593
options?: GetTokenOptions,
7694
): Promise<AccessToken>;
95+
96+
/**
97+
* Retrieves the last authenticated account. This method expects an authentication record to have been previously loaded.
98+
*
99+
* An authentication record could be loaded by calling the `getToken` method, or by providing an `authenticationRecord` when creating a credential.
100+
*/
101+
getActiveAccount(): AuthenticationRecord | undefined;
77102
}
78103

79104
/**
80105
* Options for creating an instance of the MsalClient.
81106
*/
82-
export type MsalClientOptions = Partial<Omit<MsalNodeOptions, "clientId" | "tenantId">>;
107+
export type MsalClientOptions = Partial<
108+
Omit<MsalNodeOptions, "clientId" | "tenantId" | "disableAutomaticAuthentication">
109+
>;
83110

84111
/**
85112
* Generates the configuration for MSAL (Microsoft Authentication Library).
@@ -173,6 +200,40 @@ export function createMsalClient(
173200
pluginConfiguration: msalPlugins.generatePluginConfiguration(createMsalClientOptions),
174201
};
175202

203+
const publicApps: Map<string, msal.PublicClientApplication> = new Map();
204+
async function getPublicApp(
205+
options: GetTokenOptions = {},
206+
): Promise<msal.PublicClientApplication> {
207+
const appKey = options.enableCae ? "CAE" : "default";
208+
209+
let publicClientApp = publicApps.get(appKey);
210+
if (publicClientApp) {
211+
msalLogger.getToken.info("Existing PublicClientApplication found in cache, returning it.");
212+
return publicClientApp;
213+
}
214+
215+
// Initialize a new app and cache it
216+
msalLogger.getToken.info(
217+
`Creating new PublicClientApplication with CAE ${options.enableCae ? "enabled" : "disabled"}.`,
218+
);
219+
220+
const cachePlugin = options.enableCae
221+
? state.pluginConfiguration.cache.cachePluginCae
222+
: state.pluginConfiguration.cache.cachePlugin;
223+
224+
state.msalConfig.auth.clientCapabilities = options.enableCae ? ["cp1"] : undefined;
225+
226+
publicClientApp = new msal.PublicClientApplication({
227+
...state.msalConfig,
228+
broker: { nativeBrokerPlugin: state.pluginConfiguration.broker.nativeBrokerPlugin },
229+
cache: { cachePlugin: await cachePlugin },
230+
});
231+
232+
publicApps.set(appKey, publicClientApp);
233+
234+
return publicClientApp;
235+
}
236+
176237
const confidentialApps: Map<string, msal.ConfidentialClientApplication> = new Map();
177238
async function getConfidentialApp(
178239
options: GetTokenOptions = {},
@@ -272,7 +333,7 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
272333
async function withSilentAuthentication(
273334
msalApp: msal.ConfidentialClientApplication | msal.PublicClientApplication,
274335
scopes: Array<string>,
275-
options: GetTokenOptions,
336+
options: GetTokenWithSilentAuthOptions,
276337
onAuthenticationRequired: () => Promise<msal.AuthenticationResult | null>,
277338
): Promise<AccessToken> {
278339
let response: msal.AuthenticationResult | null = null;
@@ -282,7 +343,7 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
282343
if (e.name !== "AuthenticationRequiredError") {
283344
throw e;
284345
}
285-
if (createMsalClientOptions.disableAutomaticAuthentication) {
346+
if (options.disableAutomaticAuthentication) {
286347
throw new AuthenticationRequiredError({
287348
scopes,
288349
getTokenOptions: options,
@@ -324,14 +385,24 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
324385

325386
const msalApp = await getConfidentialApp(options);
326387

327-
return withSilentAuthentication(msalApp, scopes, options, () =>
328-
msalApp.acquireTokenByClientCredential({
388+
try {
389+
const response = await msalApp.acquireTokenByClientCredential({
329390
scopes,
330391
authority: state.msalConfig.auth.authority,
331392
azureRegion: calculateRegionalAuthority(),
332393
claims: options?.claims,
333-
}),
334-
);
394+
});
395+
ensureValidMsalToken(scopes, response, options);
396+
397+
msalLogger.getToken.info(formatSuccess(scopes));
398+
399+
return {
400+
token: response.accessToken,
401+
expiresOnTimestamp: response.expiresOn.getTime(),
402+
};
403+
} catch (err: any) {
404+
throw handleMsalError(scopes, err, options);
405+
}
335406
}
336407

337408
async function getTokenByClientAssertion(
@@ -345,15 +416,25 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
345416

346417
const msalApp = await getConfidentialApp(options);
347418

348-
return withSilentAuthentication(msalApp, scopes, options, () =>
349-
msalApp.acquireTokenByClientCredential({
419+
try {
420+
const response = await msalApp.acquireTokenByClientCredential({
350421
scopes,
351422
authority: state.msalConfig.auth.authority,
352423
azureRegion: calculateRegionalAuthority(),
353424
claims: options?.claims,
354425
clientAssertion,
355-
}),
356-
);
426+
});
427+
ensureValidMsalToken(scopes, response, options);
428+
429+
msalLogger.getToken.info(formatSuccess(scopes));
430+
431+
return {
432+
token: response.accessToken,
433+
expiresOnTimestamp: response.expiresOn.getTime(),
434+
};
435+
} catch (err: any) {
436+
throw handleMsalError(scopes, err, options);
437+
}
357438
}
358439

359440
async function getTokenByClientCertificate(
@@ -366,20 +447,66 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
366447
state.msalConfig.auth.clientCertificate = certificate;
367448

368449
const msalApp = await getConfidentialApp(options);
369-
370-
return withSilentAuthentication(msalApp, scopes, options, () =>
371-
msalApp.acquireTokenByClientCredential({
450+
try {
451+
const response = await msalApp.acquireTokenByClientCredential({
372452
scopes,
453+
authority: state.msalConfig.auth.authority,
373454
azureRegion: calculateRegionalAuthority(),
455+
claims: options?.claims,
456+
});
457+
ensureValidMsalToken(scopes, response, options);
458+
459+
msalLogger.getToken.info(formatSuccess(scopes));
460+
461+
return {
462+
token: response.accessToken,
463+
expiresOnTimestamp: response.expiresOn.getTime(),
464+
};
465+
} catch (err: any) {
466+
throw handleMsalError(scopes, err, options);
467+
}
468+
}
469+
470+
async function getTokenByDeviceCode(
471+
scopes: string[],
472+
deviceCodeCallback: DeviceCodePromptCallback,
473+
options: GetTokenWithSilentAuthOptions = {},
474+
): Promise<AccessToken> {
475+
msalLogger.getToken.info(`Attempting to acquire token using device code`);
476+
477+
const msalApp = await getPublicApp(options);
478+
479+
return withSilentAuthentication(msalApp, scopes, options, () => {
480+
const requestOptions: msal.DeviceCodeRequest = {
481+
scopes,
482+
cancel: options?.abortSignal?.aborted ?? false,
483+
deviceCodeCallback,
374484
authority: state.msalConfig.auth.authority,
375485
claims: options?.claims,
376-
}),
377-
);
486+
};
487+
const deviceCodeRequest = msalApp.acquireTokenByDeviceCode(requestOptions);
488+
if (options.abortSignal) {
489+
options.abortSignal.addEventListener("abort", () => {
490+
requestOptions.cancel = true;
491+
});
492+
}
493+
494+
return deviceCodeRequest;
495+
});
496+
}
497+
498+
function getActiveAccount(): AuthenticationRecord | undefined {
499+
if (!state.cachedAccount) {
500+
return undefined;
501+
}
502+
return msalToPublic(clientId, state.cachedAccount);
378503
}
379504

380505
return {
506+
getActiveAccount,
381507
getTokenByClientSecret,
382508
getTokenByClientAssertion,
383509
getTokenByClientCertificate,
510+
getTokenByDeviceCode,
384511
};
385512
}

0 commit comments

Comments
 (0)