@equinor/fusion-framework-module-msal 10.0.2 → 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 (73) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/README.md +17 -368
  3. package/dist/esm/MsalConfigurator.js +176 -88
  4. package/dist/esm/MsalConfigurator.js.map +1 -1
  5. package/dist/esm/__tests__/MsalConfigurator.test.js +75 -0
  6. package/dist/esm/__tests__/MsalConfigurator.test.js.map +1 -1
  7. package/dist/esm/__tests__/create-proxy-provider.test.js +53 -0
  8. package/dist/esm/__tests__/create-proxy-provider.test.js.map +1 -0
  9. package/dist/esm/__tests__/mock/msal-mock.test.js +399 -0
  10. package/dist/esm/__tests__/mock/msal-mock.test.js.map +1 -0
  11. package/dist/esm/index.js +5 -0
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/mock/MsalMockClient.js +467 -0
  14. package/dist/esm/mock/MsalMockClient.js.map +1 -0
  15. package/dist/esm/mock/MsalMockConfigurator.js +187 -0
  16. package/dist/esm/mock/MsalMockConfigurator.js.map +1 -0
  17. package/dist/esm/mock/create-mock-token.js +60 -0
  18. package/dist/esm/mock/create-mock-token.js.map +1 -0
  19. package/dist/esm/mock/create-msal-mock-client.js +20 -0
  20. package/dist/esm/mock/create-msal-mock-client.js.map +1 -0
  21. package/dist/esm/mock/decode-jwt-segment.js +22 -0
  22. package/dist/esm/mock/decode-jwt-segment.js.map +1 -0
  23. package/dist/esm/mock/index.js +30 -0
  24. package/dist/esm/mock/index.js.map +1 -0
  25. package/dist/esm/mock/module.js +38 -0
  26. package/dist/esm/mock/module.js.map +1 -0
  27. package/dist/esm/msal-config-schema.js +37 -0
  28. package/dist/esm/msal-config-schema.js.map +1 -0
  29. package/dist/esm/telemetry-config-schema.js +16 -0
  30. package/dist/esm/telemetry-config-schema.js.map +1 -0
  31. package/dist/esm/version.js +1 -1
  32. package/dist/esm/version.js.map +1 -1
  33. package/dist/esm/versioning/resolve-version.js +0 -1
  34. package/dist/esm/versioning/resolve-version.js.map +1 -1
  35. package/dist/tsconfig.tsbuildinfo +1 -1
  36. package/dist/types/MsalConfigurator.d.ts +96 -20
  37. package/dist/types/__tests__/create-proxy-provider.test.d.ts +1 -0
  38. package/dist/types/__tests__/mock/msal-mock.test.d.ts +1 -0
  39. package/dist/types/index.d.ts +5 -0
  40. package/dist/types/mock/MsalMockClient.d.ts +270 -0
  41. package/dist/types/mock/MsalMockConfigurator.d.ts +156 -0
  42. package/dist/types/mock/create-mock-token.d.ts +54 -0
  43. package/dist/types/mock/create-msal-mock-client.d.ts +14 -0
  44. package/dist/types/mock/decode-jwt-segment.d.ts +13 -0
  45. package/dist/types/mock/index.d.ts +29 -0
  46. package/dist/types/mock/module.d.ts +35 -0
  47. package/dist/types/msal-config-schema.d.ts +64 -0
  48. package/dist/types/telemetry-config-schema.d.ts +8 -0
  49. package/dist/types/version.d.ts +1 -1
  50. package/docs/api-reference.md +85 -0
  51. package/docs/auth-code-flow.md +86 -0
  52. package/docs/migration-v2-to-v4.md +115 -0
  53. package/docs/testing.md +167 -0
  54. package/docs/troubleshooting.md +17 -0
  55. package/docs/version-management.md +67 -0
  56. package/package.json +12 -5
  57. package/src/MsalConfigurator.ts +202 -115
  58. package/src/__tests__/MsalConfigurator.test.ts +106 -0
  59. package/src/__tests__/create-proxy-provider.test.ts +77 -0
  60. package/src/__tests__/mock/msal-mock.test.ts +544 -0
  61. package/src/index.ts +6 -0
  62. package/src/mock/MsalMockClient.ts +599 -0
  63. package/src/mock/MsalMockConfigurator.ts +241 -0
  64. package/src/mock/create-mock-token.ts +92 -0
  65. package/src/mock/create-msal-mock-client.ts +25 -0
  66. package/src/mock/decode-jwt-segment.ts +22 -0
  67. package/src/mock/index.ts +29 -0
  68. package/src/mock/module.ts +54 -0
  69. package/src/msal-config-schema.ts +81 -0
  70. package/src/telemetry-config-schema.ts +25 -0
  71. package/src/version.ts +1 -1
  72. package/src/versioning/resolve-version.ts +0 -1
  73. package/vitest.config.ts +1 -1
