Skip to content

Commit 730d51a

Browse files
fix(module-msal): avoid ~10-20s iframe timeout when refresh token is revoked (#4710)
* fix(module-msal): default cacheLookupPolicy to AccessTokenAndRefreshToken to avoid iframe timeout on revoked refresh token When a refresh token is revoked, MSAL's default CacheLookupPolicy falls back to a hidden iframe (SilentIframeClient) after receiving invalid_grant. The iframe times out after ~10-20s (monitor_window_timeout) before the app eventually triggers an interactive redirect — causing the visible iframe crash + page reload. Set CacheLookupPolicy.AccessTokenAndRefreshToken as the default in MsalConfigurator so revoked tokens immediately throw InteractionRequiredAuthError without the delay. The policy is configurable via configurator.setCacheLookupPolicy() and can be overridden per-request via SilentRequest.cacheLookupPolicy. * fix(module-msal): use z.custom validator and add cacheLookupPolicy tests - Replace z.nativeEnum (deprecated in Zod v4) with z.custom validator that checks Object.values(CacheLookupPolicy) for valid membership - Add CacheLookupPolicy import to MsalConfigurator.test.ts - Add tests: default resolves to AccessTokenAndRefreshToken, setCacheLookupPolicy(undefined) clears policy, override works - Remove trailing whitespace flagged by Biome * Update packages/modules/msal/src/MsalConfigurator.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 50abde9 commit 730d51a

4 files changed

Lines changed: 132 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@equinor/fusion-framework-module-msal": patch
3+
---
4+
5+
Fix silent token acquisition hanging ~10–20 s when refresh token is revoked.
6+
7+
When a refresh token is revoked, MSAL's default cache lookup policy (`Default`) falls back to a hidden iframe (`SilentIframeClient`) after the token endpoint returns `invalid_grant`. The iframe loads the app URL, fails to authenticate (no valid session), and times out — throwing `monitor_window_timeout` before eventually triggering an interactive redirect. This causes the visible "iframe crash + page reload" symptom.
8+
9+
The configurator now defaults `cacheLookupPolicy` to `CacheLookupPolicy.AccessTokenAndRefreshToken`, which skips the iframe step entirely. A revoked refresh token now immediately throws `InteractionRequiredAuthError`, and the app falls back to interactive login without the delay.
10+
11+
**Configuration:**
12+
13+
The policy is fully configurable via `MsalConfigurator.setCacheLookupPolicy`. To restore MSAL's original waterfall (cache → refresh token → iframe):
14+
15+
```typescript
16+
import { CacheLookupPolicy } from '@azure/msal-browser';
17+
18+
configurator.setCacheLookupPolicy(CacheLookupPolicy.Default);
19+
```
20+
21+
Per-request `cacheLookupPolicy` on `SilentRequest` still takes precedence over the instance default.
22+
23+
Fixes: https://github.com/equinor/fusion-core-tasks/issues/1234

packages/modules/msal/src/MsalClient.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
PublicClientApplication,
3+
type CacheLookupPolicy,
34
type SilentRequest,
45
type Configuration,
56
type EndSessionRequest,
@@ -40,6 +41,23 @@ export type MsalClientConfig = Configuration & {
4041
/** Optional tenant identifier for Azure AD tenant */
4142
tenantId?: string;
4243
};
44+
/**
45+
* Cache lookup policy applied to every `acquireTokenSilent` call.
46+
*
47+
* Controls whether MSAL falls back to a hidden iframe when the refresh token
48+
* fails. When `undefined`, MSAL's built-in default applies (cache → refresh
49+
* token → iframe). Set to `CacheLookupPolicy.AccessTokenAndRefreshToken` to
50+
* skip the iframe step and fail immediately with `InteractionRequiredAuthError`
51+
* when the refresh token is revoked — avoiding the ~10–20 s
52+
* `monitor_window_timeout` delay.
53+
*
54+
* When using {@link MsalConfigurator}, this defaults to
55+
* `CacheLookupPolicy.AccessTokenAndRefreshToken`. When constructing `MsalClient`
56+
* directly, the field is optional and defaults to `undefined` (MSAL default).
57+
*
58+
* Per-request `cacheLookupPolicy` on `SilentRequest` takes precedence over this value.
59+
*/
60+
cacheLookupPolicy?: CacheLookupPolicy;
4361
};
4462

