@iloveagents/foundry-agent 0.1.3 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @iloveagents/foundry-agent
2
2
 
3
+ ## 0.1.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 07ace7e: fix(agent): canonical MSAL.js recovery — acquireTokenRedirect-first, orphaned-state cleanup, dedup
8
+
9
+ Closes the production "stuck on 401 forever" bug observed on andritz-dev after the 0.1.2 / 0.1.3 fixes. Diagnostic on the live tab showed `getAllAccounts() === []` despite localStorage holding 3 access tokens + 1 refresh token — the orphaned-state signature.
10
+
11
+ **Root cause:** The previous recovery code did `setActiveAccount(null) + clearCache({account}) + loginRedirect()`. When `loginRedirect()` silently failed to navigate (caught by our try/catch — popup blocker, browser policy, async race), the account record was already gone but the tokens lingered. Every subsequent API call hit `getAllAccounts() === []` and silently returned `null` — no Bearer header, endless 401s, only manual `localStorage.clear()` recovered.
12
+
13
+ **Canonical 2026 fix per Microsoft Learn `entra/msal/javascript/browser/errors`** + linked GitHub issues + msal-react samples:
14
+ 1. **`acquireTokenRedirect` first, not `loginRedirect`.** It's the documented primitive for refreshing a known account's tokens — preserves the account record across the navigation. `loginRedirect` is only the fallback when `acquireTokenRedirect` itself fails to navigate.
15
+ 2. **Never `clearCache` before redirect.** The redirect navigation IS the recovery signal; if it succeeds, MSAL handles state cleanup; if it fails, leave state intact for the next attempt rather than orphaning the cache.
16
+ 3. **Detect + repair orphaned state.** When `getAllAccounts() === []` but localStorage has MSAL token entries, nuke ALL `msal.*` keys (localStorage + sessionStorage) and force a fresh `loginRedirect`. Without this, the SPA renders authenticated UI but every API call goes anonymous.
17
+ 4. **Module-level recovery deduplication.** Multiple parallel API calls all hitting the recovery path simultaneously now share a single in-flight redirect promise instead of each issuing their own (which would cascade `interaction_in_progress` errors).
18
+
19
+ The previous `recoverFromHardAuthFailure` callback now uses the same primitive — single recovery path for both "MSAL silent failed" and "API rejected refreshed token".
20
+
21
+ 68/68 agent tests pass (was 65) — the new tests assert the no-clearCache invariant, the acquireTokenRedirect-first ordering, the loginRedirect fallback path, and the orphaned-state nuke + redirect. Pair: lastspace bump to ^0.1.4.
22
+
3
23
  ## 0.1.3
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -125,12 +125,13 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
125
125
  );
126
126
  });
127
127
 