@@ -0,0 +1,67 @@
1
+ # Version Management
2
+
3
+ The MSAL module includes built-in version checking to ensure compatibility between different MSAL library versions.
4
+
5
+ ## Version Resolution
6
+
7
+ ```typescript
8
+ import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning';
9
+
10
+ // Resolve and validate a version
11
+ const result = resolveVersion('2.0.0');
12
+ console.log(result.isLatest); // false
13
+ console.log(result.satisfiesLatest); // true
14
+ console.log(result.enumVersion); // MsalModuleVersion.V2
15
+ ```
16
+
17
+ ## Version Checking Behavior
18
+
19
+ - **Major Version Incompatibility**: Throws `VersionError` if requested major version is greater than latest
20
+ - **Minor Version Mismatch**: Logs warning but allows execution
21
+ - **Patch Differences**: Ignored for compatibility
22
+ - **Invalid Versions**: Throws `VersionError` with descriptive message
23
+
24
+ ## API Reference
25
+
26
+ ### `resolveVersion(version: string | SemVer): ResolvedVersion`
27
+
28
+ Resolves and validates a version string against the latest available MSAL version.
29
+
30
+ **Parameters:**
31
+ - `version` - Version string or SemVer object to resolve
32
+
33
+ **Returns:** `ResolvedVersion` object containing:
34
+ - `wantedVersion: SemVer` - The parsed requested version
35
+ - `latestVersion: SemVer` - The latest available version
36
+ - `isLatest: boolean` - Whether the version is exactly the latest
37
+ - `satisfiesLatest: boolean` - Whether the major version matches latest
38
+ - `enumVersion: MsalModuleVersion` - Corresponding enum version
39
+
40
+ **Throws:** `VersionError` for invalid or incompatible versions
41
+
42
+ ### `VersionError`
43
+
44
+ Error class for version-related issues with the following types:
45
+ - `InvalidVersion` - Requested version is not a valid semver
46
+ - `InvalidLatestVersion` - Latest version parsing failed (build issue)
47
+ - `MajorIncompatibility` - Major version is greater than latest
48
+ - `MinorMismatch` - Minor version differs (warning only)
49
+ - `PatchDifference` - Patch version differs (info only)
50
+ - `IncompatibleVersion` - General incompatibility
51
+
52
+ ## Error Handling
53
+
54
+ ```typescript
55
+ import { resolveVersion, VersionError } from '@equinor/fusion-framework-module-msal/versioning';
56
+
57
+ try {
58
+ const result = resolveVersion('3.0.0'); // Assuming latest is 2.x
59
+ } catch (error) {
60
+ if (error instanceof VersionError) {
61
+ console.error('Version error:', error.message);
62
+ console.error('Requested:', error.requestedVersion);
63
+ console.error('Latest:', error.latestVersion);
64
+ console.error('Type:', error.type);
65
+ }
66
+ }
67
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-module-msal",
3
- "version": "10.0.2",
3
+ "version": "11.0.0-next.0",
4
4
  "description": "Microsoft Authentication Library (MSAL) integration module for Fusion Framework",
5
5
  "main": "dist/esm/index.js",
6
6
  "types": "dist/types/index.d.ts",
@@ -16,6 +16,10 @@
16
16
  "./v4": {
17
17
  "import": "./dist/esm/v4/index.js",
18
18
  "types": "./dist/types/v4/index.d.ts"
19
+ },
20
+ "./mock": {
21
+ "import": "./dist/esm/mock/index.js",
22
+ "types": "./dist/types/mock/index.d.ts"
19
23
  }
20
24
  },
21
25
  "typesVersions": {
@@ -28,6 +32,9 @@
28
32
  ],
29
33
  "v4": [
30
34
  "dist/types/v4/index.d.ts"
35
+ ],
36
+ "mock": [
37
+ "dist/types/mock/index.d.ts"
31
38
  ]
32
39
  }
33
40
  },
@@ -50,16 +57,16 @@
50
57
  "semver": "^7.7.4",
51
58
  "typescript": "^7.0.2",
52
59
  "zod": "^4.4.3",
53
- "@equinor/fusion-framework-module": "^6.1.1",
54
- "@equinor/fusion-framework-module-telemetry": "^7.0.1"
60
+ "@equinor/fusion-framework-module": "^6.1.3-next.0",
61
+ "@equinor/fusion-framework-module-telemetry": "^8.0.0-next.0"
55
62
  },
56
63
  "peerDependencies": {
57
64
  "@types/semver": "^7.0.0",
58
65
  "semver": "^7.0.0",
59
66
  "typescript": ">=5.0.0",
60
67
  "zod": "^4.0.0",
61
- "@equinor/fusion-framework-module": "^6.1.1",
62
- "@equinor/fusion-framework-module-telemetry": "^7.0.1"
68
+ "@equinor/fusion-framework-module": "^6.1.3-next.0",
69
+ "@equinor/fusion-framework-module-telemetry": "^8.0.0-next.0"
63
70
  },
64
71
  "peerDependenciesMeta": {
65
72
  "@equinor/fusion-framework-module-telemetry": {
@@ -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,6 +34,7 @@ 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.
@@ -107,6 +63,9 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
107
63
  return telemetry;
108
64
  }
109
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);
110
69
  // Default cache lookup policy to AccessTokenAndRefreshToken to avoid iframe fallback delays
111
70
  this._set('cacheLookupPolicy', async () => CacheLookupPolicy.AccessTokenAndRefreshToken);
112
71
  }
@@ -136,6 +95,23 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
136
95
  return this;
137
96
  }
138
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
+
139
115
  /**
140
116
  * Sets the cache lookup policy used for every silent token acquisition.
141
117
  *
@@ -274,10 +250,23 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
274
250
  * ```
