@equinor/fusion-framework-module-msal 10.0.1 → 11.0.0-next.0

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +17 -368
  3. package/dist/esm/MsalClient.js +9 -0
  4. package/dist/esm/MsalClient.js.map +1 -1
  5. package/dist/esm/MsalConfigurator.js +178 -88
  6. package/dist/esm/MsalConfigurator.js.map +1 -1
  7. package/dist/esm/MsalProvider.js +20 -1
  8. package/dist/esm/MsalProvider.js.map +1 -1
  9. package/dist/esm/__tests__/MsalConfigurator.test.js +75 -0
  10. package/dist/esm/__tests__/MsalConfigurator.test.js.map +1 -1
  11. package/dist/esm/__tests__/create-proxy-provider.test.js +53 -0
  12. package/dist/esm/__tests__/create-proxy-provider.test.js.map +1 -0
  13. package/dist/esm/__tests__/mock/msal-mock.test.js +399 -0
  14. package/dist/esm/__tests__/mock/msal-mock.test.js.map +1 -0
  15. package/dist/esm/create-client-log-callback.js +1 -0
  16. package/dist/esm/create-client-log-callback.js.map +1 -1
  17. package/dist/esm/create-proxy-provider.js +5 -0
  18. package/dist/esm/create-proxy-provider.js.map +1 -1
  19. package/dist/esm/index.js +5 -0
  20. package/dist/esm/index.js.map +1 -1
  21. package/dist/esm/mock/MsalMockClient.js +467 -0
  22. package/dist/esm/mock/MsalMockClient.js.map +1 -0
  23. package/dist/esm/mock/MsalMockConfigurator.js +187 -0
  24. package/dist/esm/mock/MsalMockConfigurator.js.map +1 -0
  25. package/dist/esm/mock/create-mock-token.js +60 -0
  26. package/dist/esm/mock/create-mock-token.js.map +1 -0
  27. package/dist/esm/mock/create-msal-mock-client.js +20 -0
  28. package/dist/esm/mock/create-msal-mock-client.js.map +1 -0
  29. package/dist/esm/mock/decode-jwt-segment.js +22 -0
  30. package/dist/esm/mock/decode-jwt-segment.js.map +1 -0
  31. package/dist/esm/mock/index.js +30 -0
  32. package/dist/esm/mock/index.js.map +1 -0
  33. package/dist/esm/mock/module.js +38 -0
  34. package/dist/esm/mock/module.js.map +1 -0
  35. package/dist/esm/module.js +3 -2
  36. package/dist/esm/module.js.map +1 -1
  37. package/dist/esm/msal-config-schema.js +37 -0
  38. package/dist/esm/msal-config-schema.js.map +1 -0
  39. package/dist/esm/telemetry-config-schema.js +16 -0
  40. package/dist/esm/telemetry-config-schema.js.map +1 -0
  41. package/dist/esm/util/normalize-uri.js.map +1 -1
  42. package/dist/esm/v2/Logger.js +173 -0
  43. package/dist/esm/v2/Logger.js.map +1 -0
  44. package/dist/esm/v2/create-proxy-client.js +6 -0
  45. package/dist/esm/v2/create-proxy-client.js.map +1 -1
  46. package/dist/esm/v2/create-proxy-provider.js +4 -0
  47. package/dist/esm/v2/create-proxy-provider.js.map +1 -1
  48. package/dist/esm/v2/types.js +3 -124
  49. package/dist/esm/v2/types.js.map +1 -1
  50. package/dist/esm/v4/create-proxy-provider.js +1 -0
  51. package/dist/esm/v4/create-proxy-provider.js.map +1 -1
  52. package/dist/esm/v4/types.js.map +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/dist/esm/version.js.map +1 -1
  55. package/dist/esm/versioning/VersionError.js.map +1 -1
  56. package/dist/esm/versioning/resolve-version.js +4 -1
  57. package/dist/esm/versioning/resolve-version.js.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/types/MsalClient.d.ts +4 -0
  60. package/dist/types/MsalConfigurator.d.ts +97 -20
  61. package/dist/types/MsalProvider.d.ts +13 -0
  62. package/dist/types/__tests__/create-proxy-provider.test.d.ts +1 -0
  63. package/dist/types/__tests__/mock/msal-mock.test.d.ts +1 -0
  64. package/dist/types/create-proxy-provider.d.ts +3 -0
  65. package/dist/types/index.d.ts +5 -0
  66. package/dist/types/mock/MsalMockClient.d.ts +270 -0
  67. package/dist/types/mock/MsalMockConfigurator.d.ts +156 -0
  68. package/dist/types/mock/create-mock-token.d.ts +54 -0
  69. package/dist/types/mock/create-msal-mock-client.d.ts +14 -0
  70. package/dist/types/mock/decode-jwt-segment.d.ts +13 -0
  71. package/dist/types/mock/index.d.ts +29 -0
  72. package/dist/types/mock/module.d.ts +35 -0
  73. package/dist/types/msal-config-schema.d.ts +64 -0
  74. package/dist/types/static.d.ts +1 -1
  75. package/dist/types/telemetry-config-schema.d.ts +8 -0
  76. package/dist/types/v2/IAuthClient.interface.d.ts +2 -2
  77. package/dist/types/v2/Logger.d.ts +92 -0
  78. package/dist/types/v2/MsalProvider.interface.d.ts +1 -1
  79. package/dist/types/v2/types.d.ts +3 -49
  80. package/dist/types/v4/types.d.ts +1 -3
  81. package/dist/types/version.d.ts +1 -1
  82. package/docs/api-reference.md +85 -0
  83. package/docs/auth-code-flow.md +86 -0
  84. package/docs/migration-v2-to-v4.md +115 -0
  85. package/docs/testing.md +167 -0
  86. package/docs/troubleshooting.md +17 -0
  87. package/docs/version-management.md +67 -0
  88. package/package.json +14 -7
  89. package/src/MsalClient.ts +9 -0
  90. package/src/MsalConfigurator.ts +204 -115
  91. package/src/MsalProvider.ts +20 -1
  92. package/src/__tests__/MsalConfigurator.test.ts +106 -0
  93. package/src/__tests__/create-proxy-provider.test.ts +77 -0
  94. package/src/__tests__/mock/msal-mock.test.ts +544 -0
  95. package/src/create-client-log-callback.ts +1 -0
  96. package/src/create-proxy-provider.ts +5 -0
  97. package/src/index.ts +6 -0
  98. package/src/mock/MsalMockClient.ts +599 -0
  99. package/src/mock/MsalMockConfigurator.ts +241 -0
  100. package/src/mock/create-mock-token.ts +92 -0
  101. package/src/mock/create-msal-mock-client.ts +25 -0
  102. package/src/mock/decode-jwt-segment.ts +22 -0
  103. package/src/mock/index.ts +29 -0
  104. package/src/mock/module.ts +54 -0
  105. package/src/module.ts +3 -2
  106. package/src/msal-config-schema.ts +81 -0
  107. package/src/telemetry-config-schema.ts +25 -0
  108. package/src/v2/IAuthClient.interface.ts +2 -2
  109. package/src/v2/Logger.ts +204 -0
  110. package/src/v2/MsalProvider.interface.ts +1 -1
  111. package/src/v2/create-proxy-client.ts +6 -0
  112. package/src/v2/create-proxy-provider.ts +4 -0
  113. package/src/v2/types.ts +8 -158
  114. package/src/v4/create-proxy-provider.ts +1 -0
  115. package/src/v4/types.ts +3 -3
  116. package/src/version.ts +1 -1
  117. package/src/versioning/resolve-version.ts +4 -1
  118. package/vitest.config.ts +1 -1
