@iloveagents/foundry-agent 0.1.2 → 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 +34 -0
- package/package.json +1 -1
- package/src/__tests__/auth-store.test.ts +184 -22
- package/src/__tests__/service-fetch.test.ts +77 -3
- package/src/__tests__/token-fetch.test.ts +16 -3
- package/src/client/service-fetch.ts +41 -1
- package/src/msal/auth-store.ts +184 -47
- package/src/msal/token-fetch.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.1.3
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- d9b0460: fix(agent): escalate to loginRedirect after second 401
|
|
28
|
+
|
|
29
|
+
Follow-up to the auth-stale-token-401-retry fix in 0.1.2. The first version retried with `forceRefresh: true` and bounced to `loginRedirect` only when MSAL itself reported `InteractionRequiredAuthError`. But there's a more common production failure mode the silent path can't recover: the resource server rejects the **freshly-refreshed** token too — server-side policy drift, audience mismatch, conditional-access re-evaluation, claims challenge, tenant-policy change. MSAL has no way to know about any of this; it produces a clean refreshed token and calls it a day. The user is left in an endless silent 401 loop because nothing kicks them to `loginRedirect`.
|
|
30
|
+
|
|
31
|
+
Adds `recoverFromHardAuthFailure(reason)` on `authStore`. The fetch interceptor calls it when a SECOND consecutive 401 fires (i.e. even the force-refresh retry didn't help). It clears the MSAL cache and starts `loginRedirect` so a brand-new session mints a token bound to current server policy.
|
|
32
|
+
|
|
33
|
+
`createServiceFetch` now accepts an optional `recoverFromHardAuthFailure` callback. The default consumer (`@iloveagents/foundry-web-shell`) wires it to the new method on `authStore`. Same pattern in `tokenFetch`.
|
|
34
|
+
|
|
35
|
+
Pair: `lastspace#TBD` (forwards the new option through `spacesFetch`).
|
|
36
|
+
|
|
3
37
|
## 0.1.2
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -125,6 +125,27 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
125
125
|
);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
it("recoverFromHardAuthFailure starts acquireTokenRedirect (preserves account)", async () => {
|
|
129
|
+
// When the fetch interceptor's force-refresh retry STILL gets 401,
|
|
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.
|
|
135
|
+
const msal = mockMsal();
|
|
136
|
+
const reason = new Error("API returned 401 after force-refresh retry");
|
|
137
|
+
await expectInteractiveRecoveryError(
|
|
138
|
+
store().recoverFromHardAuthFailure(reason),
|
|
139
|
+
{
|
|
140
|
+
code: undefined,
|
|
141
|
+
cause: reason,
|
|
142
|
+
messageIncludes: ["interaction required"],
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
146
|
+
expect(msal.clearCache).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
128
149
|
it("returns null without redirecting when no account is cached", async () => {
|
|
129
150
|
// AuthGuard calls loginRedirect when accounts.length === 0; double-
|
|
130
151
|
// redirecting from getAccessToken would race with AuthGuard.
|
|
@@ -135,7 +156,12 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
135
156
|
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
136
157
|
});
|
|
137
158
|
|
|
138
|
-
it("
|
|
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).
|
|
139
165
|
const err = Object.assign(new Error("MFA required"), {
|
|
140
166
|
name: "InteractionRequiredAuthError",
|
|
141
167
|
errorCode: "interaction_required",
|
|
@@ -151,17 +177,41 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
151
177
|
"MFA required",
|
|
152
178
|
],
|
|
153
179
|
});
|
|
154
|
-
expect(msal.
|
|
180
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
181
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
|
|
182
|
+
scopes: [config.apiScope],
|
|
155
183
|
account: { username: "alice@example.com", localAccountId: "alice-oid" },
|
|
156
184
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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",
|
|
160
200
|
});
|
|
161
|
-
|
|
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();
|
|
162
212
|
});
|
|
163
213
|
|
|
164
|
-
it("
|
|
214
|
+
it("recovers on consent_required without clearing cache before redirect", async () => {
|
|
165
215
|
const err = Object.assign(new Error("consent required"), {
|
|
166
216
|
name: "InteractionRequiredAuthError",
|
|
167
217
|
errorCode: "consent_required",
|
|
@@ -172,11 +222,11 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
172
222
|
cause: err,
|
|
173
223
|
messageIncludes: ["Authentication interaction required", "consent_required"],
|
|
174
224
|
});
|
|
175
|
-
expect(msal.
|
|
176
|
-
expect(msal.
|
|
225
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
226
|
+
expect(msal.clearCache).not.toHaveBeenCalled();
|
|
177
227
|
});
|
|
178
228
|
|
|
179
|
-
it("
|
|
229
|
+
it("recovers on monitor_window_timeout without clearing cache before redirect", async () => {
|
|
180
230
|
// Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
|
|
181
231
|
// → documented remedy includes "Invoke an interactive API".
|
|
182
232
|
const err = Object.assign(new Error("monitor_window_timeout"), {
|
|
@@ -189,8 +239,8 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
189
239
|
cause: err,
|
|
190
240
|
messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
|
|
191
241
|
});
|
|
192
|
-
expect(msal.
|
|
193
|
-
expect(msal.
|
|
242
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
243
|
+
expect(msal.clearCache).not.toHaveBeenCalled();
|
|
194
244
|
});
|
|
195
245
|
|
|
196
246
|
it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
|
|
@@ -231,36 +281,148 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
231
281
|
cause: err,
|
|
232
282
|
messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
|
|
233
283
|
});
|
|
234
|
-
expect(msal.
|
|
235
|
-
expect(msal.
|
|
284
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
285
|
+
expect(msal.clearCache).not.toHaveBeenCalled();
|
|
236
286
|
});
|
|
237
287
|
|
|
238
|
-
it("preserves redirect failure details when
|
|
288
|
+
it("preserves redirect failure details when both redirect attempts fail", async () => {
|
|
239
289
|
const tokenErr = Object.assign(new Error("MFA required"), {
|
|
240
290
|
name: "InteractionRequiredAuthError",
|
|
241
291
|
errorCode: "interaction_required",
|
|
242
292
|
});
|
|
243
|
-
const
|
|
293
|
+
const acquireErr = Object.assign(new Error("acquireToken redirect blocked"), {
|
|
244
294
|
name: "BrowserAuthError",
|
|
245
295
|
errorCode: "redirect_failed",
|
|
246
296
|
});
|
|
297
|
+
const loginErr = Object.assign(new Error("login redirect blocked"), {
|
|
298
|
+
name: "BrowserAuthError",
|
|
299
|
+
errorCode: "redirect_failed_login",
|
|
300
|
+
});
|
|
247
301
|
const msal = mockMsal({
|
|
248
302
|
acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
|
|
249
|
-
|
|
303
|
+
acquireTokenRedirect: vi.fn().mockRejectedValue(acquireErr),
|
|
304
|
+
loginRedirect: vi.fn().mockRejectedValue(loginErr),
|
|
250
305
|
});
|
|
251
306
|
|
|
252
307
|
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
253
|
-
code: "
|
|
254
|
-
cause:
|
|
308
|
+
code: "redirect_failed_login",
|
|
309
|
+
cause: loginErr,
|
|
255
310
|
messageIncludes: [
|
|
256
311
|
"Authentication interaction required",
|
|
257
|
-
"
|
|
258
|
-
"
|
|
312
|
+
"redirect failed",
|
|
313
|
+
"redirect_failed_login",
|
|
259
314
|
"Original token error",
|
|
260
315
|
"interaction_required",
|
|
261
316
|
],
|
|
262
317
|
});
|
|
263
|
-
expect(msal.
|
|
318
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
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();
|
|
264
402
|
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
265
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();
|
|
427
|
+
});
|
|
266
428
|
});
|
|
@@ -284,9 +284,10 @@ describe("createServiceFetch", () => {
|
|
|
284
284
|
});
|
|
285
285
|
|
|
286
286
|
it("only retries once even if the second attempt also returns 401", async () => {
|
|
287
|
-
// Hard auth failure (refresh token expired).
|
|
288
|
-
//
|
|
289
|
-
//
|
|
287
|
+
// Hard auth failure (refresh token expired). With no
|
|
288
|
+
// ``recoverFromHardAuthFailure`` configured, the second 401
|
|
289
|
+
// propagates to the caller. (Tests cover the recovery path
|
|
290
|
+
// separately below.)
|
|
290
291
|
fetchSpy.mockReset();
|
|
291
292
|
fetchSpy
|
|
292
293
|
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
@@ -300,5 +301,78 @@ describe("createServiceFetch", () => {
|
|
|
300
301
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
301
302
|
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
302
303
|
});
|
|
304
|
+
|
|
305
|
+
it("kicks off interactive recovery after a second 401", async () => {
|
|
306
|
+
// Server-policy drift: even the force-refreshed token is rejected.
|
|
307
|
+
// The fetch interceptor must escalate to loginRedirect via the
|
|
308
|
+
// recoverFromHardAuthFailure callback — without it the user is
|
|
309
|
+
// stuck in a silent 401 loop forever (the bug this fix exists for).
|
|
310
|
+
fetchSpy.mockReset();
|
|
311
|
+
fetchSpy
|
|
312
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
313
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
314
|
+
const acquireToken = vi
|
|
315
|
+
.fn()
|
|
316
|
+
.mockResolvedValueOnce("stale-token")
|
|
317
|
+
.mockResolvedValueOnce("force-refreshed-but-still-bad-token");
|
|
318
|
+
const recoverFromHardAuthFailure = vi
|
|
319
|
+
.fn()
|
|
320
|
+
.mockRejectedValue(new Error("loginRedirect in flight"));
|
|
321
|
+
const serviceFetch = createServiceFetch({
|
|
322
|
+
acquireToken,
|
|
323
|
+
recoverFromHardAuthFailure,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow(
|
|
327
|
+
"loginRedirect in flight",
|
|
328
|
+
);
|
|
329
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
330
|
+
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
331
|
+
expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
|
|
332
|
+
// Reason carries enough context to log/alert without leaking tokens.
|
|
333
|
+
const reason = recoverFromHardAuthFailure.mock.calls[0]![0];
|
|
334
|
+
expect(reason).toBeInstanceOf(Error);
|
|
335
|
+
expect((reason as Error).message).toMatch(/401/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("does NOT call recoverFromHardAuthFailure on first-attempt success", async () => {
|
|
339
|
+
// Sanity: when the first request succeeds, recovery callback must
|
|
340
|
+
// never fire.
|
|
341
|
+
fetchSpy.mockReset();
|
|
342
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
343
|
+
const acquireToken = vi.fn().mockResolvedValue("good-token");
|
|
344
|
+
const recoverFromHardAuthFailure = vi.fn();
|
|
345
|
+
const serviceFetch = createServiceFetch({
|
|
346
|
+
acquireToken,
|
|
347
|
+
recoverFromHardAuthFailure,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("does NOT call recoverFromHardAuthFailure when the retry recovers", async () => {
|
|
356
|
+
// Standard recovery — first 401, retry with forceRefresh succeeds.
|
|
357
|
+
// Recovery callback must not fire (would needlessly bounce the user
|
|
358
|
+
// through loginRedirect).
|
|
359
|
+
fetchSpy.mockReset();
|
|
360
|
+
fetchSpy
|
|
361
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
362
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
363
|
+
const acquireToken = vi
|
|
364
|
+
.fn()
|
|
365
|
+
.mockResolvedValueOnce("stale")
|
|
366
|
+
.mockResolvedValueOnce("fresh");
|
|
367
|
+
const recoverFromHardAuthFailure = vi.fn();
|
|
368
|
+
const serviceFetch = createServiceFetch({
|
|
369
|
+
acquireToken,
|
|
370
|
+
recoverFromHardAuthFailure,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
374
|
+
expect(res.status).toBe(200);
|
|
375
|
+
expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
303
377
|
});
|
|
304
378
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const getAccessToken = vi.fn();
|
|
4
|
+
const recoverFromHardAuthFailure = vi.fn();
|
|
4
5
|
|
|
5
6
|
vi.mock("../msal/auth-store.ts", () => ({
|
|
6
7
|
authStore: {
|
|
7
8
|
getState: () => ({
|
|
8
9
|
getAccessToken,
|
|
10
|
+
recoverFromHardAuthFailure,
|
|
9
11
|
}),
|
|
10
12
|
},
|
|
11
13
|
}));
|
|
@@ -15,6 +17,7 @@ import { tokenFetch } from "../msal/token-fetch.ts";
|
|
|
15
17
|
describe("tokenFetch", () => {
|
|
16
18
|
beforeEach(() => {
|
|
17
19
|
getAccessToken.mockReset();
|
|
20
|
+
recoverFromHardAuthFailure.mockReset();
|
|
18
21
|
vi.restoreAllMocks();
|
|
19
22
|
});
|
|
20
23
|
|
|
@@ -105,17 +108,27 @@ describe("tokenFetch", () => {
|
|
|
105
108
|
expect(getAccessToken).toHaveBeenCalledTimes(1);
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
it("
|
|
111
|
+
it("escalates to interactive recovery on a hard 401-then-401 path", async () => {
|
|
112
|
+
// Server-policy drift: even the force-refreshed token is rejected.
|
|
113
|
+
// The fetch interceptor must call ``recoverFromHardAuthFailure`` so
|
|
114
|
+
// the auth layer can kick off ``loginRedirect``. The recovery
|
|
115
|
+
// throws once the redirect is in flight; the throw stops the
|
|
116
|
+
// calling pipeline.
|
|
109
117
|
getAccessToken.mockResolvedValue("token");
|
|
118
|
+
recoverFromHardAuthFailure.mockRejectedValue(
|
|
119
|
+
new Error("loginRedirect in flight"),
|
|
120
|
+
);
|
|
110
121
|
const fetchSpy = vi
|
|
111
122
|
.spyOn(globalThis, "fetch")
|
|
112
123
|
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
113
124
|
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
await expect(tokenFetch("https://example.com/api/items")).rejects.toThrow(
|
|
127
|
+
"loginRedirect in flight",
|
|
128
|
+
);
|
|
116
129
|
|
|
117
|
-
expect(res.status).toBe(401);
|
|
118
130
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
119
131
|
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
132
|
+
expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
|
|
120
133
|
});
|
|
121
134
|
});
|
|
@@ -26,6 +26,27 @@ export interface ServiceFetchOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
acquireToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Force interactive recovery (e.g. ``loginRedirect``) when even a
|
|
31
|
+
* force-refreshed access token gets rejected by the resource server.
|
|
32
|
+
* The fetch interceptor calls this after a SECOND consecutive 401 —
|
|
33
|
+
* at that point we know the silent refresh produced a token the
|
|
34
|
+
* server still won't accept (audience drift, conditional-access
|
|
35
|
+
* re-eval, tenant-policy change), and the only correct UX is to
|
|
36
|
+
* mint a fresh session.
|
|
37
|
+
*
|
|
38
|
+
* Implementations should clear cached auth state and start a redirect
|
|
39
|
+
* to the IdP. They MUST throw rather than return so the fetch caller
|
|
40
|
+
* can stop processing the in-flight request — when this resolves
|
|
41
|
+
* normally the redirect is in flight and the page is about to
|
|
42
|
+
* navigate away.
|
|
43
|
+
*
|
|
44
|
+
* Optional: when omitted, the fetch interceptor lets the second 401
|
|
45
|
+
* propagate as-is. Hosts without an interactive recovery path (e.g.
|
|
46
|
+
* tests, embedded apps) should leave it unset.
|
|
47
|
+
*/
|
|
48
|
+
recoverFromHardAuthFailure?: (reason: unknown) => Promise<never>;
|
|
49
|
+
|
|
29
50
|
/**
|
|
30
51
|
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
31
52
|
* Empty / undefined leaves the URL untouched (Vite proxy handles routing
|
|
@@ -152,6 +173,25 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
|
|
|
152
173
|
// Drain the failed response body — letting it sit unread keeps the
|
|
153
174
|
// underlying connection occupied on some runtimes.
|
|
154
175
|
response.body?.cancel().catch(() => undefined);
|
|
155
|
-
|
|
176
|
+
const retried = await dispatch(true);
|
|
177
|
+
|
|
178
|
+
// Hard auth failure: even the force-refreshed token got rejected.
|
|
179
|
+
// The silent path can't recover this — a token minted from the
|
|
180
|
+
// cached refresh token carries the same identity claims as the
|
|
181
|
+
// original, so it'll keep getting rejected for the same reason
|
|
182
|
+
// (audience drift, conditional-access re-eval, claims challenge,
|
|
183
|
+
// tenant-policy change). Only a brand-new ``loginRedirect`` produces
|
|
184
|
+
// a token bound to the current server policy. Without this branch
|
|
185
|
+
// the user sees an endless 401 loop until they manually log out.
|
|
186
|
+
if (retried.status === 401 && options.recoverFromHardAuthFailure) {
|
|
187
|
+
retried.body?.cancel().catch(() => undefined);
|
|
188
|
+
// ``recoverFromHardAuthFailure`` throws once the redirect is in
|
|
189
|
+
// flight; the throw stops the calling pipeline so we don't
|
|
190
|
+
// return a stale 401 the caller might handle as a real failure.
|
|
191
|
+
await options.recoverFromHardAuthFailure(
|
|
192
|
+
new Error("API returned 401 after force-refresh retry"),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return retried;
|
|
156
196
|
};
|
|
157
197
|
}
|
package/src/msal/auth-store.ts
CHANGED
|
@@ -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
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
58
|
-
* after recovery has been started. The error exposes
|
|
59
|
-
* so callers can distinguish a normal interaction-required
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
73
|
-
* because
|
|
74
|
-
* error class identity isn't shared across
|
|
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
|
-
*
|
|
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
|
|
@@ -84,6 +92,18 @@ interface AuthState {
|
|
|
84
92
|
audience?: "api" | "spaces",
|
|
85
93
|
options?: { forceRefresh?: boolean },
|
|
86
94
|
) => Promise<string | null>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
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.
|
|
105
|
+
*/
|
|
106
|
+
recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
@@ -105,7 +125,14 @@ const RECOVERABLE_ERROR_CODES = new Set([
|
|
|
105
125
|
* class name as a fallback when the error code isn't set. */
|
|
106
126
|
const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
|
|
107
127
|
|
|
108
|
-
|
|
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;
|
|
109
136
|
|
|
110
137
|
function isRecoverableAuthError(err: unknown): boolean {
|
|
111
138
|
if (!err || typeof err !== "object") return false;
|
|
@@ -145,7 +172,7 @@ function createInteractionRequiredError(
|
|
|
145
172
|
|
|
146
173
|
if (redirectError) {
|
|
147
174
|
return new AuthInteractionRequiredError(
|
|
148
|
-
`Authentication interaction required, but
|
|
175
|
+
`Authentication interaction required, but redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
|
|
149
176
|
{
|
|
150
177
|
code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
|
|
151
178
|
cause: redirectError,
|
|
@@ -162,31 +189,98 @@ function createInteractionRequiredError(
|
|
|
162
189
|
);
|
|
163
190
|
}
|
|
164
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
|
+
|
|
165
226
|
async function startInteractiveRecovery(
|
|
166
227
|
msal: MsalClientApplication,
|
|
167
|
-
account: MsalAccountInfo,
|
|
228
|
+
account: MsalAccountInfo | null,
|
|
168
229
|
scope: string,
|
|
169
230
|
reason: unknown,
|
|
170
231
|
): Promise<never> {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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;
|
|
174
242
|
authStore.setState({ user: null, isAuthenticated: false });
|
|
175
|
-
|
|
176
|
-
|
|
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).
|
|
177
270
|
try {
|
|
178
271
|
await msal.loginRedirect({ scopes: [scope] });
|
|
179
272
|
} catch (err) {
|
|
180
|
-
// The redirect is expected to navigate away; if it settles by throwing,
|
|
181
|
-
// keep that failure attached so logs/UI can show what blocked recovery.
|
|
182
273
|
redirectError = err;
|
|
183
|
-
} finally {
|
|
184
|
-
// In tests or blocked-popup environments the promise can settle without
|
|
185
|
-
// navigation. Allow a later user action/retry to start recovery again.
|
|
186
|
-
interactiveRecoveryStarted = false;
|
|
187
274
|
}
|
|
275
|
+
|
|
276
|
+
throw createInteractionRequiredError(reason, redirectError);
|
|
277
|
+
})();
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
return await activeRecoveryPromise;
|
|
281
|
+
} finally {
|
|
282
|
+
activeRecoveryPromise = null;
|
|
188
283
|
}
|
|
189
|
-
throw createInteractionRequiredError(reason, redirectError);
|
|
190
284
|
}
|
|
191
285
|
|
|
192
286
|
export const authStore = createStore<AuthState>((set) => ({
|
|
@@ -209,14 +303,33 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
209
303
|
if (!msal || !config) return null;
|
|
210
304
|
|
|
211
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.
|
|
212
315
|
if (accounts.length === 0) {
|
|
213
|
-
|
|
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
|
|
214
329
|
// its next render. Don't double-redirect from here.
|
|
215
330
|
return null;
|
|
216
331
|
}
|
|
217
332
|
|
|
218
|
-
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
|
|
219
|
-
const scope = config.apiScope;
|
|
220
333
|
const forceRefresh = options?.forceRefresh === true;
|
|
221
334
|
|
|
222
335
|
try {
|
|
@@ -233,4 +346,28 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
233
346
|
return null;
|
|
234
347
|
}
|
|
235
348
|
},
|
|
349
|
+
|
|
350
|
+
recoverFromHardAuthFailure: async (reason) => {
|
|
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.
|
|
357
|
+
const msal = getMsalInstance();
|
|
358
|
+
const config = getMsalConfig();
|
|
359
|
+
if (!msal || !config) {
|
|
360
|
+
throw new AuthInteractionRequiredError(
|
|
361
|
+
"Authentication interaction required (no MSAL instance configured).",
|
|
362
|
+
{ cause: reason },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const accounts = msal.getAllAccounts();
|
|
366
|
+
return startInteractiveRecovery(
|
|
367
|
+
msal,
|
|
368
|
+
accounts[0] ?? null,
|
|
369
|
+
config.apiScope,
|
|
370
|
+
reason,
|
|
371
|
+
);
|
|
372
|
+
},
|
|
236
373
|
}));
|
package/src/msal/token-fetch.ts
CHANGED
|
@@ -48,5 +48,21 @@ export async function tokenFetch(
|
|
|
48
48
|
return response;
|
|
49
49
|
}
|
|
50
50
|
response.body?.cancel().catch(() => undefined);
|
|
51
|
-
|
|
51
|
+
const retried = await dispatch(true);
|
|
52
|
+
if (retried.status === 401) {
|
|
53
|
+
// Second 401 after a force-refresh retry — the silent token path
|
|
54
|
+
// can't recover this (refresh-token grant produces the same
|
|
55
|
+
// identity claims that just got rejected). Kick off interactive
|
|
56
|
+
// recovery so a fresh ``loginRedirect`` mints a token bound to
|
|
57
|
+
// current server policy. ``recoverFromHardAuthFailure`` throws
|
|
58
|
+
// once the redirect is in flight so we don't return a stale 401
|
|
59
|
+
// the caller might handle as a real failure.
|
|
60
|
+
retried.body?.cancel().catch(() => undefined);
|
|
61
|
+
await authStore
|
|
62
|
+
.getState()
|
|
63
|
+
.recoverFromHardAuthFailure(
|
|
64
|
+
new Error("API returned 401 after force-refresh retry"),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return retried;
|
|
52
68
|
}
|