Skip to content

Commit a5027ce

Browse files
author
Doğan Erişen
authored
Merge pull request #5578 from AzureAD/custom-loopback-client
Allow adding custom loopback client in acquireTokenInteractive
2 parents c06c817 + 012ec3b commit a5027ce

10 files changed

Lines changed: 274 additions & 25 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Allow adding custom loopback client in acquireTokenInteractive #5578",
4+
"packageName": "@azure/msal-node",
5+
"email": "v-derisen@microsoft.com",
6+
"dependentChangeType": "minor"
7+
}

lib/msal-node/src/client/PublicClientApplication.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { AuthorizationCodeRequest } from "../request/AuthorizationCodeRequest";
2424
import { InteractiveRequest } from "../request/InteractiveRequest";
2525
import { NodeAuthError } from "../error/NodeAuthError";
2626
import { LoopbackClient } from "../network/LoopbackClient";
27+
import { ILoopbackClient } from "../network/ILoopbackClient";
2728

2829
/**
2930
* This class is to be used to acquire tokens for public client applications (desktop, mobile). Public client applications
@@ -86,13 +87,14 @@ export class PublicClientApplication extends ClientApplication implements IPubli
8687
}
8788

8889
/**
89-
* Acquires a token by requesting an Authorization code then exchanging it for a token.
90+
* Acquires a token interactively via the browser by requesting an authorization code then exchanging it for a token.
9091
*/
91-
async acquireTokenInteractive(request: InteractiveRequest): Promise<AuthenticationResult> {
92+
public async acquireTokenInteractive(request: InteractiveRequest): Promise<AuthenticationResult> {
9293
const { verifier, challenge } = await this.cryptoProvider.generatePkceCodes();
93-
const { openBrowser, successTemplate, errorTemplate, ...remainingProperties } = request;
94+
const { openBrowser, successTemplate, errorTemplate, loopbackClient: customLoopbackClient, ...remainingProperties } = request;
95+
96+
const loopbackClient: ILoopbackClient = customLoopbackClient || new LoopbackClient();
9497

95-
const loopbackClient = new LoopbackClient();
9698
const authCodeListener = loopbackClient.listenForAuthCode(successTemplate, errorTemplate);
9799
const redirectUri = loopbackClient.getRedirectUri();
98100

@@ -101,7 +103,7 @@ export class PublicClientApplication extends ClientApplication implements IPubli
101103
scopes: request.scopes || OIDC_DEFAULT_SCOPES,
102104
redirectUri: redirectUri,
103105
responseMode: ResponseMode.QUERY,
104-
codeChallenge: challenge,
106+
codeChallenge: challenge,
105107
codeChallengeMethod: CodeChallengeMethodValues.S256
106108
};
107109

lib/msal-node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { IConfidentialClientApplication } from "./client/IConfidentialClientAppl
1414
export { ITokenCache } from "./cache/ITokenCache";
1515
export { ICacheClient } from "./cache/distributed/ICacheClient";
1616
export { IPartitionManager } from "./cache/distributed/IPartitionManager";
17+
export { ILoopbackClient } from "./network/ILoopbackClient";
1718

1819
// Clients and Configuration
1920
export { PublicClientApplication } from "./client/PublicClientApplication";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import { ServerAuthorizationCodeResponse } from "@azure/msal-common";
7+
8+
/**
9+
* Interface for LoopbackClient allowing to replace the default loopback server with a custom implementation.
10+
* @public
11+
*/
12+
export interface ILoopbackClient {
13+
listenForAuthCode(successTemplate?: string, errorTemplate?: string): Promise<ServerAuthorizationCodeResponse>,
14+
getRedirectUri(): string,
15+
closeServer(): void
16+
}

lib/msal-node/src/network/LoopbackClient.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import { Constants as CommonConstants, ServerAuthorizationCodeResponse, UrlStrin
77
import { createServer, IncomingMessage, Server, ServerResponse } from "http";
88
import { NodeAuthError } from "../error/NodeAuthError";
99
import { Constants, HttpStatus, LOOPBACK_SERVER_CONSTANTS } from "../utils/Constants";
10+
import { ILoopbackClient } from "./ILoopbackClient";
1011

11-
export class LoopbackClient {
12+
export class LoopbackClient implements ILoopbackClient {
1213
private server: Server;
1314

1415
/**
1516
* Spins up a loopback server which returns the server response when the localhost redirectUri is hit
16-
* @param successTemplate
17-
* @param errorTemplate
18-
* @returns
17+
* @param successTemplate
18+
* @param errorTemplate
19+
* @returns
1920
*/
2021
async listenForAuthCode(successTemplate?: string, errorTemplate?: string): Promise<ServerAuthorizationCodeResponse> {
2122
if (!!this.server) {
@@ -33,7 +34,7 @@ export class LoopbackClient {
3334
res.end(successTemplate || "Auth code was successfully acquired. You can close this window now.");
3435
return;
3536
}
36-
37+
3738
const authCodeResponse = UrlString.getDeserializedQueryString(url);
3839
if (authCodeResponse.code) {
3940
const redirectUri = await this.getRedirectUri();
@@ -52,7 +53,7 @@ export class LoopbackClient {
5253
if ((LOOPBACK_SERVER_CONSTANTS.TIMEOUT_MS / LOOPBACK_SERVER_CONSTANTS.INTERVAL_MS) < ticks) {
5354
throw NodeAuthError.createLoopbackServerTimeoutError();
5455
}
55-
56+
5657
if (this.server.listening) {
5758
clearInterval(id);
5859
resolve();
@@ -66,18 +67,18 @@ export class LoopbackClient {
6667

6768
/**
6869
* Get the port that the loopback server is running on
69-
* @returns
70+
* @returns
7071
*/
7172
getRedirectUri(): string {
7273
if (!this.server) {
7374
throw NodeAuthError.createNoLoopbackServerExistsError();
7475
}
75-
76+
7677
const address = this.server.address();
7778
if (!address || typeof address === "string" || !address.port) {
7879
this.closeServer();
7980
throw NodeAuthError.createInvalidLoopbackAddressTypeError();
80-
}
81+
}
8182

8283
const port = address && address.port;
8384

lib/msal-node/src/request/InteractiveRequest.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@
33
* Licensed under the MIT License.
44
*/
55

6+
import { ILoopbackClient } from "../network/ILoopbackClient";
67
import { AuthorizationUrlRequest } from "./AuthorizationUrlRequest";
78

9+
/**
10+
* Request object passed by user to configure acquireTokenInteractive API
11+
*
12+
* - openBrowser - Function to open a browser instance on user's system.
13+
* - scopes - Array of scopes the application is requesting access to.
14+
* - successTemplate: - Template to be displayed on the opened browser instance upon successful token acquisition.
15+
* - errorTemplate - Template to be displayed on the opened browser instance upon token acquisition failure.
16+
* - loopbackClient - Custom implementation for a loopback server to listen for authorization code response.
17+
* @public
18+
*/
819
export type InteractiveRequest = Pick<AuthorizationUrlRequest, "authority"|"correlationId"|"claims"|"azureCloudOptions"|"account"|"extraQueryParameters"|"tokenQueryParameters"|"extraScopesToConsent"|"loginHint"|"prompt"> & {
920
openBrowser: (url: string) => Promise<void>;
1021
scopes?: Array<string>;
1122
successTemplate?: string;
1223
errorTemplate?: string;
24+
loopbackClient?: ILoopbackClient
1325
};

lib/msal-node/test/client/PublicClientApplication.spec.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { PublicClientApplication } from './../../src/client/PublicClientApplication';
2-
import { Configuration, InteractiveRequest } from './../../src/index';
3-
import { ID_TOKEN_CLAIMS, mockAuthenticationResult, TEST_CONSTANTS } from '../utils/TestConstants';
2+
import { Configuration, ILoopbackClient, InteractiveRequest } from './../../src/index';
3+
import { ID_TOKEN_CLAIMS, mockAuthenticationResult, TEST_CONSTANTS, TEST_DATA_CLIENT_INFO } from '../utils/TestConstants';
44
import {
55
ClientConfiguration, AuthenticationResult, AuthorizationCodeClient, RefreshTokenClient, UsernamePasswordClient,
6-
SilentFlowClient, ProtocolMode, Logger, LogLevel, ClientAuthError, AccountInfo
6+
SilentFlowClient, ProtocolMode, Logger, LogLevel, ClientAuthError, AccountInfo, ServerAuthorizationCodeResponse
77
} from '@azure/msal-common';
88
import { CryptoProvider } from '../../src/crypto/CryptoProvider';
99
import { DeviceCodeRequest } from '../../src/request/DeviceCodeRequest';
@@ -197,7 +197,7 @@ describe('PublicClientApplication', () => {
197197
);
198198
});
199199

200-
test('acquireTokenSilent', async () => {
200+
test('acquireTokenSilent', async () => {
201201
const account: AccountInfo = {
202202
homeAccountId: "",
203203
environment: "",
@@ -230,7 +230,7 @@ describe('PublicClientApplication', () => {
230230
const authApp = new PublicClientApplication(appConfig);
231231

232232
let redirectUri: string;
233-
233+
234234
const openBrowser = (url: string) => {
235235
expect(url.startsWith("https://login.microsoftonline.com")).toBe(true);
236236
http.get(`${redirectUri}?code=${TEST_CONSTANTS.AUTHORIZATION_CODE}`);
@@ -260,6 +260,62 @@ describe('PublicClientApplication', () => {
260260
expect(response.account).toEqual(mockAuthenticationResult.account);
261261
});
262262

263+
test("acquireTokenInteractive - with custom loopback client", async () => {
264+
const authApp = new PublicClientApplication(appConfig);
265+
266+
const openBrowser = (url: string) => {
267+
expect(url.startsWith("https://login.microsoftonline.com")).toBe(true);
268+
return Promise.resolve();
269+
};
270+
271+
const testServerCodeResponse: ServerAuthorizationCodeResponse = {
272+
code: TEST_CONSTANTS.AUTHORIZATION_CODE,
273+
client_info: TEST_DATA_CLIENT_INFO.TEST_DECODED_CLIENT_INFO,
274+
state: "123"
275+
};
276+
277+
const mockListenForAuthCode = jest.fn(() => {
278+
return new Promise<ServerAuthorizationCodeResponse>((resolve) => {
279+
resolve(testServerCodeResponse);
280+
});
281+
});
282+
const mockGetRedirectUri = jest.fn(() => TEST_CONSTANTS.REDIRECT_URI);
283+
const mockCloseServer = jest.fn(() => {});
284+
285+
const customLoopbackClient: ILoopbackClient = {
286+
listenForAuthCode: mockListenForAuthCode,
287+
getRedirectUri: mockGetRedirectUri,
288+
closeServer: mockCloseServer
289+
};
290+
291+
const request: InteractiveRequest = {
292+
scopes: TEST_CONSTANTS.DEFAULT_GRAPH_SCOPE,
293+
openBrowser: openBrowser,
294+
loopbackClient: customLoopbackClient,
295+
};
296+
297+
const MockAuthorizationCodeClient = getMsalCommonAutoMock().AuthorizationCodeClient;
298+
jest.spyOn(msalCommon, 'AuthorizationCodeClient').mockImplementation((config) => new MockAuthorizationCodeClient(config));
299+
300+
jest.spyOn(MockAuthorizationCodeClient.prototype, "getAuthCodeUrl").mockImplementation((req) => {
301+
expect(req.redirectUri).toEqual(TEST_CONSTANTS.REDIRECT_URI);
302+
return Promise.resolve(TEST_CONSTANTS.AUTH_CODE_URL);
303+
});
304+
305+
jest.spyOn(MockAuthorizationCodeClient.prototype, "acquireToken").mockImplementation((tokenRequest) => {
306+
expect(tokenRequest.scopes).toEqual([...TEST_CONSTANTS.DEFAULT_GRAPH_SCOPE, ...TEST_CONSTANTS.DEFAULT_OIDC_SCOPES]);
307+
return Promise.resolve(mockAuthenticationResult);
308+
});
309+
310+
const response = await authApp.acquireTokenInteractive(request);
311+
expect(response.idToken).toEqual(mockAuthenticationResult.idToken);
312+
expect(response.accessToken).toEqual(mockAuthenticationResult.accessToken);
313+
expect(response.account).toEqual(mockAuthenticationResult.account);
314+
expect(mockListenForAuthCode).toHaveBeenCalledTimes(1);
315+
expect(mockGetRedirectUri).toHaveBeenCalledTimes(1);
316+
expect(mockCloseServer).toHaveBeenCalledTimes(1);
317+
});
318+
263319
test('initializeBaseRequest passes a claims hash to acquireToken', async () => {
264320
const account: AccountInfo = {
265321
homeAccountId: "",
@@ -345,7 +401,7 @@ describe('PublicClientApplication', () => {
345401
const authorityMock = setupAuthorityFactory_createDiscoveredInstance_mock(fakeAuthority);
346402

347403
const authApp = new PublicClientApplication(config);
348-
await authApp.acquireTokenByRefreshToken(request);
404+
await authApp.acquireTokenByRefreshToken(request);
349405
expect(authorityMock.mock.calls[0][0]).toBe(TEST_CONSTANTS.DEFAULT_AUTHORITY);
350406
expect(authorityMock.mock.calls[0][1]).toBeInstanceOf(HttpClient);
351407
expect(authorityMock.mock.calls[0][2]).toBeInstanceOf(NodeStorage);
@@ -490,7 +546,7 @@ describe('PublicClientApplication', () => {
490546

491547
expect(authApp.getLogger()).toBeDefined();
492548
expect(authApp.getLogger().info("Test logger")).toEqual(undefined);
493-
549+
494550
});
495551

496552
test("should throw an error if state is not provided", async () => {
@@ -530,7 +586,7 @@ describe('PublicClientApplication', () => {
530586

531587
try {
532588
await authApp.acquireTokenByCode(request, authCodePayLoad);
533-
} catch (e) {
589+
} catch (e) {
534590
expect(mockInfo).toBeCalledWith("acquireTokenByCode called");
535591
expect(mockInfo).toHaveBeenCalledWith(
536592
"acquireTokenByCode - validating state"
@@ -579,4 +635,4 @@ describe('PublicClientApplication', () => {
579635
.rejects.toMatchObject(ClientAuthError.createStateMismatchError());
580636
});
581637

582-
});
638+
});

samples/msal-node-samples/ElectronSystemBrowserTestApp/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"start": "electron-forge start",
1010
"package": "electron-forge package",
1111
"build:package": "cd ../../../lib/msal-common && npm run build && cd ../msal-node && npm run build",
12+
"install:local": "npm install ../../../lib/msal-common && npm install ../../../lib/msal-node",
1213
"make": "electron-forge make",
1314
"publish": "electron-forge publish",
1415
"lint": "eslint --ext .ts,.tsx ."
@@ -47,7 +48,8 @@
4748
"typescript": "~4.5.4"
4849
},
4950
"dependencies": {
50-
"@azure/msal-node": "^1.14.2",
51+
"@azure/msal-common": "file:../../../lib/msal-common",
52+
"@azure/msal-node": "file:../../../lib/msal-node",
5153
"axios": "^1.1.3",
5254
"bootstrap": "^5.2.2",
5355
"electron-squirrel-startup": "^1.0.0"

samples/msal-node-samples/ElectronSystemBrowserTestApp/src/AuthProvider.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
InteractiveRequest,
88
SilentFlowRequest,
99
} from "@azure/msal-node";
10+
import { CustomLoopbackClient } from "./CustomLoopbackClient";
1011
import { cachePlugin } from "./CachePlugin";
1112
import * as fs from "fs";
1213

@@ -48,7 +49,7 @@ export default class AuthProvider {
4849
try {
4950
if (!this.account) {
5051
return;
51-
}
52+
}
5253
await this.clientApplication
5354
.getTokenCache()
5455
.removeAccount(this.account);
@@ -111,6 +112,13 @@ export default class AuthProvider {
111112
tokenRequest: SilentFlowRequest
112113
): Promise<AuthenticationResult> {
113114
try {
115+
/**
116+
* A loopback server of your own implementation, which can have custom logic
117+
* such as attempting to listen on a given port if it is available.
118+
*/
119+
const customLoopbackClient = await CustomLoopbackClient.initialize(3874);
120+
121+
// opens a browser instance via Electron shell API
114122
const openBrowser = async (url: any) => {
115123
await shell.openExternal(url);
116124
};
@@ -124,6 +132,7 @@ export default class AuthProvider {
124132
errorTemplate: fs
125133
.readFileSync("./public/errorTemplate.html", "utf8")
126134
.toString(),
135+
loopbackClient: customLoopbackClient // overrides default loopback client
127136
};
128137

129138
const authResponse =

0 commit comments

Comments
 (0)