@@ -1,68 +1,23 @@
1
- import z from 'zod';
2
- import { BaseConfigBuilder } from '@equinor/fusion-framework-module';
3
- import semver from 'semver';
4
- import type { IMsalProvider } from './MsalProvider.interface';
5
1
  import {
6
- TelemetryLevel,
7
- type ITelemetryProvider,
8
- } from '@equinor/fusion-framework-module-telemetry';
2
+ BaseConfigBuilder,
3
+ type ConfigBuilderCallbackArgs,
4
+ } from '@equinor/fusion-framework-module';
5
+ import { TelemetryLevel } from '@equinor/fusion-framework-module-telemetry';
6
+ import { CacheLookupPolicy, LogLevel } from '@azure/msal-browser';
7
+
8
+ import type { ITelemetryProvider } from '@equinor/fusion-framework-module-telemetry';
9
+ import type { IMsalProvider } from './MsalProvider.interface';
9
10
  import { MsalClient, type MsalClientConfig, type IMsalClient } from './MsalClient';
10
11
  import { createClientLogCallback } from './create-client-log-callback';
11
- import { CacheLookupPolicy, LogLevel } from '@azure/msal-browser';
12
12
  import { version } from './version';
13
+ import { MsalConfigSchema, type MsalConfig } from './msal-config-schema';
13
14
 
