@azure/msal-angular 4.0.12 → 4.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beachballrc +3 -0
- package/.editorconfig +16 -0
- package/.eslintrc.json +3 -0
- package/CHANGELOG.json +2315 -0
- package/CHANGELOG.md +869 -0
- package/FAQ.md +182 -0
- package/angular.json +51 -0
- package/docs/angular-universal.md +64 -0
- package/docs/configuration.md +609 -0
- package/docs/errors.md +89 -0
- package/docs/events.md +262 -0
- package/docs/initialization.md +206 -0
- package/docs/known-issues.md +19 -0
- package/docs/logging.md +47 -0
- package/docs/msal-guard.md +208 -0
- package/docs/msal-interceptor.md +160 -0
- package/docs/multi-tenant.md +90 -0
- package/docs/performance.md +50 -0
- package/docs/public-apis.md +29 -0
- package/docs/redirects.md +257 -0
- package/docs/security.md +4 -0
- package/docs/ssosilent.md +84 -0
- package/docs/v0-v1-upgrade-guide.md +61 -0
- package/docs/v1-v2-upgrade-guide.md +70 -0
- package/docs/v2-v3-upgrade-guide.md +52 -0
- package/docs/v3-v4-upgrade-guide.md +36 -0
- package/karma.conf.js +44 -0
- package/ng-package.json +7 -0
- package/package.json +56 -26
- package/src/IMsalService.ts +34 -0
- package/src/constants.ts +20 -0
- package/src/msal.broadcast.config.ts +8 -0
- package/src/msal.broadcast.service.spec.ts +471 -0
- package/src/msal.broadcast.service.ts +84 -0
- package/src/msal.guard.config.ts +27 -0
- package/src/msal.guard.spec.ts +525 -0
- package/src/msal.guard.ts +290 -0
- package/src/msal.interceptor.config.ts +39 -0
- package/src/msal.interceptor.spec.ts +1168 -0
- package/src/msal.interceptor.ts +388 -0
- package/src/msal.module.ts +51 -0
- package/src/msal.navigation.client.spec.ts +96 -0
- package/src/msal.navigation.client.ts +59 -0
- package/src/msal.redirect.component.spec.ts +64 -0
- package/src/msal.redirect.component.ts +26 -0
- package/src/msal.service.spec.ts +468 -0
- package/src/msal.service.ts +104 -0
- package/src/packageMetadata.ts +3 -0
- package/{public-api.d.ts → src/public-api.ts} +35 -17
- package/src/test.ts +19 -0
- package/tsconfig.json +25 -0
- package/tsconfig.lib.json +27 -0
- package/tsconfig.lib.prod.json +10 -0
- package/tsconfig.spec.json +17 -0
- package/typedoc.json +10 -0
- package/IMsalService.d.ts +0 -17
- package/constants.d.ts +0 -5
- package/esm2020/IMsalService.mjs +0 -6
- package/esm2020/azure-msal-angular.mjs +0 -5
- package/esm2020/constants.mjs +0 -10
- package/esm2020/msal.broadcast.config.mjs +0 -6
- package/esm2020/msal.broadcast.service.mjs +0 -66
- package/esm2020/msal.guard.config.mjs +0 -6
- package/esm2020/msal.guard.mjs +0 -218
- package/esm2020/msal.interceptor.config.mjs +0 -6
- package/esm2020/msal.interceptor.mjs +0 -270
- package/esm2020/msal.module.mjs +0 -46
- package/esm2020/msal.navigation.client.mjs +0 -53
- package/esm2020/msal.redirect.component.mjs +0 -31
- package/esm2020/msal.service.mjs +0 -88
- package/esm2020/packageMetadata.mjs +0 -4
- package/esm2020/public-api.mjs +0 -18
- package/fesm2015/azure-msal-angular.mjs +0 -762
- package/fesm2015/azure-msal-angular.mjs.map +0 -1
- package/fesm2020/azure-msal-angular.mjs +0 -758
- package/fesm2020/azure-msal-angular.mjs.map +0 -1
- package/index.d.ts +0 -5
- package/msal.broadcast.config.d.ts +0 -3
- package/msal.broadcast.service.d.ts +0 -19
- package/msal.guard.config.d.ts +0 -9
- package/msal.guard.d.ts +0 -43
- package/msal.interceptor.config.d.ts +0 -13
- package/msal.interceptor.d.ts +0 -70
- package/msal.module.d.ts +0 -13
- package/msal.navigation.client.d.ts +0 -19
- package/msal.redirect.component.d.ts +0 -15
- package/msal.service.d.ts +0 -33
- package/packageMetadata.d.ts +0 -2
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Injectable, Inject } from "@angular/core";
|
|
7
|
+
import { Location, DOCUMENT } from "@angular/common";
|
|
8
|
+
import {
|
|
9
|
+
HttpRequest,
|
|
10
|
+
HttpHandler,
|
|
11
|
+
HttpEvent,
|
|
12
|
+
HttpInterceptor,
|
|
13
|
+
} from "@angular/common/http"; // eslint-disable-line import/no-unresolved
|
|
14
|
+
import {
|
|
15
|
+
AccountInfo,
|
|
16
|
+
AuthenticationResult,
|
|
17
|
+
BrowserConfigurationAuthError,
|
|
18
|
+
InteractionStatus,
|
|
19
|
+
InteractionType,
|
|
20
|
+
StringUtils,
|
|
21
|
+
} from "@azure/msal-browser";
|
|
22
|
+
import { Observable, EMPTY, of } from "rxjs";
|
|
23
|
+
import { switchMap, catchError, take, filter } from "rxjs/operators";
|
|
24
|
+
import { MsalService } from "./msal.service";
|
|
25
|
+
import {
|
|
26
|
+
MsalInterceptorAuthRequest,
|
|
27
|
+
MsalInterceptorConfiguration,
|
|
28
|
+
ProtectedResourceScopes,
|
|
29
|
+
} from "./msal.interceptor.config";
|
|
30
|
+
import { MsalBroadcastService } from "./msal.broadcast.service";
|
|
31
|
+
import { MSAL_INTERCEPTOR_CONFIG } from "./constants";
|
|
32
|
+
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class MsalInterceptor implements HttpInterceptor {
|
|
35
|
+
private _document?: Document;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
@Inject(MSAL_INTERCEPTOR_CONFIG)
|
|
39
|
+
private msalInterceptorConfig: MsalInterceptorConfiguration,
|
|
40
|
+
private authService: MsalService,
|
|
41
|
+
private location: Location,
|
|
42
|
+
private msalBroadcastService: MsalBroadcastService,
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
|
44
|
+
@Inject(DOCUMENT) document?: any
|
|
45
|
+
) {
|
|
46
|
+
this._document = document as Document;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
intercept(
|
|
50
|
+
req: HttpRequest<any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
51
|
+
next: HttpHandler
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
): Observable<HttpEvent<any>> {
|
|
54
|
+
if (
|
|
55
|
+
this.msalInterceptorConfig.interactionType !== InteractionType.Popup &&
|
|
56
|
+
this.msalInterceptorConfig.interactionType !== InteractionType.Redirect
|
|
57
|
+
) {
|
|
58
|
+
throw new BrowserConfigurationAuthError(
|
|
59
|
+
"invalid_interaction_type",
|
|
60
|
+
"Invalid interaction type provided to MSAL Interceptor. InteractionType.Popup, InteractionType.Redirect must be provided in the msalInterceptorConfiguration"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.authService.getLogger().verbose("MSAL Interceptor activated");
|
|
65
|
+
const scopes = this.getScopesForEndpoint(req.url, req.method);
|
|
66
|
+
|
|
67
|
+
// If no scopes for endpoint, does not acquire token
|
|
68
|
+
if (!scopes || scopes.length === 0) {
|
|
69
|
+
this.authService
|
|
70
|
+
.getLogger()
|
|
71
|
+
.verbose("Interceptor - no scopes for endpoint");
|
|
72
|
+
return next.handle(req);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Sets account as active account or first account
|
|
76
|
+
let account: AccountInfo;
|
|
77
|
+
if (!!this.authService.instance.getActiveAccount()) {
|
|
78
|
+
this.authService
|
|
79
|
+
.getLogger()
|
|
80
|
+
.verbose("Interceptor - active account selected");
|
|
81
|
+
account = this.authService.instance.getActiveAccount();
|
|
82
|
+
} else {
|
|
83
|
+
this.authService
|
|
84
|
+
.getLogger()
|
|
85
|
+
.verbose("Interceptor - no active account, fallback to first account");
|
|
86
|
+
account = this.authService.instance.getAllAccounts()[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const authRequest =
|
|
90
|
+
typeof this.msalInterceptorConfig.authRequest === "function"
|
|
91
|
+
? this.msalInterceptorConfig.authRequest(this.authService, req, {
|
|
92
|
+
account: account,
|
|
93
|
+
})
|
|
94
|
+
: { ...this.msalInterceptorConfig.authRequest, account };
|
|
95
|
+
|
|
96
|
+
this.authService
|
|
97
|
+
.getLogger()
|
|
98
|
+
.info(`Interceptor - ${scopes.length} scopes found for endpoint`);
|
|
99
|
+
this.authService
|
|
100
|
+
.getLogger()
|
|
101
|
+
.infoPii(`Interceptor - [${scopes}] scopes found for ${req.url}`);
|
|
102
|
+
|
|
103
|
+
return this.acquireToken(authRequest, scopes, account).pipe(
|
|
104
|
+
switchMap((result: AuthenticationResult) => {
|
|
105
|
+
this.authService
|
|
106
|
+
.getLogger()
|
|
107
|
+
.verbose("Interceptor - setting authorization headers");
|
|
108
|
+
const headers = req.headers.set(
|
|
109
|
+
"Authorization",
|
|
110
|
+
`Bearer ${result.accessToken}`
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const requestClone = req.clone({ headers });
|
|
114
|
+
return next.handle(requestClone);
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Try to acquire token silently. Invoke interaction if acquireTokenSilent rejected with error or resolved with null access token
|
|
121
|
+
* @param authRequest Request
|
|
122
|
+
* @param scopes Array of scopes for the request
|
|
123
|
+
* @param account Account
|
|
124
|
+
* @returns Authentication result
|
|
125
|
+
*/
|
|
126
|
+
private acquireToken(
|
|
127
|
+
authRequest: MsalInterceptorAuthRequest,
|
|
128
|
+
scopes: string[],
|
|
129
|
+
account: AccountInfo
|
|
130
|
+
): Observable<AuthenticationResult> {
|
|
131
|
+
// Note: For MSA accounts, include openid scope when calling acquireTokenSilent to return idToken
|
|
132
|
+
return this.authService
|
|
133
|
+
.acquireTokenSilent({ ...authRequest, scopes, account })
|
|
134
|
+
.pipe(
|
|
135
|
+
catchError(() => {
|
|
136
|
+
this.authService
|
|
137
|
+
.getLogger()
|
|
138
|
+
.error(
|
|
139
|
+
"Interceptor - acquireTokenSilent rejected with error. Invoking interaction to resolve."
|
|
140
|
+
);
|
|
141
|
+
return this.msalBroadcastService.inProgress$.pipe(
|
|
142
|
+
take(1),
|
|
143
|
+
switchMap((status: InteractionStatus) => {
|
|
144
|
+
if (status === InteractionStatus.None) {
|
|
145
|
+
return this.acquireTokenInteractively(authRequest, scopes);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return this.msalBroadcastService.inProgress$.pipe(
|
|
149
|
+
filter(
|
|
150
|
+
(status: InteractionStatus) =>
|
|
151
|
+
status === InteractionStatus.None
|
|
152
|
+
),
|
|
153
|
+
take(1),
|
|
154
|
+
switchMap(() => this.acquireToken(authRequest, scopes, account))
|
|
155
|
+
);
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
}),
|
|
159
|
+
switchMap((result: AuthenticationResult) => {
|
|
160
|
+
if (!result.accessToken) {
|
|
161
|
+
this.authService
|
|
162
|
+
.getLogger()
|
|
163
|
+
.error(
|
|
164
|
+
"Interceptor - acquireTokenSilent resolved with null access token. Known issue with B2C tenants, invoking interaction to resolve."
|
|
165
|
+
);
|
|
166
|
+
return this.msalBroadcastService.inProgress$.pipe(
|
|
167
|
+
filter(
|
|
168
|
+
(status: InteractionStatus) => status === InteractionStatus.None
|
|
169
|
+
),
|
|
170
|
+
take(1),
|
|
171
|
+
switchMap(() =>
|
|
172
|
+
this.acquireTokenInteractively(authRequest, scopes)
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return of(result);
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Invoke interaction for the given set of scopes
|
|
183
|
+
* @param authRequest Request
|
|
184
|
+
* @param scopes Array of scopes for the request
|
|
185
|
+
* @returns Result from the interactive request
|
|
186
|
+
*/
|
|
187
|
+
private acquireTokenInteractively(
|
|
188
|
+
authRequest: MsalInterceptorAuthRequest,
|
|
189
|
+
scopes: string[]
|
|
190
|
+
): Observable<AuthenticationResult> {
|
|
191
|
+
if (this.msalInterceptorConfig.interactionType === InteractionType.Popup) {
|
|
192
|
+
this.authService
|
|
193
|
+
.getLogger()
|
|
194
|
+
.verbose(
|
|
195
|
+
"Interceptor - error acquiring token silently, acquiring by popup"
|
|
196
|
+
);
|
|
197
|
+
return this.authService.acquireTokenPopup({ ...authRequest, scopes });
|
|
198
|
+
}
|
|
199
|
+
this.authService
|
|
200
|
+
.getLogger()
|
|
201
|
+
.verbose(
|
|
202
|
+
"Interceptor - error acquiring token silently, acquiring by redirect"
|
|
203
|
+
);
|
|
204
|
+
const redirectStartPage = window.location.href;
|
|
205
|
+
this.authService.acquireTokenRedirect({
|
|
206
|
+
...authRequest,
|
|
207
|
+
scopes,
|
|
208
|
+
redirectStartPage,
|
|
209
|
+
});
|
|
210
|
+
return EMPTY;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Looks up the scopes for the given endpoint from the protectedResourceMap
|
|
215
|
+
* @param endpoint Url of the request
|
|
216
|
+
* @param httpMethod Http method of the request
|
|
217
|
+
* @returns Array of scopes, or null if not found
|
|
218
|
+
*
|
|
219
|
+
*/
|
|
220
|
+
private getScopesForEndpoint(
|
|
221
|
+
endpoint: string,
|
|
222
|
+
httpMethod: string
|
|
223
|
+
): Array<string> | null {
|
|
224
|
+
this.authService
|
|
225
|
+
.getLogger()
|
|
226
|
+
.verbose("Interceptor - getting scopes for endpoint");
|
|
227
|
+
|
|
228
|
+
// Ensures endpoints and protected resources compared are normalized
|
|
229
|
+
const normalizedEndpoint = this.location.normalize(endpoint);
|
|
230
|
+
|
|
231
|
+
const protectedResourcesArray = Array.from(
|
|
232
|
+
this.msalInterceptorConfig.protectedResourceMap.keys()
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const matchingProtectedResources = this.matchResourcesToEndpoint(
|
|
236
|
+
protectedResourcesArray,
|
|
237
|
+
normalizedEndpoint
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (matchingProtectedResources.length > 0) {
|
|
241
|
+
return this.matchScopesToEndpoint(
|
|
242
|
+
this.msalInterceptorConfig.protectedResourceMap,
|
|
243
|
+
matchingProtectedResources,
|
|
244
|
+
httpMethod
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Finds resource endpoints that match request endpoint
|
|
253
|
+
* @param protectedResourcesEndpoints
|
|
254
|
+
* @param endpoint
|
|
255
|
+
* @returns
|
|
256
|
+
*/
|
|
257
|
+
private matchResourcesToEndpoint(
|
|
258
|
+
protectedResourcesEndpoints: string[],
|
|
259
|
+
endpoint: string
|
|
260
|
+
): Array<string> {
|
|
261
|
+
const matchingResources: Array<string> = [];
|
|
262
|
+
|
|
263
|
+
protectedResourcesEndpoints.forEach((key) => {
|
|
264
|
+
const normalizedKey = this.location.normalize(key);
|
|
265
|
+
|
|
266
|
+
// Get url components
|
|
267
|
+
const absoluteKey = this.getAbsoluteUrl(normalizedKey);
|
|
268
|
+
const keyComponents = new URL(absoluteKey);
|
|
269
|
+
const absoluteEndpoint = this.getAbsoluteUrl(endpoint);
|
|
270
|
+
const endpointComponents = new URL(absoluteEndpoint);
|
|
271
|
+
|
|
272
|
+
if (this.checkUrlComponents(keyComponents, endpointComponents)) {
|
|
273
|
+
matchingResources.push(key);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return matchingResources;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Compares URL segments between key and endpoint
|
|
282
|
+
* @param key
|
|
283
|
+
* @param endpoint
|
|
284
|
+
* @returns
|
|
285
|
+
*/
|
|
286
|
+
private checkUrlComponents(
|
|
287
|
+
keyComponents: URL,
|
|
288
|
+
endpointComponents: URL
|
|
289
|
+
): boolean {
|
|
290
|
+
// URL properties from https://developer.mozilla.org/en-US/docs/Web/API/URL
|
|
291
|
+
const urlProperties = ["protocol", "host", "pathname", "search", "hash"];
|
|
292
|
+
|
|
293
|
+
for (const property of urlProperties) {
|
|
294
|
+
if (keyComponents[property]) {
|
|
295
|
+
const decodedInput = decodeURIComponent(keyComponents[property]);
|
|
296
|
+
if (
|
|
297
|
+
!StringUtils.matchPattern(decodedInput, endpointComponents[property])
|
|
298
|
+
) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Transforms relative urls to absolute urls
|
|
309
|
+
* @param url
|
|
310
|
+
* @returns
|
|
311
|
+
*/
|
|
312
|
+
private getAbsoluteUrl(url: string): string {
|
|
313
|
+
const link = this._document.createElement("a");
|
|
314
|
+
link.href = url;
|
|
315
|
+
return link.href;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Finds scopes from first matching endpoint with HTTP method that matches request
|
|
320
|
+
* @param protectedResourceMap Protected resource map
|
|
321
|
+
* @param endpointArray Array of resources that match request endpoint
|
|
322
|
+
* @param httpMethod Http method of the request
|
|
323
|
+
* @returns
|
|
324
|
+
*/
|
|
325
|
+
private matchScopesToEndpoint(
|
|
326
|
+
protectedResourceMap: Map<
|
|
327
|
+
string,
|
|
328
|
+
Array<string | ProtectedResourceScopes> | null
|
|
329
|
+
>,
|
|
330
|
+
endpointArray: string[],
|
|
331
|
+
httpMethod: string
|
|
332
|
+
): Array<string> | null {
|
|
333
|
+
const allMatchedScopes = [];
|
|
334
|
+
|
|
335
|
+
// Check each matched endpoint for matching HttpMethod and scopes
|
|
336
|
+
endpointArray.forEach((matchedEndpoint) => {
|
|
337
|
+
const scopesForEndpoint = [];
|
|
338
|
+
const methodAndScopesArray = protectedResourceMap.get(matchedEndpoint);
|
|
339
|
+
|
|
340
|
+
// Return if resource is unprotected
|
|
341
|
+
if (methodAndScopesArray === null) {
|
|
342
|
+
allMatchedScopes.push(null);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
methodAndScopesArray.forEach((entry) => {
|
|
347
|
+
// Entry is either array of scopes or ProtectedResourceScopes object
|
|
348
|
+
if (typeof entry === "string") {
|
|
349
|
+
scopesForEndpoint.push(entry);
|
|
350
|
+
} else {
|
|
351
|
+
// Ensure methods being compared are normalized
|
|
352
|
+
const normalizedRequestMethod = httpMethod.toLowerCase();
|
|
353
|
+
const normalizedResourceMethod = entry.httpMethod.toLowerCase();
|
|
354
|
+
// Method in protectedResourceMap matches request http method
|
|
355
|
+
if (normalizedResourceMethod === normalizedRequestMethod) {
|
|
356
|
+
// Validate if scopes comes null to unprotect the resource in a certain http method
|
|
357
|
+
if (entry.scopes === null) {
|
|
358
|
+
allMatchedScopes.push(null);
|
|
359
|
+
} else {
|
|
360
|
+
entry.scopes.forEach((scope) => {
|
|
361
|
+
scopesForEndpoint.push(scope);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Only add to all scopes if scopes for endpoint and method is found
|
|
369
|
+
if (scopesForEndpoint.length > 0) {
|
|
370
|
+
allMatchedScopes.push(scopesForEndpoint);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (allMatchedScopes.length > 0) {
|
|
375
|
+
if (allMatchedScopes.length > 1) {
|
|
376
|
+
this.authService
|
|
377
|
+
.getLogger()
|
|
378
|
+
.warning(
|
|
379
|
+
"Interceptor - More than 1 matching scopes for endpoint found."
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
// Returns scopes for first matching endpoint
|
|
383
|
+
return allMatchedScopes[0];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ModuleWithProviders, NgModule } from "@angular/core";
|
|
7
|
+
import { CommonModule } from "@angular/common";
|
|
8
|
+
import { IPublicClientApplication } from "@azure/msal-browser";
|
|
9
|
+
import { MsalBroadcastService } from "./msal.broadcast.service";
|
|
10
|
+
import { MsalGuard } from "./msal.guard";
|
|
11
|
+
import { MsalGuardConfiguration } from "./msal.guard.config";
|
|
12
|
+
import { MsalInterceptorConfiguration } from "./msal.interceptor.config";
|
|
13
|
+
import { MsalRedirectComponent } from "./msal.redirect.component";
|
|
14
|
+
import { MsalService } from "./msal.service";
|
|
15
|
+
import {
|
|
16
|
+
MSAL_INSTANCE,
|
|
17
|
+
MSAL_GUARD_CONFIG,
|
|
18
|
+
MSAL_INTERCEPTOR_CONFIG,
|
|
19
|
+
} from "./constants";
|
|
20
|
+
|
|
21
|
+
@NgModule({
|
|
22
|
+
declarations: [MsalRedirectComponent],
|
|
23
|
+
imports: [CommonModule],
|
|
24
|
+
providers: [MsalGuard, MsalBroadcastService],
|
|
25
|
+
})
|
|
26
|
+
export class MsalModule {
|
|
27
|
+
static forRoot(
|
|
28
|
+
msalInstance: IPublicClientApplication,
|
|
29
|
+
guardConfig: MsalGuardConfiguration,
|
|
30
|
+
interceptorConfig: MsalInterceptorConfiguration
|
|
31
|
+
): ModuleWithProviders<MsalModule> {
|
|
32
|
+
return {
|
|
33
|
+
ngModule: MsalModule,
|
|
34
|
+
providers: [
|
|
35
|
+
{
|
|
36
|
+
provide: MSAL_INSTANCE,
|
|
37
|
+
useValue: msalInstance,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
provide: MSAL_GUARD_CONFIG,
|
|
41
|
+
useValue: guardConfig,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
provide: MSAL_INTERCEPTOR_CONFIG,
|
|
45
|
+
useValue: interceptorConfig,
|
|
46
|
+
},
|
|
47
|
+
MsalService,
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TestBed } from "@angular/core/testing";
|
|
2
|
+
import { Location } from "@angular/common";
|
|
3
|
+
import { Router } from "@angular/router";
|
|
4
|
+
import {
|
|
5
|
+
InteractionType,
|
|
6
|
+
NavigationClient,
|
|
7
|
+
NavigationOptions,
|
|
8
|
+
PublicClientApplication,
|
|
9
|
+
} from "@azure/msal-browser";
|
|
10
|
+
import { MsalBroadcastService } from "./msal.broadcast.service";
|
|
11
|
+
import { MsalGuard } from "./msal.guard";
|
|
12
|
+
import { MsalCustomNavigationClient } from "./msal.navigation.client";
|
|
13
|
+
import { MsalModule, MsalService } from "./public-api";
|
|
14
|
+
|
|
15
|
+
let authService: MsalService;
|
|
16
|
+
let navigationClient: MsalCustomNavigationClient;
|
|
17
|
+
let routerMock = {
|
|
18
|
+
navigateByUrl: jasmine.createSpy("navigateByUrl").and.resolveTo(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const msalInstance = new PublicClientApplication({
|
|
22
|
+
auth: {
|
|
23
|
+
clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144",
|
|
24
|
+
redirectUri: "http://localhost:4200",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("MsalCustomNaviationClient", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
TestBed.resetTestingModule();
|
|
31
|
+
|
|
32
|
+
TestBed.configureTestingModule({
|
|
33
|
+
imports: [
|
|
34
|
+
MsalModule.forRoot(msalInstance, null, {
|
|
35
|
+
interactionType: InteractionType.Popup,
|
|
36
|
+
protectedResourceMap: new Map(),
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
providers: [
|
|
40
|
+
MsalBroadcastService,
|
|
41
|
+
{ provide: Router, useValue: routerMock },
|
|
42
|
+
Location,
|
|
43
|
+
MsalCustomNavigationClient,
|
|
44
|
+
MsalService,
|
|
45
|
+
MsalGuard,
|
|
46
|
+
],
|
|
47
|
+
teardown: { destroyAfterEach: false },
|
|
48
|
+
});
|
|
49
|
+
authService = TestBed.inject(MsalService);
|
|
50
|
+
navigationClient = TestBed.inject(MsalCustomNavigationClient);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("NavigateInternal Unit tests", () => {
|
|
54
|
+
it("is created", () => {
|
|
55
|
+
expect(navigationClient).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("NavigateInternal (noHistory false)", (done) => {
|
|
59
|
+
const url = "http://localhost:4200/profile";
|
|
60
|
+
const normalizedAbsoluteUrl = "/profile";
|
|
61
|
+
|
|
62
|
+
const options = {
|
|
63
|
+
noHistory: false,
|
|
64
|
+
} as NavigationOptions;
|
|
65
|
+
|
|
66
|
+
const navigateByUrlOptions = {
|
|
67
|
+
replaceUrl: false,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
navigationClient.navigateInternal(url, options).then(() => {
|
|
71
|
+
expect(routerMock.navigateByUrl).toHaveBeenCalledWith(
|
|
72
|
+
normalizedAbsoluteUrl,
|
|
73
|
+
navigateByUrlOptions
|
|
74
|
+
);
|
|
75
|
+
done();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("NavigateInternal (noHistory true)", (done) => {
|
|
80
|
+
const url = "http://localhost:4200/profile";
|
|
81
|
+
|
|
82
|
+
const options = {
|
|
83
|
+
noHistory: true,
|
|
84
|
+
} as NavigationOptions;
|
|
85
|
+
|
|
86
|
+
const windowLocationReplaceSpy = spyOn(
|
|
87
|
+
NavigationClient.prototype,
|
|
88
|
+
"navigateInternal"
|
|
89
|
+
);
|
|
90
|
+
navigationClient.navigateInternal(url, options).then(() => {
|
|
91
|
+
expect(windowLocationReplaceSpy).toHaveBeenCalledWith(url, options);
|
|
92
|
+
done();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Injectable } from "@angular/core";
|
|
7
|
+
import { Location } from "@angular/common";
|
|
8
|
+
import { Router } from "@angular/router";
|
|
9
|
+
import {
|
|
10
|
+
NavigationClient,
|
|
11
|
+
NavigationOptions,
|
|
12
|
+
UrlString,
|
|
13
|
+
} from "@azure/msal-browser";
|
|
14
|
+
import { MsalService } from "./msal.service";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom navigation used for Angular client-side navigation.
|
|
18
|
+
* See performance doc for details:
|
|
19
|
+
* https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular/docs/performance.md
|
|
20
|
+
*/
|
|
21
|
+
@Injectable()
|
|
22
|
+
export class MsalCustomNavigationClient extends NavigationClient {
|
|
23
|
+
constructor(
|
|
24
|
+
private authService: MsalService,
|
|
25
|
+
private router: Router,
|
|
26
|
+
private location: Location
|
|
27
|
+
) {
|
|
28
|
+
super();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async navigateInternal(
|
|
32
|
+
url: string,
|
|
33
|
+
options: NavigationOptions
|
|
34
|
+
): Promise<boolean> {
|
|
35
|
+
this.authService.getLogger().trace("MsalCustomNavigationClient called");
|
|
36
|
+
|
|
37
|
+
this.authService
|
|
38
|
+
.getLogger()
|
|
39
|
+
.verbose("MsalCustomNavigationClient - navigating");
|
|
40
|
+
this.authService
|
|
41
|
+
.getLogger()
|
|
42
|
+
.verbosePii(`MsalCustomNavigationClient - navigating to url: ${url}`);
|
|
43
|
+
|
|
44
|
+
// Prevent hash clearing from causing an issue with Client-side navigation after redirect is handled
|
|
45
|
+
if (options.noHistory) {
|
|
46
|
+
return super.navigateInternal(url, options);
|
|
47
|
+
} else {
|
|
48
|
+
// Normalizing newUrl if no query string
|
|
49
|
+
const urlComponents = new UrlString(url).getUrlComponents();
|
|
50
|
+
const newUrl = urlComponents.QueryString
|
|
51
|
+
? `${urlComponents.AbsolutePath}?${urlComponents.QueryString}`
|
|
52
|
+
: this.location.normalize(urlComponents.AbsolutePath);
|
|
53
|
+
await this.router.navigateByUrl(newUrl, {
|
|
54
|
+
replaceUrl: options.noHistory,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return Promise.resolve(options.noHistory);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { TestBed } from "@angular/core/testing";
|
|
2
|
+
import {
|
|
3
|
+
IPublicClientApplication,
|
|
4
|
+
PublicClientApplication,
|
|
5
|
+
} from "@azure/msal-browser";
|
|
6
|
+
import { MsalBroadcastService } from "./msal.broadcast.service";
|
|
7
|
+
import { MsalModule } from "./msal.module";
|
|
8
|
+
import { MsalRedirectComponent } from "./msal.redirect.component";
|
|
9
|
+
import { MsalService } from "./msal.service";
|
|
10
|
+
|
|
11
|
+
let authService: MsalService;
|
|
12
|
+
let broadcastService: MsalBroadcastService;
|
|
13
|
+
|
|
14
|
+
function MSALInstanceFactory(): IPublicClientApplication {
|
|
15
|
+
return new PublicClientApplication({
|
|
16
|
+
auth: {
|
|
17
|
+
clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144",
|
|
18
|
+
redirectUri: "http://localhost:4200",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function initializeMsal() {
|
|
24
|
+
TestBed.resetTestingModule();
|
|
25
|
+
|
|
26
|
+
TestBed.configureTestingModule({
|
|
27
|
+
declarations: [MsalRedirectComponent],
|
|
28
|
+
imports: [MsalModule.forRoot(MSALInstanceFactory(), null, null)],
|
|
29
|
+
providers: [],
|
|
30
|
+
teardown: { destroyAfterEach: false },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
authService = TestBed.inject(MsalService);
|
|
34
|
+
broadcastService = TestBed.inject(MsalBroadcastService);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("MsalRedirectComponent", () => {
|
|
38
|
+
beforeEach(initializeMsal);
|
|
39
|
+
|
|
40
|
+
it("calls handleRedirectObservable on ngInit", (done) => {
|
|
41
|
+
const sampleAccessToken = {
|
|
42
|
+
accessToken: "123abc",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
spyOn(
|
|
46
|
+
PublicClientApplication.prototype,
|
|
47
|
+
"handleRedirectPromise"
|
|
48
|
+
).and.callFake(() => {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
console.log("Spy called");
|
|
51
|
+
//@ts-ignore
|
|
52
|
+
resolve(sampleAccessToken);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
PublicClientApplication.prototype.handleRedirectPromise
|
|
56
|
+
).toHaveBeenCalled();
|
|
57
|
+
done();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const component = new MsalRedirectComponent(authService);
|
|
62
|
+
component.ngOnInit();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This is a dedicated redirect component to be added to Angular apps to
|
|
8
|
+
* handle redirects when using @azure/msal-angular.
|
|
9
|
+
* Import this component to use redirects in your app.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Component, OnInit } from "@angular/core";
|
|
13
|
+
import { MsalService } from "./msal.service";
|
|
14
|
+
|
|
15
|
+
@Component({
|
|
16
|
+
selector: "app-redirect",
|
|
17
|
+
template: "",
|
|
18
|
+
})
|
|
19
|
+
export class MsalRedirectComponent implements OnInit {
|
|
20
|
+
constructor(private authService: MsalService) {}
|
|
21
|
+
|
|
22
|
+
ngOnInit(): void {
|
|
23
|
+
this.authService.getLogger().verbose("MsalRedirectComponent activated");
|
|
24
|
+
this.authService.handleRedirectObservable().subscribe();
|
|
25
|
+
}
|
|
26
|
+
}
|