275
251
  */
276
252
  setClient(client: IMsalClient): this {
277
- this._set('client', async () => client);
253
+ this.#client = client;
278
254
  return this;
279
255
  }
280
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
+
281
270
  /**
282
271
  * Sets telemetry provider for MSAL authentication events.
283
272
  *
@@ -328,72 +317,170 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
328
317
  /**
329
318
  * Processes and validates the configuration.
330
319
  *
331
- * @param config - Raw configuration object
320
+ * @param rawConfig - Raw configuration object
321
+ * @param init - The builder arguments, carrying the host reference when hoisted
332
322
  * @returns Processed and validated configuration
333
323
  */
334
- async _processConfig(rawConfig: MsalConfig): Promise<MsalConfig> {
324
+ async _processConfig(
325
+ rawConfig: MsalConfig,
326
+ init?: ConfigBuilderCallbackArgs,
327
+ ): Promise<MsalConfig> {
335
328
  // Validate and coerce configuration using Zod schema
336
329
  const config = await MsalConfigSchema.parseAsync(rawConfig);
337
330
 
338
- // Auto-create client if config provided but no client instance
331
+ // Auto-create client if no client instance was supplied
339
332
  // This allows users to provide configuration without manually instantiating the client
340
- if (!config.client && this.#msalConfig) {
341
- 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
+ }
342
339
 
343
- config.telemetry.provider?.trackEvent({
344
- name: 'module-msal.configurator._processConfig.creating-client',
345
- level: TelemetryLevel.Debug,
346
- scope: config.telemetry.scope,
347
- metadata: { ...config.telemetry.metadata, clientConfig },
348
- });
340
+ return config;
341
+ }
349
342
 
350
- // Auto-generate authority URL from tenant ID if not explicitly provided
351
- // This simplifies configuration for most common cases
352
- if (!clientConfig.auth.authority && clientConfig.auth.tenantId) {
353
- clientConfig.auth.authority = `https://login.microsoftonline.com/${clientConfig.auth.tenantId}`;
354
- }
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
+ }
355
379
 
356
- // Set default cache location to localStorage for browser environments
357
- // MSAL supports sessionStorage as well, but localStorage is the standard for persistent auth
358
- if (!clientConfig.cache) {
359
- clientConfig.cache = { cacheLocation: 'localStorage' };
360
- }
380
+ // Instantiate MSAL client with fully configured options
381
+ return new MsalClient(clientConfig);
382
+ }
361
383
 
362
- // Integrate framework telemetry with MSAL logging system
363
- // This allows MSAL events to flow through the framework's telemetry pipeline
364
- if (!clientConfig.system?.loggerOptions && config.telemetry?.provider) {
365
- const { provider, metadata, scope } = config.telemetry;
366
-
367
- provider.trackEvent({
368
- name: 'module-msal.configurator._processConfig.client-telemetry-connected',
369
- level: TelemetryLevel.Debug,
370
- scope,
371
- metadata,
372
- });
373
-
374
- clientConfig.system = {
375
- ...clientConfig.system,
376
- loggerOptions: {
377
- // Only log PII in development to protect user privacy in production
378
- piiLoggingEnabled: process.env.NODE_ENV === 'development',
379
- // Bridge MSAL log events to framework telemetry system
380
- loggerCallback: createClientLogCallback(provider, metadata, [...scope, '3rd-party']),
381
- // Use Warning level by default - captures errors and warnings without being verbose
382
- logLevel: LogLevel.Warning,
383
- // Preserve any user-provided logger options (allows customization)
384
- ...clientConfig.system?.loggerOptions,
385
- },
386
- };
387
- }
388
- // Apply silent cache lookup policy if configured
389
- if (config.cacheLookupPolicy !== undefined) {
390
- clientConfig.cacheLookupPolicy = config.cacheLookupPolicy;
391
- }
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
+ }
392
404
 
393
- // Instantiate MSAL client with fully configured options
394
- 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;
395
426
  }
396
427
 
397
- 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;
398
485
  }
399
486
  }
@@ -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();