128
- it("recoverFromHardAuthFailure clears cache + starts loginRedirect", async () => {
128
+ it("recoverFromHardAuthFailure starts acquireTokenRedirect (preserves account)", async () => {
129
129
  // When the fetch interceptor's force-refresh retry STILL gets 401,
130
- // the only correct UX is a fresh ``loginRedirect`` (server policy
131
- // drift, audience mismatch, etc.). Same recovery primitive used by
132
- // the silent path, but invoked from the resource-server signal
133
- // rather than an MSAL exception.
130
+ // server-side drift (audience / claims / conditional-access) means
131
+ // we need a fresh interactive auth. Same recovery primitive as the
132
+ // silent-failure path: ``acquireTokenRedirect`` first (preserves
133
+ // the account record), ``loginRedirect`` only as fallback. Cache
134
+ // is NOT cleared before redirect.
134
135
  const msal = mockMsal();
135
136
  const reason = new Error("API returned 401 after force-refresh retry");
136
137
  await expectInteractiveRecoveryError(
@@ -141,8 +142,8 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
141
142
  messageIncludes: ["interaction required"],
142
143
  },
143
144
  );
144
- expect(msal.clearCache).toHaveBeenCalled();
145
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
145
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
146
+ expect(msal.clearCache).not.toHaveBeenCalled();
146
147
  });
147
148
 
148
149
  it("returns null without redirecting when no account is cached", async () => {
@@ -155,7 +156,12 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
155
156
  expect(msal.loginRedirect).not.toHaveBeenCalled();
156
157
  });
157
158
 
158
- it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
159
+ it("uses acquireTokenRedirect on InteractionRequiredAuthError (preserves account)", async () => {
160
+ // Canonical 2026 MSAL.js pattern: ``acquireTokenRedirect`` is the
161
+ // documented primitive for refreshing a known account's tokens
162
+ // without a full ``loginRedirect``. Critically, the cache is NOT
163
+ // cleared before the redirect — that was the orphaned-state bug
164
+ // (account record gone + tokens lingering when redirect fails).
159
165
  const err = Object.assign(new Error("MFA required"), {
160
166
  name: "InteractionRequiredAuthError",
161
167
  errorCode: "interaction_required",
@@ -171,17 +177,41 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
171
177
  "MFA required",
172
178
  ],
173
179
  });
174
- expect(msal.clearCache).toHaveBeenCalledWith({
180
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
181
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
182
+ scopes: [config.apiScope],
175
183
  account: { username: "alice@example.com", localAccountId: "alice-oid" },
176
184
  });
177
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
178
- expect(msal.loginRedirect).toHaveBeenCalledWith({
179
- scopes: [config.apiScope],
185
+ // Cache MUST NOT be cleared before redirect — see
186
+ // AzureAD/microsoft-authentication-library-for-js#7551 and the
187
+ // orphaned-state production bug this fix exists to address.
188
+ expect(msal.clearCache).not.toHaveBeenCalled();
189
+ expect(msal.setActiveAccount).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("falls back to loginRedirect when acquireTokenRedirect returns without navigating", async () => {
193
+ // ``acquireTokenRedirect`` resolves before navigation in some
194
+ // environments (test runners, blocked redirects). When it settles
195
+ // without throwing AND without navigating, fall through to a full
196
+ // ``loginRedirect`` so the user still gets through the auth flow.
197
+ const err = Object.assign(new Error("interaction"), {
198
+ name: "InteractionRequiredAuthError",
199
+ errorCode: "interaction_required",
180
200
  });
181
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
201
+ const msal = mockMsal({
202
+ acquireTokenSilent: vi.fn().mockRejectedValue(err),
203
+ acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
204
+ });
205
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
206
+ code: "interaction_required",
207
+ cause: err,
208
+ messageIncludes: ["Authentication interaction required"],
209
+ });
210
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
211
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
182
212
  });
183
213
 
184
- it("clears stale cache and redirects on consent_required", async () => {
214
+ it("recovers on consent_required without clearing cache before redirect", async () => {
185
215
  const err = Object.assign(new Error("consent required"), {
186
216
  name: "InteractionRequiredAuthError",
187
217
  errorCode: "consent_required",
@@ -192,11 +222,11 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
192
222
  cause: err,
193
223
  messageIncludes: ["Authentication interaction required", "consent_required"],
194
224
  });
195
- expect(msal.clearCache).toHaveBeenCalledOnce();
196
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
225
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
226
+ expect(msal.clearCache).not.toHaveBeenCalled();
197
227
  });
198
228
 
199
- it("clears stale cache and redirects on monitor_window_timeout", async () => {
229
+ it("recovers on monitor_window_timeout without clearing cache before redirect", async () => {
200
230
  // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
201
231
  // → documented remedy includes "Invoke an interactive API".
202
232
  const err = Object.assign(new Error("monitor_window_timeout"), {
@@ -209,8 +239,8 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
209
239
  cause: err,
210
240
  messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
211
241
  });
212
- expect(msal.clearCache).toHaveBeenCalledOnce();
213
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
242
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
243
+ expect(msal.clearCache).not.toHaveBeenCalled();
214
244
  });
215
245
 
216
246
  it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
@@ -251,36 +281,148 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
251
281
  cause: err,
252
282
  messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
253
283
  });
254
- expect(msal.clearCache).toHaveBeenCalledOnce();
255
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
284
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
285
+ expect(msal.clearCache).not.toHaveBeenCalled();
256
286
  });
257
287
 
258
- it("preserves redirect failure details when interactive recovery cannot start", async () => {
288
+ it("preserves redirect failure details when both redirect attempts fail", async () => {
259
289
  const tokenErr = Object.assign(new Error("MFA required"), {
260
290
  name: "InteractionRequiredAuthError",
261
291
  errorCode: "interaction_required",
262
292
  });
263
- const redirectErr = Object.assign(new Error("redirect blocked"), {
293
+ const acquireErr = Object.assign(new Error("acquireToken redirect blocked"), {
264
294
  name: "BrowserAuthError",
265
295
  errorCode: "redirect_failed",
266
296
  });
297
+ const loginErr = Object.assign(new Error("login redirect blocked"), {
298
+ name: "BrowserAuthError",
299
+ errorCode: "redirect_failed_login",
300
+ });
267
301
  const msal = mockMsal({
268
302
  acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
269
- loginRedirect: vi.fn().mockRejectedValue(redirectErr),
303
+ acquireTokenRedirect: vi.fn().mockRejectedValue(acquireErr),
304
+ loginRedirect: vi.fn().mockRejectedValue(loginErr),
270
305
  });
271
306
 
272
307
  await expectInteractiveRecoveryError(store().getAccessToken(), {
273
- code: "redirect_failed",
274
- cause: redirectErr,
308
+ code: "redirect_failed_login",
309
+ cause: loginErr,
275
310
  messageIncludes: [
276
311
  "Authentication interaction required",
277
- "login redirect failed",
278
- "redirect_failed",
312
+ "redirect failed",
313
+ "redirect_failed_login",
279
314
  "Original token error",
280
315
  "interaction_required",
281
316
  ],
282
317
  });
283
- expect(msal.clearCache).toHaveBeenCalledOnce();
318
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
284
319
  expect(msal.loginRedirect).toHaveBeenCalledOnce();
320
+ // Even on full failure, cache is NOT touched — leaves state intact
321
+ // for the next attempt rather than creating an orphaned cache.
322
+ expect(msal.clearCache).not.toHaveBeenCalled();
323
+ });
324
+ });
325
+
326
+ describe("authStore — orphaned-state recovery", () => {
327
+ // Production bug: a previous recovery cleared the account record but
328
+ // its redirect didn't navigate (browser policy, popup blocker, etc.).
329
+ // localStorage is left with token entries but ``getAllAccounts()``
330
+ // returns []. Every subsequent API call gets null token + 401 forever
331
+ // until manual ``localStorage.clear()``.
332
+
333
+ const config = {
334
+ clientId: "test-client",
335
+ authority: "https://login.microsoftonline.com/test-tenant",
336
+ redirectUri: "http://localhost:8010",
337
+ apiScope: "api://test-api/access_as_user",
338
+ };
339
+
340
+ beforeEach(() => {
341
+ if (typeof localStorage !== "undefined") {
342
+ localStorage.clear();
343
+ }
344
+ });
345
+
346
+ afterEach(() => {
347
+ vi.restoreAllMocks();
348
+ if (typeof localStorage !== "undefined") {
349
+ localStorage.clear();
350
+ }
351
+ });
352
+
353
+ it("detects orphaned state, nukes localStorage, and triggers fresh login", async () => {
354
+ // Seed the orphan: token-shaped MSAL keys but no account record.
355
+ localStorage.setItem(
356
+ "msal.3|home-id.tenant|login.windows.net|accesstoken|client|tenant|scope|",
357
+ JSON.stringify({ id: "x", nonce: "n", data: "encrypted-blob", lastUpdatedAt: "0" }),
358
+ );
359
+ localStorage.setItem(
360
+ "msal.3|home-id.tenant|login.windows.net|refreshtoken|client|||",
361
+ JSON.stringify({ id: "x", nonce: "n", data: "encrypted-blob", lastUpdatedAt: "0" }),
362
+ );
363
+ localStorage.setItem(
364
+ "msal.client.active-account-filters",
365
+ JSON.stringify({ homeAccountId: "home-id.tenant" }),
366
+ );
367
+
368
+ const msal = {
369
+ initialize: vi.fn().mockResolvedValue(undefined),
370
+ handleRedirectPromise: vi.fn().mockResolvedValue(null),
371
+ clearCache: vi.fn().mockResolvedValue(undefined),
372
+ // CRITICAL: the orphaned-state — empty accounts despite localStorage entries.
373
+ getAllAccounts: vi.fn().mockReturnValue([]),
374
+ loginRedirect: vi.fn().mockResolvedValue(undefined),
375
+ logoutRedirect: vi.fn().mockResolvedValue(undefined),
376
+ setActiveAccount: vi.fn(),
377
+ acquireTokenSilent: vi.fn(),
378
+ acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
379
+ acquireTokenPopup: vi.fn(),
380
+ };
381
+ vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(msal);
382
+ vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
383
+
384
+ let thrown: unknown;
385
+ try {
386
+ await authStore.getState().getAccessToken();
387
+ } catch (err) {
388
+ thrown = err;
389
+ }
390
+ expect(thrown).toBeInstanceOf(AuthInteractionRequiredError);
391
+ expect((thrown as AuthInteractionRequiredError).message).toMatch(/orphaned/i);
392
+
393
+ // localStorage should have been nuked of all msal.* keys.
394
+ const remainingMsalKeys = Object.keys(localStorage).filter((k) =>
395
+ k.startsWith("msal."),
396
+ );
397
+ expect(remainingMsalKeys).toEqual([]);
398
+
399
+ // No account → recovery skips acquireTokenRedirect and goes
400
+ // straight to loginRedirect.
401
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
402
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
403
+ });
404
+
405
+ it("returns null on truly clean no-account state (no orphan)", async () => {
406
+ // No MSAL keys in localStorage and no accounts — user genuinely
407
+ // hasn't logged in yet. AuthGuard handles ``loginRedirect`` on
408
+ // its render; we don't double-redirect from here.
409
+ const msal = {
410
+ initialize: vi.fn().mockResolvedValue(undefined),
411
+ handleRedirectPromise: vi.fn().mockResolvedValue(null),
412
+ clearCache: vi.fn().mockResolvedValue(undefined),
413
+ getAllAccounts: vi.fn().mockReturnValue([]),
414
+ loginRedirect: vi.fn().mockResolvedValue(undefined),
415
+ logoutRedirect: vi.fn().mockResolvedValue(undefined),
416
+ setActiveAccount: vi.fn(),
417
+ acquireTokenSilent: vi.fn(),
418
+ acquireTokenRedirect: vi.fn(),
419
+ acquireTokenPopup: vi.fn(),
420
+ };
421
+ vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(msal);
422
+ vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
423
+
424
+ expect(await authStore.getState().getAccessToken()).toBeNull();
425
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
426
+ expect(msal.loginRedirect).not.toHaveBeenCalled();
285
427
  });
286
428
  });
@@ -38,42 +38,50 @@ interface AuthState {
38
38
  * Recovery semantics — follows the canonical MSAL.js pattern documented
39
39
  * at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
40
40
  *
41
- * Try acquireTokenSilent first, then use an interactive redirect when
42
- * MSAL reports that user interaction is required.
41
+ * 1. ``acquireTokenSilent`` first. Pass-through on success.
42
+ * 2. On a recoverable error (``InteractionRequiredAuthError``,
43
+ * ``monitor_window_timeout`` from blocked silent-SSO iframes,
44
+ * ``login_required`` / ``consent_required``), call
45
+ * :func:`startInteractiveRecovery` to navigate the user through a
46
+ * fresh interactive auth.
43
47
  *
44
- * Plus one extra recoverable case explicitly called out in those docs:
45
- * `BrowserAuthError: monitor_window_timeout`. Microsoft's recommendation
46
- * is to either backoff, fix the redirectUri page, or "invoke an
47
- * interactive API such as acquireTokenPopup or acquireTokenRedirect"
48
- * we take the last option, which matches third-party-iframe storage
49
- * blocking on Chrome 120+ / Edge / Safari (the silent iframe times
50
- * out because the cross-site cookie is partitioned).
48
+ * Recovery uses ``acquireTokenRedirect`` (NOT ``loginRedirect``) as
49
+ * the first step — that's the documented primitive for refreshing a
50
+ * known account's tokens without forcing a full re-login. Falls back
51
+ * to ``loginRedirect`` only when ``acquireTokenRedirect`` itself
52
+ * fails to navigate (browser policy, popup blocker, etc.).
51
53
  *
52
- * Before starting that redirect, clear the stale local account/token
53
- * cache. This avoids the broken half-authenticated state where the SPA
54
- * still renders an account but every API call goes out without a bearer
55
- * token after MSAL returned `interaction_required`.
54
+ * Critically: cache is NEVER cleared before the redirect. The
55
+ * previous version cleared the account record before calling
56
+ * ``loginRedirect``; when the redirect failed silently (browser
57
+ * blocking the navigation), the account record was gone but the
58
+ * tokens lingered, leaving every subsequent API call to find
59
+ * ``getAllAccounts() === []``, return null, and ship anonymously
60
+ * to the API — endless 401s, only manual ``localStorage.clear()``
61
+ * recovers. This bug is documented in
62
+ * AzureAD/microsoft-authentication-library-for-js#7551.
56
63
  *
57
- * Recoverable auth failures reject with `AuthInteractionRequiredError`
58
- * after recovery has been started. The error exposes `code` and `cause`
59
- * so callers can distinguish a normal interaction-required redirect from
60
- * a blocked/failed redirect attempt.
64
+ * Recoverable auth failures reject with ``AuthInteractionRequiredError``
65
+ * after recovery has been started. The error exposes ``code`` and
66
+ * ``cause`` so callers can distinguish a normal interaction-required
67
+ * redirect from a blocked/failed redirect attempt.
61
68
  *
62
- * Other `BrowserAuthError` codes (`interaction_in_progress`,
63
- * `hash_empty_error`, `hash_does_not_contain_known_properties`,
64
- * `block_iframe_reload`) are config / race-condition bugs that another
65
- * redirect won't fix — propagate them as null without navigating.
69
+ * Other ``BrowserAuthError`` codes (``hash_empty_error``,
70
+ * ``hash_does_not_contain_known_properties``, ``block_iframe_reload``)
71
+ * are config / race-condition bugs that another redirect won't fix —
72
+ * propagate them as null without navigating.
66
73
  *
67
- * The "no account in cache" case is handled at boot by `AuthGuard`,
68
- * which calls `loginRedirect` when `accounts.length === 0`. If we
69
- * still reach this method without an account, it's an unusual state;
70
- * return null and let the next AuthGuard render recover.
74
+ * The "no account in cache" case is handled by detecting ORPHANED
75
+ * STATE (no accounts but localStorage has MSAL token entries) and
76
+ * cleaning the half-corrupted cache before redirecting. Without this,
77
+ * the page renders authenticated UI but every API call goes anonymous.
71
78
  *
72
- * Errors are matched by `name` / `errorCode` rather than `instanceof`
73
- * because `@azure/msal-browser` is loaded via dynamic import; the
74
- * error class identity isn't shared across module boundaries.
79
+ * Errors are matched by ``name`` / ``errorCode`` rather than
80
+ * ``instanceof`` because ``@azure/msal-browser`` is loaded via
81
+ * dynamic import; the error class identity isn't shared across
82
+ * module boundaries.
75
83
  *
76
- * `forceRefresh: true` skips MSAL's local cache and goes back to the
84
+ * ``forceRefresh: true`` skips MSAL's local cache and goes back to the
77
85
  * token endpoint with the cached refresh token. Use it from the
78
86
  * fetch interceptor when a protected API returns 401 — the first
79
87
  * attempt may have used a stale cached access token (claims
@@ -86,13 +94,14 @@ interface AuthState {
86
94
  ) => Promise<string | null>;
87
95
 
88
96
  /**
89
- * Force interactive recovery: clear MSAL's cache for the active account
90
- * and start a ``loginRedirect``. Use this from the fetch interceptor
91
- * when even a force-refreshed access token still gets rejected by the
92
- * resource server (the silent path can't tell us "this is fundamentally
93
- * the wrong token" — it has to come from the protected API saying 401
94
- * after we already retried). Throws ``AuthInteractionRequiredError``
95
- * once the redirect has been kicked off so the caller can stop processing.
97
+ * Force interactive recovery: start an ``acquireTokenRedirect`` (or
98
+ * ``loginRedirect`` fallback). Use this from the fetch interceptor
99
+ * when even a force-refreshed access token still gets rejected by
100
+ * the resource server (the silent path can't tell us "this is
101
+ * fundamentally the wrong token" — it has to come from the protected
102
+ * API saying 401 after we already retried). Throws
103
+ * ``AuthInteractionRequiredError`` once the redirect has been kicked
104
+ * off so the caller can stop processing.
96
105
  */
97
106
  recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
98
107
  }
@@ -116,7 +125,14 @@ const RECOVERABLE_ERROR_CODES = new Set([
116
125
  * class name as a fallback when the error code isn't set. */
117
126
  const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
118
127
 
119
- let interactiveRecoveryStarted = false;
128
+ /** Module-level deduplication: when N parallel API calls all trip the
129
+ * recovery path simultaneously, the second-onwards must reuse the first
130
+ * one's promise rather than each issuing their own redirect. Without
131
+ * this, MSAL throws ``interaction_in_progress`` on N-1 of them and the
132
+ * cascading errors mask the actual recovery state. Mirrors the pattern
133
+ * in the msal-react ``useMsalAuthentication`` hook + the production
134
+ * patterns linked from Microsoft Learn's "Common errors" guide. */
135
+ let activeRecoveryPromise: Promise<never> | null = null;
120
136
 
121
137
  function isRecoverableAuthError(err: unknown): boolean {
122
138
  if (!err || typeof err !== "object") return false;
@@ -156,7 +172,7 @@ function createInteractionRequiredError(
156
172
 
157
173
  if (redirectError) {
158
174
  return new AuthInteractionRequiredError(
159
- `Authentication interaction required, but login redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
175
+ `Authentication interaction required, but redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
160
176
  {
161
177
  code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
162
178
  cause: redirectError,
@@ -173,31 +189,98 @@ function createInteractionRequiredError(
173
189
  );
174
190
  }
175
191
 
192
+ /** Storage probe: does the browser have orphaned MSAL token entries with
193
+ * no matching account record? That state is the "stuck on 401 forever"
194
+ * signature — getAllAccounts() returns [] but the SPA still has tokens
195
+ * lingering from a previous session that recovery half-cleaned. Returns
196
+ * the list of MSAL keys to nuke; empty array means cache is coherent. */
197
+ function findOrphanedMsalKeys(): string[] {
198
+ if (typeof window === "undefined" || !window.localStorage) return [];
199
+ const allKeys: string[] = [];
200
+ for (let i = 0; i < window.localStorage.length; i++) {
201
+ const k = window.localStorage.key(i);
202
+ if (k && k.startsWith("msal.")) allKeys.push(k);
203
+ }
204
+ const tokenKeys = allKeys.filter((k) =>
205
+ /\|(?:access|refresh|id)token\|/.test(k),
206
+ );
207
+ // No tokens = clean (or never logged in). Tokens AND empty
208
+ // getAllAccounts means orphaned — caller is the only one who knows
209
+ // the accounts state, so caller decides whether to nuke.
210
+ return tokenKeys.length > 0 ? allKeys : [];
211
+ }
212
+
213
+ function nukeMsalLocalStorage(): void {
214
+ if (typeof window === "undefined") return;
215
+ for (const store of [window.localStorage, window.sessionStorage]) {
216
+ if (!store) continue;
217
+ const toRemove: string[] = [];
218
+ for (let i = 0; i < store.length; i++) {
219
+ const k = store.key(i);
220
+ if (k && k.startsWith("msal.")) toRemove.push(k);
221
+ }
222
+ toRemove.forEach((k) => store.removeItem(k));
223
+ }
224
+ }
225
+
176
226
  async function startInteractiveRecovery(
177
227
  msal: MsalClientApplication,
178
- account: MsalAccountInfo,
228
+ account: MsalAccountInfo | null,
179
229
  scope: string,
180
230
  reason: unknown,
181
231
  ): Promise<never> {
182
- let redirectError: unknown;
183
- if (!interactiveRecoveryStarted) {
184
- interactiveRecoveryStarted = true;
232
+ // Deduplicate concurrent recovery attempts. Multiple in-flight API
233
+ // calls can simultaneously detect a stale token and trip recovery;
234
+ // without this, MSAL throws ``interaction_in_progress`` on every
235
+ // call after the first.
236
+ if (activeRecoveryPromise) {
237
+ return activeRecoveryPromise;
238
+ }
239
+
240
+ activeRecoveryPromise = (async () => {
241
+ let redirectError: unknown;
185
242
  authStore.setState({ user: null, isAuthenticated: false });
186
- msal.setActiveAccount(null);
187
- await msal.clearCache({ account }).catch(() => undefined);
243
+
244
+ // Step 1: ``acquireTokenRedirect`` the documented primitive for
245
+ // refreshing a known account's tokens without forcing full
246
+ // re-auth. Preserves the account record across the navigation,
247
+ // which is critical: ``loginRedirect`` + pre-redirect
248
+ // ``clearCache`` (the previous design) caused the orphaned-state
249
+ // bug we just hit in production (account record gone, tokens
250
+ // linger, every API call returns null token + 401 forever).
251
+ if (account) {
252
+ try {
253
+ await msal.acquireTokenRedirect({ scopes: [scope], account });
254
+ // If we reach here without navigating, MSAL settled the promise
255
+ // before the browser navigation kicked in (test environments,
256
+ // blocked redirects, etc.). Fall through to loginRedirect.
257
+ } catch (err) {
258
+ redirectError = err;
259
+ }
260
+ }
261
+
262
+ // Step 2: full ``loginRedirect`` fallback. Used when:
263
+ // * No account was passed (orphaned-state caller)
264
+ // * acquireTokenRedirect threw a non-navigation error
265
+ // * acquireTokenRedirect settled without navigating
266
+ // Still NO ``clearCache`` before this — let MSAL handle its own
267
+ // state across the navigation. Only nuke localStorage if BOTH
268
+ // redirect attempts fail (the orphaned-state caller does this
269
+ // up-front because their state is already corrupt).
188
270
  try {
189
271
  await msal.loginRedirect({ scopes: [scope] });
190
272
  } catch (err) {
191
- // The redirect is expected to navigate away; if it settles by throwing,
192
- // keep that failure attached so logs/UI can show what blocked recovery.
193
273
  redirectError = err;
194
- } finally {
195
- // In tests or blocked-popup environments the promise can settle without
196
- // navigation. Allow a later user action/retry to start recovery again.
197
- interactiveRecoveryStarted = false;
198
274
  }
275
+
276
+ throw createInteractionRequiredError(reason, redirectError);
277
+ })();
278
+
279
+ try {
280
+ return await activeRecoveryPromise;
281
+ } finally {
282
+ activeRecoveryPromise = null;
199
283
  }
200
- throw createInteractionRequiredError(reason, redirectError);
201
284
  }
202
285
 
203
286
  export const authStore = createStore<AuthState>((set) => ({
@@ -220,14 +303,33 @@ export const authStore = createStore<AuthState>((set) => ({
220
303
  if (!msal || !config) return null;
221
304
 
222
305
  const accounts = msal.getAllAccounts();
306
+ const scope = config.apiScope;
307
+
308
+ // Orphaned-state recovery: ``getAllAccounts()`` returns [] BUT
309
+ // localStorage still holds MSAL token entries. That happens when a
310
+ // previous recovery attempt cleared the account record but its
311
+ // redirect didn't navigate (browser policy, popup blocker, async
312
+ // race). The SPA renders "logged in" but every API call goes
313
+ // anonymous. Nuke the orphans + force a fresh redirect so the
314
+ // user gets unstuck without manually clearing browser storage.
223
315
  if (accounts.length === 0) {
224
- // No cached account — `AuthGuard` will call `loginRedirect` on
316
+ const orphans = findOrphanedMsalKeys();
317
+ if (orphans.length > 0) {
318
+ nukeMsalLocalStorage();
319
+ return startInteractiveRecovery(
320
+ msal,
321
+ null,
322
+ scope,
323
+ new Error(
324
+ `Detected orphaned MSAL state (${orphans.length} cache entries with no account record); cleaned up + redirecting`,
325
+ ),
326
+ );
327
+ }
328
+ // Truly logged out — ``AuthGuard`` will call ``loginRedirect`` on
225
329
  // its next render. Don't double-redirect from here.
226
330
  return null;
227
331
  }
228
332
 
229
- // Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
230
- const scope = config.apiScope;
231
333
  const forceRefresh = options?.forceRefresh === true;
232
334
 
233
335
  try {
@@ -246,12 +348,12 @@ export const authStore = createStore<AuthState>((set) => ({
246
348
  },
247
349
 
248
350
  recoverFromHardAuthFailure: async (reason) => {
249
- // The silent path produced a token but the resource server rejected
250
- // it (audience drift, conditional-access re-eval, claims challenge,
251
- // tenant-policy change, etc.). The cached refresh token can't help
252
- // a token minted from it would carry the same identity claims
253
- // so the only correct UX is a full ``loginRedirect`` that produces
254
- // a fresh token bound to the current server policy.
351
+ // Fetch interceptor's escape hatch: silent path produced a token
352
+ // but the resource server rejected it (audience drift, conditional-
353
+ // access re-eval, claims challenge, tenant-policy change, etc.).
354
+ // Same recovery primitive as the silent-failure path
355
+ // acquireTokenRedirect loginRedirect invoked from the
356
+ // resource-server signal rather than an MSAL exception.
255
357
  const msal = getMsalInstance();
256
358
  const config = getMsalConfig();
257
359
  if (!msal || !config) {
@@ -261,12 +363,11 @@ export const authStore = createStore<AuthState>((set) => ({
261
363
  );
262
364
  }
263
365
  const accounts = msal.getAllAccounts();
264
- if (accounts.length === 0) {
265
- throw new AuthInteractionRequiredError(
266
- "Authentication interaction required (no cached account).",
267
- { cause: reason },
268
- );
269
- }
270
- return startInteractiveRecovery(msal, accounts[0], config.apiScope, reason);
366
+ return startInteractiveRecovery(
367
+ msal,
368
+ accounts[0] ?? null,
369
+ config.apiScope,
370
+ reason,
371
+ );
271
372
  },
272
373
  }));