4563
/**
@@ -65,6 +83,7 @@ export type MsalClientConfig = Configuration & {
6583
export class MsalClient extends PublicClientApplication implements IMsalClient {
6684
#tenantId?: string;
6785
#clientId?: string;
86+
#cacheLookupPolicy?: CacheLookupPolicy;
6887

6988
/**
7089
* Creates a new MSAL client instance.
@@ -75,6 +94,7 @@ export class MsalClient extends PublicClientApplication implements IMsalClient {
7594
super(config);
7695
this.#tenantId = config.auth?.tenantId;
7796
this.#clientId = config.auth?.clientId;
97+
this.#cacheLookupPolicy = config.cacheLookupPolicy;
7898
}
7999

80100
/**
@@ -257,7 +277,11 @@ export class MsalClient extends PublicClientApplication implements IMsalClient {
257277
'Attempting to acquire token silently',
258278
request.correlationId || FUSION_CORRELATION_ID,
259279
);
260-
return await this.acquireTokenSilent(request as SilentRequest);
280+
return await this.acquireTokenSilent({
281+
// Instance-level policy is the default; request-level cacheLookupPolicy takes precedence
282+
cacheLookupPolicy: this.#cacheLookupPolicy,
283+
...(request as SilentRequest),
284+
});
261285
} catch {
262286
// Silent acquisition failed - fall back to interactive
263287
this.getLogger().warning(

packages/modules/msal/src/MsalConfigurator.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@equinor/fusion-framework-module-telemetry';
99
import { MsalClient, type MsalClientConfig, type IMsalClient } from './MsalClient';
1010
import { createClientLogCallback } from './create-client-log-callback';
11-
import { LogLevel } from '@azure/msal-browser';
11+
import { CacheLookupPolicy, LogLevel } from '@azure/msal-browser';
1212
import { version } from './version';
1313

1414
/**
@@ -45,6 +45,13 @@ const MsalConfigSchema = z.object({
4545
redirectUri: z.string().optional(),
4646
loginHint: z.string().optional(),
4747
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(),
4855
version: z.string().transform((x: string) => String(semver.coerce(x))),
4956
telemetry: TelemetryConfigSchema,
5057
});
@@ -98,6 +105,8 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
98105
return telemetry;
99106
}
100107
});
108+
// Default cache lookup policy to AccessTokenAndRefreshToken to avoid iframe fallback delays
109+
this._set('cacheLookupPolicy', async () => CacheLookupPolicy.AccessTokenAndRefreshToken);
101110
}
102111

103112
/**
@@ -125,6 +134,34 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
125134
return this;
126135
}
127136

137+
/**
138+
* Sets the cache lookup policy used for every silent token acquisition.
139+
*
140+
* Controls whether MSAL falls back to a hidden iframe when the refresh token
141+
* fails. Defaults to `CacheLookupPolicy.AccessTokenAndRefreshToken`, which skips
142+
* the iframe step and fails immediately with `InteractionRequiredAuthError` when
143+
* the refresh token is revoked — avoiding the ~10–20 s `monitor_window_timeout`
144+
* delay caused by MSAL's built-in iframe fallback.
145+
*
146+
* Set to `CacheLookupPolicy.Default` to restore MSAL's full waterfall:
147+
* cache → refresh token → iframe.
148+
*
149+
* @param policy - Cache lookup policy to apply
150+
* @returns The configurator instance for method chaining
151+
*
152+
* @example
153+
* ```typescript
154+
* import { CacheLookupPolicy } from '@azure/msal-browser';
155+
*
156+
* // Restore MSAL's built-in iframe fallback (not recommended for most apps)
157+
* configurator.setCacheLookupPolicy(CacheLookupPolicy.Default);
158+
* ```
159+
*/
160+
setCacheLookupPolicy(policy: CacheLookupPolicy | undefined): this {
161+
this._set('cacheLookupPolicy', async () => policy);
162+
return this;
163+
}
164+
128165
/**
129166
* Sets a backend-issued authorization code for token exchange.
130167
*
@@ -346,6 +383,11 @@ export class MsalConfigurator extends BaseConfigBuilder<MsalConfig> {
346383
},
347384
};
348385
}
386+
// Apply silent cache lookup policy if configured
387+
if (config.cacheLookupPolicy !== undefined) {
388+
clientConfig.cacheLookupPolicy = config.cacheLookupPolicy;
389+
}
390+
349391
// Instantiate MSAL client with fully configured options
350392
config.client = new MsalClient(clientConfig);
351393
}

packages/modules/msal/src/__tests__/MsalConfigurator.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ConfigBuilderCallbackArgs } from '@equinor/fusion-framework-module';
2+
import { CacheLookupPolicy } from '@azure/msal-browser';
23
import { describe, expect, it, vi } from 'vitest';
34
import type { IMsalClient } from '../MsalClient.interface';
45
import { type MsalConfig, MsalConfigurator } from '../MsalConfigurator';
@@ -72,4 +73,44 @@ describe('MsalConfigurator', () => {
7273

7374
expect(config.client).toBeUndefined();
7475
});
76+
77+
describe('cacheLookupPolicy', () => {
78+
it('defaults to CacheLookupPolicy.AccessTokenAndRefreshToken', async () => {
79+
const configurator = new MsalConfigurator();
80+
configurator.setClient(createClient());
81+
82+
const config = await configurator.createConfigAsync(
83+
createConfigCallbackArgs(),
84+
createInitialConfig(),
85+
);
86+
87+
expect(config.cacheLookupPolicy).toBe(CacheLookupPolicy.AccessTokenAndRefreshToken);
88+
});
89+
90+
it('setCacheLookupPolicy(undefined) clears the policy so MSAL default applies', async () => {
91+
const configurator = new MsalConfigurator();
92+
configurator.setClient(createClient());
93+
configurator.setCacheLookupPolicy(undefined);
94+
95+
const config = await configurator.createConfigAsync(
96+
createConfigCallbackArgs(),
97+
createInitialConfig(),
98+
);
99+
100+
expect(config.cacheLookupPolicy).toBeUndefined();
101+
});
102+
103+
it('setCacheLookupPolicy overrides the default', async () => {
104+
const configurator = new MsalConfigurator();
105+
configurator.setClient(createClient());
106+
configurator.setCacheLookupPolicy(CacheLookupPolicy.Default);
107+
108+
const config = await configurator.createConfigAsync(
109+
createConfigCallbackArgs(),
110+
createInitialConfig(),
111+
);
112+
113+
expect(config.cacheLookupPolicy).toBe(CacheLookupPolicy.Default);
114+
});
115+
});
75116
});

0 commit comments

Comments
 (0)