14
- /**
15
- * Zod schema for telemetry configuration validation.
16
- *
17
- * @internal
18
- */
19
- const TelemetryConfigSchema = z.object({
20
- provider: z.custom<ITelemetryProvider>().optional(),
21
- metadata: z.record(z.string(), z.unknown()).optional().default({
22
- module: 'msal',
23
- version,
24
- }),
25
- scope: z.array(z.string()).optional().default(['framework', 'authentication']),
26
- });
27
-
28
- /**
29
- * Telemetry configuration for MSAL module.
30
- *
31
- * This configuration controls how authentication events are tracked and logged
32
- * through the framework's telemetry system.
33
- */
34
- export type TelemetryConfig = z.infer<typeof TelemetryConfigSchema>;
35
-
36
- /**
37
- * Zod schema for MSAL module configuration validation.
38
- *
39
- * @internal
40
- */
41
- const MsalConfigSchema = z.object({
42
- client: z.custom<IMsalClient>().optional(),
43
- provider: z.custom<IMsalProvider>().optional(),
44
- requiresAuth: z.boolean().optional(),
45
- redirectUri: z.string().optional(),
46
- loginHint: z.string().optional(),
47
- authCode: z.string().optional(),
48
- cacheLookupPolicy: z
49
- .custom<CacheLookupPolicy>(
50
- (val) =>
51
- typeof val === 'number' &&
52
- Object.values(CacheLookupPolicy).includes(val as CacheLookupPolicy),
53
- )
54
- .optional(),
55
- version: z.string().transform((x: string) => String(semver.coerce(x))),
56
- telemetry: TelemetryConfigSchema,
57
- });
58
-
59
- /**
60
- * Complete configuration object for MSAL authentication module.
61
- *
62
- * This type represents the full configuration including client setup, authentication
63
- * requirements, telemetry, and version information.
64
- */
65
- export type MsalConfig = z.infer<typeof MsalConfigSchema>;
15
+ export {
16
+ MsalConfigSchema,
17
+ type MsalConfig,
18
+ type MsalConfigExtension,
19
+ } from './msal-config-schema';
20
+ export { TelemetryConfigSchema, type TelemetryConfig } from './telemetry-config-schema';
66
21
 
67
22
  /**
68
23
  * Configuration builder for MSAL v4 authentication module.
@@ -79,11 +34,13 @@ export type MsalConfig = z.infer<typeof MsalConfigSchema>;
79
34
  */
80
35
  export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
81
36
  #msalConfig?: MsalClientConfig;
37
+ #client?: IMsalClient;
82
38
 
83
39
  /**
84
40
  * The MSAL module version being configured.
85
41
  *
86
42
  * @default Latest
43
+ * @returns The configured MSAL module version.
87
44
  */
88
45
  public get version(): string {
89
46
  return version;
@@ -100,11 +57,15 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
100
57
  this._set('version', async () => this.version);
101
58
  // Auto-detect and integrate telemetry module if available
102
59
  this._set('telemetry.provider', async (args) => {
60
+ // Only resolve the telemetry instance when the telemetry module is registered
103
61
  if (args.hasModule('telemetry')) {
104
62
  const telemetry = await args.requireInstance('telemetry');
105
63
  return telemetry;
106
64
  }
107
65
  });
66
+ // Always resolve the configured client instance through the builder.
67
+ // This keeps the client getter live and avoids re-registering the same config key.
68
+ this._set('client', async () => this.#client);
108
69
  // Default cache lookup policy to AccessTokenAndRefreshToken to avoid iframe fallback delays
109
70
  this._set('cacheLookupPolicy', async () => CacheLookupPolicy.AccessTokenAndRefreshToken);
110
71
  }
@@ -134,6 +95,23 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
134
95
  return this;
135
96
  }
136
97
 
98
+ /**
99
+ * Returns the client configuration declared through
100
+ * {@link MsalConfigurator.setClientConfig | setClientConfig}, if any.
101
+ *
102
+ * @remarks
103
+ * This is the configuration as declared, not the resolved one a client is
104
+ * built from — see
105
+ * {@link MsalConfigurator._createClientConfig | _createClientConfig} for that.
106
+ * Reading it is how a subclass can tell "nothing was declared" apart from
107
+ * "declared, and here it is", without re-deriving that from a resolved value.
108
+ *
109
+ * @returns The declared client configuration, or `undefined` when none was declared.
110
+ */
111
+ public getClientConfig(): MsalClientConfig | undefined {
112
+ return this.#msalConfig;
113
+ }
114
+
137
115
  /**
138
116
  * Sets the cache lookup policy used for every silent token acquisition.
139
117
  *
@@ -272,10 +250,23 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
272
250
  * ```
273
251
  */
