Skip to content

Commit fa4fd3a

Browse files
authored
[identity] Migrate UsernamePasswordCredential to MSALClient (Azure#29656)
### Packages impacted by this PR @azure/identity ### Issues associated with this PR Resolves Azure#29409 ### Describe the problem that is addressed by this PR Migrates UsernamePasswordCredential to the MSALClient flow, simplifying the implementation.
1 parent 3158783 commit fa4fd3a

File tree

9 files changed

+198
-93
lines changed

9 files changed

+198
-93
lines changed

sdk/identity/identity/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
### Other Changes
1414

1515
- `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)
16+
- `UsernamePasswordCredential` migrated to use MSALClient internally instead of MSALNode flow. This is an internal refactoring and should not result in any behavioral changes. [#29656](https://github.com/Azure/azure-sdk-for-js/pull/29656)
17+
1618

1719
## 4.2.0 (2024-04-30)
1820

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_72abb85c88"
5+
"Tag": "js/identity/identity_a7eb8b7286"
66
}

sdk/identity/identity/src/credentials/usernamePasswordCredential.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@ import {
66
processMultiTenantRequest,
77
resolveAdditionallyAllowedTenantIds,
88
} from "../util/tenantIdUtils";
9-
import { MsalFlow } from "../msal/flows";
10-
import { MsalUsernamePassword } from "../msal/nodeFlows/msalUsernamePassword";
119
import { UsernamePasswordCredentialOptions } from "./usernamePasswordCredentialOptions";
1210
import { credentialLogger } from "../util/logging";
1311
import { ensureScopes } from "../util/scopeUtils";
1412
import { tracingClient } from "../util/tracing";
13+
import { MsalClient, createMsalClient } from "../msal/nodeFlows/msalClient";
1514

1615
const logger = credentialLogger("UsernamePasswordCredential");
1716

@@ -24,7 +23,9 @@ const logger = credentialLogger("UsernamePasswordCredential");
2423
export class UsernamePasswordCredential implements TokenCredential {
2524
private tenantId: string;
2625
private additionallyAllowedTenantIds: string[];
27-
private msalFlow: MsalFlow;
26+
private msalClient: MsalClient;
27+
private username: string;
28+
private password: string;
2829

2930
/**
3031
* Creates an instance of the UsernamePasswordCredential with the details
@@ -55,14 +56,12 @@ export class UsernamePasswordCredential implements TokenCredential {
5556
options?.additionallyAllowedTenants,
5657
);
5758

58-
this.msalFlow = new MsalUsernamePassword({
59+
this.username = username;
60+
this.password = password;
61+
62+
this.msalClient = createMsalClient(clientId, this.tenantId, {
5963
...options,
60-
logger,
61-
clientId,
62-
tenantId,
63-
username,
64-
password,
65-
tokenCredentialOptions: options || {},
64+
tokenCredentialOptions: options ?? {},
6665
});
6766
}
6867

@@ -91,7 +90,12 @@ export class UsernamePasswordCredential implements TokenCredential {
9190
);
9291

9392
const arrayScopes = ensureScopes(scopes);
94-
return this.msalFlow.getToken(arrayScopes, newOptions);
93+
return this.msalClient.getTokenByUsernamePassword(
94+
arrayScopes,
95+
this.username,
96+
this.password,
97+
newOptions,
98+
);
9599
},
96100
);
97101
}

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

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,58 @@ export interface GetTokenWithSilentAuthOptions extends GetTokenOptions {
4646
* Represents a client for interacting with the Microsoft Authentication Library (MSAL).
4747
*/
4848
export interface MsalClient {
49+
/**
50+
* Retrieves an access token by using a user's username and password.
51+
*
52+
* @param scopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
53+
* @param username - The username provided by the developer.
54+
* @param password - The user's password provided by the developer.
55+
* @param options - Additional options that may be provided to the method.
56+
* @returns An access token.
57+
*/
58+
getTokenByUsernamePassword(
59+
scopes: string[],
60+
username: string,
61+
password: string,
62+
options?: GetTokenOptions,
63+
): Promise<AccessToken>;
64+
/**
65+
* Retrieves an access token by prompting the user to authenticate using a device code.
66+
*
67+
* @param scopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
68+
* @param userPromptCallback - The callback function that allows developers to customize the prompt message.
69+
* @param options - Additional options that may be provided to the method.
70+
* @returns An access token.
71+
*/
4972
getTokenByDeviceCode(
50-
arrayScopes: string[],
73+
scopes: string[],
5174
userPromptCallback: DeviceCodePromptCallback,
5275
options?: GetTokenWithSilentAuthOptions,
5376
): Promise<AccessToken>;
5477
/**
5578
* Retrieves an access token by using a client certificate.
5679
*
57-
* @param arrayScopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
80+
* @param scopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
5881
* @param certificate - The client certificate used for authentication.
5982
* @param options - Additional options that may be provided to the method.
6083
* @returns An access token.
6184
*/
6285
getTokenByClientCertificate(
63-
arrayScopes: string[],
86+
scopes: string[],
6487
certificate: CertificateParts,
6588
options?: GetTokenOptions,
6689
): Promise<AccessToken>;
6790

6891
/**
6992
* Retrieves an access token by using a client assertion.
7093
*
71-
* @param arrayScopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
94+
* @param scopes - The scopes for which the access token is requested. These represent the resources that the application wants to access.
7295
* @param clientAssertion - The client assertion used for authentication.
7396
* @param options - Additional options that may be provided to the method.
7497
* @returns An access token.
7598
*/
7699
getTokenByClientAssertion(
77-
arrayScopes: string[],
100+
scopes: string[],
78101
clientAssertion: string,
79102
options?: GetTokenOptions,
80103
): Promise<AccessToken>;
@@ -495,6 +518,29 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
495518
});
496519
}
497520

521+
async function getTokenByUsernamePassword(
522+
scopes: string[],
523+
username: string,
524+
password: string,
525+
options: GetTokenOptions = {},
526+
): Promise<AccessToken> {
527+
msalLogger.getToken.info(`Attempting to acquire token using username and password`);
528+
529+
const msalApp = await getPublicApp(options);
530+
531+
return withSilentAuthentication(msalApp, scopes, options, () => {
532+
const requestOptions: msal.UsernamePasswordRequest = {
533+
scopes,
534+
username,
535+
password,
536+
authority: state.msalConfig.auth.authority,
537+
claims: options?.claims,
538+
};
539+
540+
return msalApp.acquireTokenByUsernamePassword(requestOptions);
541+
});
542+
}
543+
498544
function getActiveAccount(): AuthenticationRecord | undefined {
499545
if (!state.cachedAccount) {
500546
return undefined;
@@ -508,5 +554,6 @@ To work with multiple accounts for the same Client ID and Tenant ID, please prov
508554
getTokenByClientAssertion,
509555
getTokenByClientCertificate,
510556
getTokenByDeviceCode,
557+
getTokenByUsernamePassword,
511558
};
512559
}

sdk/identity/identity/test/internal/node/msalClient.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { msalPlugins } from "../../../src/msal/nodeFlows/msalPlugins";
2121
import sinon from "sinon";
2222
import { DeveloperSignOnClientId } from "../../../src/constants";
2323
import { Context } from "mocha";
24+
import { getUsernamePasswordStaticResources } from "../../msalTestUtils";
2425

2526
describe("MsalClient", function () {
2627
describe("recorded tests", function () {
@@ -73,6 +74,20 @@ describe("MsalClient", function () {
7374
assert.isNotEmpty(accessToken.token);
7475
assert.isNotNaN(accessToken.expiresOnTimestamp);
7576
});
77+
78+
it("supports getTokenByUsernamePassword", async function () {
79+
const scopes = ["https://vault.azure.net/.default"];
80+
const { username, password, clientId, tenantId } = getUsernamePasswordStaticResources();
81+
82+
const clientOptions = recorder.configureClientOptions({});
83+
const client = msalClient.createMsalClient(clientId, tenantId, {
84+
tokenCredentialOptions: { additionalPolicies: clientOptions.additionalPolicies },
85+
});
86+
87+
const accessToken = await client.getTokenByUsernamePassword(scopes, username, password);
88+
assert.isNotEmpty(accessToken.token);
89+
assert.isNotNaN(accessToken.expiresOnTimestamp);
90+
});
7691
});
7792

7893
describe("#createMsalClient", function () {

sdk/identity/identity/test/internal/node/usernamePasswordCredential.spec.ts

Lines changed: 44 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@
55

66
import { AzureLogger, setLogLevel } from "@azure/logger";
77
import { MsalTestCleanup, msalNodeTestSetup } from "../../node/msalNodeTestSetup";
8-
import { Recorder, env, isLiveMode, isPlaybackMode } from "@azure-tools/test-recorder";
8+
import { Recorder, isPlaybackMode } from "@azure-tools/test-recorder";
99
import { Context } from "mocha";
10-
import { DeveloperSignOnClientId } from "../../../src/constants";
11-
import { GetTokenOptions } from "@azure/core-auth";
12-
import { MsalNode } from "../../../src/msal/nodeFlows/msalNodeCommon";
1310
import { PublicClientApplication } from "@azure/msal-node";
1411
import Sinon from "sinon";
1512
import { UsernamePasswordCredential } from "../../../src";
1613
import { assert } from "chai";
14+
import { getUsernamePasswordStaticResources } from "../../msalTestUtils";
1715

1816
describe("UsernamePasswordCredential (internal)", function () {
1917
let cleanup: MsalTestCleanup;
@@ -26,9 +24,10 @@ describe("UsernamePasswordCredential (internal)", function () {
2624
cleanup = setup.cleanup;
2725
recorder = setup.recorder;
2826

29-
getTokenSilentSpy = setup.sandbox.spy(MsalNode.prototype, "getTokenSilent");
27+
// MsalClient calls to this method underneath when silent authentication can be attempted.
28+
getTokenSilentSpy = setup.sandbox.spy(PublicClientApplication.prototype, "acquireTokenSilent");
3029

31-
// MsalClientSecret calls to this method underneath.
30+
// MsalClient calls to this method underneath for interactive auth.
3231
doGetTokenSpy = setup.sandbox.spy(
3332
PublicClientApplication.prototype,
3433
"acquireTokenByUsernamePassword",
@@ -46,38 +45,38 @@ describe("UsernamePasswordCredential (internal)", function () {
4645
try {
4746
new UsernamePasswordCredential(
4847
undefined as any,
49-
env.AZURE_CLIENT_ID!,
50-
env.AZURE_USERNAME!,
51-
env.AZURE_PASSWORD!,
48+
"azure_client_id",
49+
"azure_username",
50+
"azure_password",
5251
);
5352
} catch (e: any) {
5453
errors.push(e);
5554
}
5655
try {
5756
new UsernamePasswordCredential(
58-
env.AZURE_TENANT_ID!,
57+
"azure_tenant_id",
5958
undefined as any,
60-
env.AZURE_USERNAME!,
61-
env.AZURE_PASSWORD!,
59+
"azure_username",
60+
"azure_password",
6261
);
6362
} catch (e: any) {
6463
errors.push(e);
6564
}
6665
try {
6766
new UsernamePasswordCredential(
68-
env.AZURE_TENANT_ID!,
69-
env.AZURE_CLIENT_ID!,
67+
"azure_tenant_id",
68+
"azure_client_id",
7069
undefined as any,
71-
env.AZURE_PASSWORD!,
70+
"azure_password",
7271
);
7372
} catch (e: any) {
7473
errors.push(e);
7574
}
7675
try {
7776
new UsernamePasswordCredential(
78-
env.AZURE_TENANT_ID!,
79-
env.AZURE_CLIENT_ID!,
80-
env.AZURE_USERNAME!,
77+
"azure_tenant_id",
78+
"azure_client_id",
79+
"azure_username",
8180
undefined as any,
8281
);
8382
} catch (e: any) {
@@ -103,65 +102,55 @@ describe("UsernamePasswordCredential (internal)", function () {
103102
});
104103
});
105104

106-
// This is not the way to test persistence with acquireTokenByClientCredential,
107-
// since acquireTokenByClientCredential caches at the method level, and not with the same cache used for acquireTokenSilent.
108-
// I'm leaving this here so I can remember about this in the future.
109-
it.skip("Authenticates silently after the initial request", async function (this: Context) {
110-
// These tests should not run live because this credential requires user interaction.
111-
if (isLiveMode()) {
112-
this.skip();
113-
}
105+
it("Authenticates silently after the initial request", async function (this: Context) {
106+
const { clientId, password, tenantId, username } = getUsernamePasswordStaticResources();
114107
const credential = new UsernamePasswordCredential(
115-
env.AZURE_TENANT_ID!,
116-
env.AZURE_CLIENT_ID!,
117-
env.AZURE_USERNAME!,
118-
env.AZURE_PASSWORD!,
108+
tenantId,
109+
clientId,
110+
username,
111+
password,
112+
recorder.configureClientOptions({}),
119113
);
120114

121115
await credential.getToken(scope);
122-
assert.equal(getTokenSilentSpy.callCount, 1);
123116
assert.equal(doGetTokenSpy.callCount, 1);
124117

125118
await credential.getToken(scope);
126-
assert.equal(getTokenSilentSpy.callCount, 2);
127-
assert.equal(doGetTokenSpy.callCount, 1);
119+
assert.equal(
120+
getTokenSilentSpy.callCount,
121+
1,
122+
"getTokenSilentSpy.callCount should have been 1 (Silent authentication after the initial request).",
123+
);
124+
assert.equal(
125+
doGetTokenSpy.callCount,
126+
1,
127+
"Expected no additional calls to doGetTokenSpy after the initial request.",
128+
);
128129
});
129130

130131
it("Authenticates with tenantId on getToken", async function (this: Context) {
131-
// The live environment isn't ready for this test
132-
if (isLiveMode()) {
133-
this.skip();
134-
}
132+
const { clientId, password, tenantId, username } = getUsernamePasswordStaticResources();
135133
const credential = new UsernamePasswordCredential(
136-
env.AZURE_IDENTITY_TEST_TENANTID || env.AZURE_TENANT_ID!,
137-
env.AZURE_IDENTITY_TEST_CLIENTID || env.AZURE_CLIENT_ID!,
138-
env.AZURE_IDENTITY_TEST_USERNAME || env.AZURE_USERNAME!,
139-
env.AZURE_IDENTITY_TEST_PASSWORD || env.AZURE_PASSWORD!,
134+
tenantId,
135+
clientId,
136+
username,
137+
password,
140138
recorder.configureClientOptions({}),
141139
);
142140

143-
await credential.getToken(scope, { tenantId: env.AZURE_TENANT_ID } as GetTokenOptions);
144-
assert.equal(getTokenSilentSpy.callCount, 1);
141+
await credential.getToken(scope);
145142
assert.equal(doGetTokenSpy.callCount, 1);
146143
});
147144

148145
it("authenticates (with allowLoggingAccountIdentifiers set to true)", async function (this: Context) {
146+
const { clientId, password, tenantId, username } = getUsernamePasswordStaticResources();
149147
if (isPlaybackMode()) {
150148
// The recorder clears the access tokens.
151149
this.skip();
152150
}
153-
const tenantId = env.AZURE_IDENTITY_TEST_TENANTID || env.AZURE_TENANT_ID!;
154-
const clientId = isLiveMode() ? DeveloperSignOnClientId : env.AZURE_CLIENT_ID!;
155-
156-
const credential = new UsernamePasswordCredential(
157-
tenantId,
158-
clientId,
159-
env.AZURE_IDENTITY_TEST_USERNAME || env.AZURE_USERNAME!,
160-
env.AZURE_IDENTITY_TEST_PASSWORD || env.AZURE_PASSWORD!,
161-
recorder.configureClientOptions({
162-
loggingOptions: { allowLoggingAccountIdentifiers: true },
163-
}),
164-
);
151+
const credential = new UsernamePasswordCredential(tenantId, clientId, username, password, {
152+
loggingOptions: { allowLoggingAccountIdentifiers: true },
153+
});
165154
setLogLevel("info");
166155
const spy = Sinon.spy(process.stderr, "write");
167156

0 commit comments

Comments
 (0)