274
252
  setClient(client: IMsalClient): this {
275
- this._set('client', async () => client);
253
+ this.#client = client;
276
254
  return this;
277
255
  }
278
256
 
257
+ /**
258
+ * Returns the currently configured MSAL client, if one has been set.
259
+ *
260
+ * @remarks
261
+ * This is useful in tests when a mock client has been provided and the test
262
+ * wants to adjust its state after it has been assigned to the configurator.
263
+ *
264
+ * @returns The configured client, or `undefined` when none has been set.
265
+ */
266
+ public getClient(): IMsalClient | undefined {
267
+ return this.#client;
268
+ }
269
+
279
270
  /**
280
271
  * Sets telemetry provider for MSAL authentication events.
281
272
  *
@@ -326,72 +317,170 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
326
317
  /**
327
318
  * Processes and validates the configuration.
328
319
  *
329
- * @param config - Raw configuration object
320
+ * @param rawConfig - Raw configuration object
321
+ * @param init - The builder arguments, carrying the host reference when hoisted
330
322
  * @returns Processed and validated configuration
331
323
  */
332
- async _processConfig(rawConfig: MsalConfig): Promise<MsalConfig> {
324
+ async _processConfig(
325
+ rawConfig: MsalConfig,
326
+ init?: ConfigBuilderCallbackArgs,
327
+ ): Promise<MsalConfig> {
333
328
  // Validate and coerce configuration using Zod schema
334
329
  const config = await MsalConfigSchema.parseAsync(rawConfig);
335
330
 
336
- // Auto-create client if config provided but no client instance
331
+ // Auto-create client if no client instance was supplied
337
332
  // This allows users to provide configuration without manually instantiating the client
338
- if (!config.client && !!this.#msalConfig) {
339
- const clientConfig = this.#msalConfig;
333
+ // A hoisted module authenticates through the host's provider, so any client built here
334
+ // would be discarded — gate it here rather than in `_createClient`, so a substituted
335
+ // client (see `MsalMockConfigurator`) cannot shadow the host's signed-in user
336
+ if (!config.client && !this._isHoisted(init)) {
337
+ config.client = await this._createClient(config, init);
338
+ }
340
339
 
341
- config.telemetry.provider?.trackEvent({
342
- name: 'module-msal.configurator._processConfig.creating-client',
343
- level: TelemetryLevel.Debug,
344
- scope: config.telemetry.scope,
345
- metadata: { ...config.telemetry.metadata, clientConfig },
346
- });
340
+ return config;
341
+ }
347
342
 
348
- // Auto-generate authority URL from tenant ID if not explicitly provided
349
- // This simplifies configuration for most common cases
350
- if (!clientConfig.auth.authority && clientConfig.auth.tenantId) {
351
- clientConfig.auth.authority = `https://login.microsoftonline.com/${clientConfig.auth.tenantId}`;
352
- }
343
+ /**
344
+ * Creates the client to authenticate through, when none was supplied.
345
+ *
346
+ * @remarks
347
+ * Called by {@link MsalConfigurator._processConfig | _processConfig} only when
348
+ * no client was set, so a client supplied through
349
+ * {@link MsalConfigurator.setClient | setClient} always wins. It is likewise
350
+ * not called when the module is hoisted onto a host application's provider —
351
+ * see {@link MsalConfigurator._isHoisted | _isHoisted}.
352
+ *
353
+ * This is the seam for authenticating through something other than Entra ID.
354
+ * Overriding it replaces only the client, leaving the builder, the schema
355
+ * validation and `MsalProvider` untouched — which is how
356
+ * `MsalMockConfigurator` substitutes an in-process client for tests.
357
+ *
358
+ * An override normally builds from
359
+ * {@link MsalConfigurator._createClientConfig | _createClientConfig}, so it
360
+ * receives the same fully-resolved {@link MsalClientConfig} the real client is
361
+ * built from rather than re-deriving it.
362
+ *
363
+ * Returning `undefined` is legitimate and means "there is nothing to build a
364
+ * client from", which leaves the module without one.
365
+ *
366
+ * @param config - The validated configuration the client is built from.
367
+ * @param init - The builder arguments, carrying the host reference when hoisted.
368
+ * @returns The client, or `undefined` when there is nothing to build one from.
369
+ */
370
+ protected async _createClient(
371
+ config: MsalConfig,
372
+ _init?: ConfigBuilderCallbackArgs,
373
+ ): Promise<IMsalClient | undefined> {
374
+ const clientConfig = this._createClientConfig(config);
375
+ // A client can be omitted for a hoisted module or an intentionally incomplete setup.
376
+ if (!clientConfig) {
377
+ return undefined;
378
+ }
353
379
 
354
- // Set default cache location to localStorage for browser environments
355
- // MSAL supports sessionStorage as well, but localStorage is the standard for persistent auth
356
- if (!clientConfig.cache) {
357
- clientConfig.cache = { cacheLocation: 'localStorage' };
358
- }
380
+ // Instantiate MSAL client with fully configured options
381
+ return new MsalClient(clientConfig);
382
+ }
359
383
 
360
- // Integrate framework telemetry with MSAL logging system
361
- // This allows MSAL events to flow through the framework's telemetry pipeline
362
- if (!clientConfig.system?.loggerOptions && config.telemetry?.provider) {
363
- const { provider, metadata, scope } = config.telemetry;
364
-
365
- provider.trackEvent({
366
- name: 'module-msal.configurator._processConfig.client-telemetry-connected',
367
- level: TelemetryLevel.Debug,
368
- scope,
369
- metadata,
370
- });
371
-
372
- clientConfig.system = {
373
- ...clientConfig.system,
374
- loggerOptions: {
375
- // Only log PII in development to protect user privacy in production
376
- piiLoggingEnabled: process.env.NODE_ENV === 'development',
377
- // Bridge MSAL log events to framework telemetry system
378
- loggerCallback: createClientLogCallback(provider, metadata, [...scope, '3rd-party']),
379
- // Use Warning level by default - captures errors and warnings without being verbose
380
- logLevel: LogLevel.Warning,
381
- // Preserve any user-provided logger options (allows customization)
382
- ...clientConfig.system?.loggerOptions,
383
- },
384
- };
385
- }
386
- // Apply silent cache lookup policy if configured
387
- if (config.cacheLookupPolicy !== undefined) {
388
- clientConfig.cacheLookupPolicy = config.cacheLookupPolicy;
389
- }
384
+ /**
385
+ * Whether this module is hoisted onto a host application's authentication.
386
+ *
387
+ * @remarks
388
+ * When an application runs inside a host — a portal loading an app, or an app
389
+ * loading a widget — the module initializer returns a proxy of the host's
390
+ * provider instead of building its own (see the host-provider branch of the
391
+ * module initializer). A client built during configuration would therefore be
392
+ * constructed and immediately discarded.
393
+ *
394
+ * Detecting this during configuration lets the configurator skip building a
395
+ * client entirely, which matters most for substituted clients: a mock client
396
+ * built here would otherwise silently shadow the host's real signed-in user.
397
+ *
398
+ * @param init - The builder arguments, carrying the host reference when hoisted.
399
+ * @returns `true` when a host provider will be used instead of a locally built client.
400
+ */
401
+ protected _isHoisted(init?: ConfigBuilderCallbackArgs): boolean {
402
+ return !!(init?.ref as { auth?: IMsalProvider } | undefined)?.auth;
403
+ }
390
404
 
391
- // Instantiate MSAL client with fully configured options
392
- config.client = new MsalClient(clientConfig);
405
+ /**
406
+ * Resolves the full MSAL client configuration to build a client from.
407
+ *
408
+ * @remarks
409
+ * Applies the defaults a client is expected to be built with — authority
410
+ * derived from the tenant, cache location, telemetry-backed logging and the
411
+ * configured cache lookup policy.
412
+ *
413
+ * Kept separate from {@link MsalConfigurator._createClient | _createClient} so
414
+ * that substituting the client does not also mean re-implementing this
415
+ * resolution. `MsalMockConfigurator` relies on it to hand its mock client the
416
+ * very same configuration the real client would have received.
417
+ *
418
+ * @param config - The validated configuration.
419
+ * @returns The client configuration, or `undefined` when none was declared.
420
+ */
421
+ protected _createClientConfig(config: MsalConfig): MsalClientConfig | undefined {
422
+ const declared = this.#msalConfig;
423
+ // Do not construct a client when configuration has not supplied client settings.
424
+ if (!declared) {
425
+ return undefined;
393
426
  }
394
427
 
395
- return config;
428
+ config.telemetry.provider?.trackEvent({
429
+ name: 'module-msal.configurator._processConfig.creating-client',
430
+ level: TelemetryLevel.Debug,
431
+ scope: config.telemetry.scope,
432
+ metadata: { ...config.telemetry.metadata, clientConfig: declared },
433
+ });
434
+
435
+ // Copied rather than enriched in place, so the object a caller passed to
436
+ // `setClientConfig` is never rewritten behind its back — a caller may well
437
+ // be reusing or asserting on it
438
+ const clientConfig: MsalClientConfig = {
439
+ ...declared,
440
+ auth: { ...declared.auth },
441
+ // Default to localStorage: MSAL supports sessionStorage too, but
442
+ // localStorage is the standard for persistent auth in browsers
443
+ cache: declared.cache ?? { cacheLocation: 'localStorage' },
444
+ };
445
+
446
+ // Auto-generate authority URL from tenant ID if not explicitly provided
447
+ // This simplifies configuration for most common cases
448
+ if (!clientConfig.auth.authority && clientConfig.auth.tenantId) {
449
+ clientConfig.auth.authority = `https://login.microsoftonline.com/${clientConfig.auth.tenantId}`;
450
+ }
451
+
452
+ // Integrate framework telemetry with MSAL logging system
453
+ // This allows MSAL events to flow through the framework's telemetry pipeline
454
+ if (!clientConfig.system?.loggerOptions && config.telemetry?.provider) {
455
+ const { provider, metadata, scope } = config.telemetry;
456
+
457
+ provider.trackEvent({
458
+ name: 'module-msal.configurator._processConfig.client-telemetry-connected',
459
+ level: TelemetryLevel.Debug,
460
+ scope,
461
+ metadata,
462
+ });
463
+
464
+ clientConfig.system = {
465
+ ...clientConfig.system,
466
+ loggerOptions: {
467
+ // Only log PII in development to protect user privacy in production
468
+ piiLoggingEnabled: process.env.NODE_ENV === 'development',
469
+ // Bridge MSAL log events to framework telemetry system
470
+ loggerCallback: createClientLogCallback(provider, metadata, [...scope, '3rd-party']),
471
+ // Use Warning level by default - captures errors and warnings without being verbose
472
+ logLevel: LogLevel.Warning,
473
+ // Preserve any user-provided logger options (allows customization)
474
+ ...clientConfig.system?.loggerOptions,
475
+ },
476
+ };
477
+ }
478
+
479
+ // Apply silent cache lookup policy if configured
480
+ if (config.cacheLookupPolicy !== undefined) {
481
+ clientConfig.cacheLookupPolicy = config.cacheLookupPolicy;
482
+ }
483
+
484
+ return clientConfig;
396
485
  }
397
486
  }
@@ -67,6 +67,8 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
67
67
  * Default OAuth scopes used when the caller provides no scopes.
68
68
  *
69
69
  * Resolves to the app's Entra ID configured permissions via the `/.default` scope.
70
+ *
71
+ * @returns The default OAuth scopes derived from the configured client ID.
70
72
  */
71
73
  get defaultScopes(): string[] {
72
74
  const clientId = this.#client.clientId;
@@ -91,6 +93,8 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
91
93
  *
92
94
  * Provides access to the underlying MSAL PublicClientApplication for advanced use cases.
93
95
  * Prefer using provider methods for standard authentication operations.
96
+ *
97
+ * @returns The underlying MSAL client instance.
94
98
  */
95
99
  get client(): IMsalClient {
96
100
  return this.#client;
@@ -101,6 +105,8 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
101
105
  *
102
106
  * Returns the active account if a user is authenticated, or null if no user is logged in.
103
107
  * This is a shorthand for `client.getActiveAccount()`.
108
+ *
109
+ * @returns The currently authenticated account, or `null` if no user is logged in.
104
110
  */
105
111
  get account(): AccountInfo | null {
106
112
  return this.#client.getActiveAccount();
@@ -175,6 +181,8 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
175
181
  *
176
182
  * The provider will attempt automatic login with empty scopes if requiresAuth is true.
177
183
  * Apps should call acquireToken with actual scopes after initialization completes.
184
+ *
185
+ * @throws {Error} If auth code exchange requires a client ID but none is configured.
178
186
  */
179
187
  async initialize(): Promise<void> {
180
188
  // Guard: skip authentication when running inside MSAL's hidden iframe.
@@ -204,6 +212,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
204
212
  // Use MSAL's acquireTokenByCode to exchange backend auth code for tokens
205
213
  // This follows Microsoft's standard SPA Auth Code Flow pattern
206
214
  const clientId = this.#client.clientId;
215
+ // A client ID is required to build the default scope used for the auth code exchange
207
216
  if (!clientId) {
208
217
  throw new Error('Client ID is required for auth code exchange');
209
218
  }
@@ -248,6 +257,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
248
257
  // Priority 1: Check if returning from redirect-based authentication
249
258
  // This handles cases where user just completed a login/acquireToken via redirect
250
259
  const handleRedirectResult = await this.handleRedirect();
260
+ // A returned account means the user just completed a redirect-based login
251
261
  if (handleRedirectResult?.account) {
252
262
  // Successfully authenticated via redirect - set as active account
253
263
  // This means the user was redirected to Microsoft and came back authenticated
@@ -263,6 +273,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
263
273
  // Note: Using default scopes here as we don't know what scopes the app needs yet
264
274
  // App should call acquireToken with actual scopes after initialization
265
275
  const loginResult = await this.login({ request: { scopes: this.defaultScopes } });
276
+ // Only set the active account when the automatic login actually returned one
266
277
  if (loginResult?.account) {
267
278
  // Automatic login successful - set as active account
268
279
  this.#client.setActiveAccount(loginResult.account);
@@ -315,6 +326,8 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
315
326
  * @remark Empty scopes are currently tracked as telemetry exceptions but execution continues for monitoring purposes.
316
327
  * This behavior will be changed to throw exceptions once sufficient metrics are collected.
317
328
  *
329
+ * @throws {Error} Re-throws any error encountered during token acquisition after tracking it via telemetry.
330
+ *
318
331
  * @example
319
332
  * ```typescript
320
333
  * // Modern API format
@@ -371,6 +384,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
371
384
 
372
385
  // Handle empty scopes - currently monitoring for telemetry, will throw in future
373
386
  if (candidateScopes.length === 0) {
387
+ // Fall back to the client-id-derived default scope when one is available
374
388
  if (defaultScopes.length > 0) {
375
389
  this._trackEvent('acquireToken.missing-scope.defaulted', TelemetryLevel.Warning, {
376
390
  properties: { ...telemetryProperties, defaultScopes },
@@ -383,7 +397,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
383
397
  exception,
384
398
  properties: telemetryProperties,
385
399
  });
386
- // TODO: throw exception when sufficient metrics are collected
400
+ // TODO(#5113): throw exception when sufficient metrics are collected
387
401
  // This allows us to monitor how often empty scopes are provided before enforcing validation
388
402
  }
389
403
  }
@@ -521,6 +535,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
521
535
  }
522
536
  }
523
537
 
538
+ // Perform interactive authentication based on specified behavior
524
539
  switch (behavior) {
525
540
  case 'popup':
526
541
  return await this.#client.loginPopup(request);
@@ -606,6 +621,7 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
606
621
  async handleRedirect(): Promise<AuthenticationResult | null> {
607
622
  // Process any pending redirect from authentication flow
608
623
  const result = await this.client.handleRedirectPromise();
624
+ // Only track/log when a redirect result is actually returned
609
625
  if (result) {
610
626
  // Track successful redirect completion for monitoring
611
627
  this._trackEvent('handleRedirect.success', TelemetryLevel.Information, {
@@ -632,6 +648,9 @@ export class MsalProvider extends BaseModuleProvider<MsalConfig> implements IMsa
632
648
  * - Version compatibility is tracked via telemetry
633
649
  * - Throws error if unsupported version is requested
634
650
  *
651
+ * @template T - The provider interface type expected by the target version.
652
+ * @throws {Error} If the requested version cannot be resolved or the proxy fails to create.
653
+ *
635
654
  * @example
636
655
  * ```typescript
637
656
  * // Create v2-compatible proxy
@@ -20,6 +20,23 @@ const createInitialConfig = (): Pick<MsalConfig, 'telemetry'> => ({
20
20
  });
21
21
 
22
22
  describe('MsalConfigurator', () => {
23
+ it('enriches a copy, leaving the declared client configuration untouched', async () => {
24
+ // A caller may reuse or assert on the object it passed, and the defaults
25
+ // applied here are derived — rewriting it behind their back is not ours to do
26
+ const declared = { auth: { clientId: 'my-app', tenantId: 'my-tenant' } };
27
+ const configurator = new MsalConfigurator();
28
+
29
+ configurator.setClientConfig(declared);
30
+
31
+ const config = await configurator.createConfigAsync(
32
+ createConfigCallbackArgs(),
33
+ createInitialConfig(),
34
+ );
35
+
36
+ expect(declared).toEqual({ auth: { clientId: 'my-app', tenantId: 'my-tenant' } });
37
+ expect(config.client?.tenantId).toBe('my-tenant');
38
+ });
39
+
23
40
  it('setAuthCode should normalize surrounding whitespace', async () => {
24
41
  const configurator = new MsalConfigurator();
25
42
 
@@ -74,6 +91,95 @@ describe('MsalConfigurator', () => {
74
91
  expect(config.client).toBeUndefined();
75
92
  });
76
93
 
94
+ describe('_createClient', () => {
95
+ it('builds from the same resolved client config the real client would get', async () => {
96
+ // The mock relies on this: substituting the client must not also mean
97
+ // re-implementing authority, cache and telemetry resolution
98
+ const received: unknown[] = [];
99
+ class CustomConfigurator extends MsalConfigurator {
100
+ protected override async _createClient(config: MsalConfig): Promise<IMsalClient> {
101
+ received.push(this._createClientConfig(config));
102
+ return createClient();
103
+ }
104
+ }
105
+
106
+ const configurator = new CustomConfigurator();
107
+ configurator.setClientConfig({ auth: { clientId: 'client-id', tenantId: 'tenant-id' } });
108
+
109
+ await configurator.createConfigAsync(createConfigCallbackArgs(), createInitialConfig());
110
+
111
+ expect(received).toEqual([
112
+ expect.objectContaining({
113
+ auth: expect.objectContaining({
114
+ clientId: 'client-id',
115
+ // derived by the configurator, not by the caller
116
+ authority: 'https://login.microsoftonline.com/tenant-id',
117
+ }),
118
+ cache: { cacheLocation: 'localStorage' },
119
+ }),
120
+ ]);
121
+ });
122
+
123
+ it('supplies the client when none was set', async () => {
124
+ const client = createClient();
125
+ class CustomConfigurator extends MsalConfigurator {
126
+ protected override async _createClient(): Promise<IMsalClient> {
127
+ return client;
128
+ }
129
+ }
130
+
131
+ const config = await new CustomConfigurator().createConfigAsync(
132
+ createConfigCallbackArgs(),
133
+ createInitialConfig(),
134
+ );
135
+
136
+ expect(config.client).toBe(client);
137
+ });
138
+
139
+ it('is not consulted when a client was set, so setClient always wins', async () => {
140
+ const own = createClient();
141
+ const createOther = vi.fn().mockResolvedValue(createClient());
142
+ class CustomConfigurator extends MsalConfigurator {
143
+ protected override _createClient(): Promise<IMsalClient> {
144
+ return createOther();
145
+ }
146
+ }
147
+
148
+ const configurator = new CustomConfigurator();
149
+ configurator.setClient(own);
150
+
151
+ const config = await configurator.createConfigAsync(
152
+ createConfigCallbackArgs(),
153
+ createInitialConfig(),
154
+ );
155
+
156
+ expect(config.client).toBe(own);
157
+ expect(createOther).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('is not consulted when hoisted, so a host provider is never shadowed', async () => {
161
+ // A hoisted module authenticates through the host's provider, so anything
162
+ // built here would be discarded — or worse, shadow the host's user
163
+ const createOther = vi.fn().mockResolvedValue(createClient());
164
+ class CustomConfigurator extends MsalConfigurator {
165
+ protected override _createClient(): Promise<IMsalClient> {
166
+ return createOther();
167
+ }
168
+ }
169
+
170
+ const configurator = new CustomConfigurator();
171
+ configurator.setClientConfig({ auth: { clientId: 'client-id', tenantId: 'tenant-id' } });
172
+
173
+ const config = await configurator.createConfigAsync(
174
+ { ...createConfigCallbackArgs(), ref: { auth: {} } },
175
+ createInitialConfig(),
176
+ );
177
+
178
+ expect(config.client).toBeUndefined();
179
+ expect(createOther).not.toHaveBeenCalled();
180
+ });
181
+ });
182
+
77
183
  describe('cacheLookupPolicy', () => {
78
184
  it('defaults to CacheLookupPolicy.AccessTokenAndRefreshToken', async () => {
79
185
  const configurator = new MsalConfigurator();