@iloveagents/foundry-agent 0.1.4 → 0.1.5

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,7 @@
1
1
  # @iloveagents/foundry-agent
2
2
 
3
+ ## 0.1.5
4
+
3
5
  ## 0.1.4
4
6
 
5
7
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -125,13 +125,20 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
125
125
  );
126
126
  });
127
127
 
128
- it("recoverFromHardAuthFailure starts acquireTokenRedirect (preserves account)", async () => {
128
+ it("recoverFromHardAuthFailure evicts bad account + redirects with prompt:'login'", async () => {
129
129
  // When the fetch interceptor's force-refresh retry STILL gets 401,
130
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.
131
+ // we need a fresh interactive auth. Two non-negotiable behaviours
132
+ // that prevent the "silent redirect loop":
133
+ // 1. ``clearCache({ account })`` BEFORE redirect evicts the
134
+ // bad refresh-token / claims so MSAL doesn't reuse the same
135
+ // poisoned material after the redirect lands.
136
+ // 2. ``prompt: "login"`` on BOTH redirect primitives — forces
137
+ // Entra to re-prompt for credentials instead of silently
138
+ // returning the SAME stale token from its server-side SSO
139
+ // session.
140
+ // See GitHub #6840 and the Microsoft Identity Platform "claims
141
+ // challenge" guidance.
135
142
  const msal = mockMsal();
136
143
  const reason = new Error("API returned 401 after force-refresh retry");
137
144
  await expectInteractiveRecoveryError(
@@ -142,8 +149,41 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
142
149
  messageIncludes: ["interaction required"],
143
150
  },
144
151
  );
152
+ expect(msal.clearCache).toHaveBeenCalledOnce();
153
+ expect(msal.clearCache).toHaveBeenCalledWith({
154
+ account: { username: "alice@example.com", localAccountId: "alice-oid" },
155
+ });
145
156
  expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
146
- expect(msal.clearCache).not.toHaveBeenCalled();
157
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
158
+ expect.objectContaining({ prompt: "login" }),
159
+ );
160
+ });
161
+
162
+ it("forwards a claims-challenge payload from the failure reason to MSAL", async () => {
163
+ // Continuous Access Evaluation / Conditional-Access challenge:
164
+ // the fetch interceptor extracted ``claims`` from the resource
165
+ // server's ``WWW-Authenticate`` header and stamped it onto the
166
+ // recovery reason. The redirect must carry it through so Entra
167
+ // mints a token that explicitly satisfies the challenge.
168
+ const msal = mockMsal();
169
+ const reason = Object.assign(
170
+ new Error("API returned 401 after force-refresh retry"),
171
+ { claims: "eyJjbGFpbXMiOiIuLi4ifQ" },
172
+ );
173
+ await expectInteractiveRecoveryError(
174
+ store().recoverFromHardAuthFailure(reason),
175
+ {
176
+ code: undefined,
177
+ cause: reason,
178
+ messageIncludes: ["interaction required"],
179
+ },
180
+ );
181
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
182
+ expect.objectContaining({
183
+ claims: "eyJjbGFpbXMiOiIuLi4ifQ",
184
+ prompt: "login",
185
+ }),
186
+ );
147
187
  });
148
188
 
149
189
  it("returns null without redirecting when no account is cached", async () => {
@@ -156,12 +196,17 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
156
196
  expect(msal.loginRedirect).not.toHaveBeenCalled();
157
197
  });
158
198
 
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).
199
+ it("uses acquireTokenRedirect on InteractionRequiredAuthError, evicting the bad account first", async () => {
200
+ // Canonical 2026 MSAL.js recovery pattern (per Microsoft Identity
201
+ // Platform docs + GitHub #6840):
202
+ // 1. ``clearCache({ account })`` surgical eviction of the bad
203
+ // token + claims, BEFORE redirect. The earlier
204
+ // "no-clearCache-before-redirect" rule was about the broad
205
+ // ``clearCache()`` form (which produces the orphaned-state
206
+ // bug); the per-account form is the documented fix.
207
+ // 2. ``acquireTokenRedirect`` with ``prompt: "login"`` — forces
208
+ // re-prompt instead of letting Entra silently reuse the SSO
209
+ // session.
165
210
  const err = Object.assign(new Error("MFA required"), {
166
211
  name: "InteractionRequiredAuthError",
167
212
  errorCode: "interaction_required",
@@ -177,15 +222,16 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
177
222
  "MFA required",
178
223
  ],
179
224
  });
225
+ expect(msal.clearCache).toHaveBeenCalledOnce();
226
+ expect(msal.clearCache).toHaveBeenCalledWith({
227
+ account: { username: "alice@example.com", localAccountId: "alice-oid" },
228
+ });
180
229
  expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
181
230
  expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
182
231
  scopes: [config.apiScope],
183
232
  account: { username: "alice@example.com", localAccountId: "alice-oid" },
233
+ prompt: "login",
184
234
  });
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
235
  expect(msal.setActiveAccount).not.toHaveBeenCalled();
190
236
  });
191
237
 
@@ -211,7 +257,7 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
211
257
  expect(msal.loginRedirect).toHaveBeenCalledOnce();
212
258
  });
213
259
 
214
- it("recovers on consent_required without clearing cache before redirect", async () => {
260
+ it("recovers on consent_required by evicting bad account + acquireTokenRedirect", async () => {
215
261
  const err = Object.assign(new Error("consent required"), {
216
262
  name: "InteractionRequiredAuthError",
217
263
  errorCode: "consent_required",
@@ -222,11 +268,15 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
222
268
  cause: err,
223
269
  messageIncludes: ["Authentication interaction required", "consent_required"],
224
270
  });
225
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
226
- expect(msal.clearCache).not.toHaveBeenCalled();
271
+ expect(msal.clearCache).toHaveBeenCalledWith(
272
+ expect.objectContaining({ account: expect.objectContaining({ localAccountId: "alice-oid" }) }),
273
+ );
274
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
275
+ expect.objectContaining({ prompt: "login" }),
276
+ );
227
277
  });
228
278
 
229
- it("recovers on monitor_window_timeout without clearing cache before redirect", async () => {
279
+ it("recovers on monitor_window_timeout by evicting bad account + acquireTokenRedirect", async () => {
230
280
  // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
231
281
  // → documented remedy includes "Invoke an interactive API".
232
282
  const err = Object.assign(new Error("monitor_window_timeout"), {
@@ -239,8 +289,12 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
239
289
  cause: err,
240
290
  messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
241
291
  });
242
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
243
- expect(msal.clearCache).not.toHaveBeenCalled();
292
+ expect(msal.clearCache).toHaveBeenCalledWith(
293
+ expect.objectContaining({ account: expect.objectContaining({ localAccountId: "alice-oid" }) }),
294
+ );
295
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
296
+ expect.objectContaining({ prompt: "login" }),
297
+ );
244
298
  });
245
299
 
246
300
  it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
@@ -281,8 +335,10 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
281
335
  cause: err,
282
336
  messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
283
337
  });
284
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
285
- expect(msal.clearCache).not.toHaveBeenCalled();
338
+ expect(msal.clearCache).toHaveBeenCalledOnce();
339
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
340
+ expect.objectContaining({ prompt: "login" }),
341
+ );
286
342
  });
287
343
 
288
344
  it("preserves redirect failure details when both redirect attempts fail", async () => {
@@ -315,11 +371,17 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
315
371
  "interaction_required",
316
372
  ],
317
373
  });
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();
374
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
375
+ expect.objectContaining({ prompt: "login" }),
376
+ );
377
+ expect(msal.loginRedirect).toHaveBeenCalledWith(
378
+ expect.objectContaining({ prompt: "login" }),
379
+ );
380
+ // Per-account cache eviction DID happen as the canonical pre-
381
+ // redirect step (see GitHub #6840). The "no clearCache" rule
382
+ // applies only to the broad ``clearCache()`` form, not the
383
+ // ``clearCache({ account })`` variant we use here.
384
+ expect(msal.clearCache).toHaveBeenCalledOnce();
323
385
  });
324
386
  });
325
387
 
@@ -426,3 +488,109 @@ describe("authStore — orphaned-state recovery", () => {
426
488
  expect(msal.loginRedirect).not.toHaveBeenCalled();
427
489
  });
428
490
  });
491
+
492
+ describe("authStore — block_iframe_reload defuse", () => {
493
+ // MSAL.js refuses to redirect when the current URL fragment still
494
+ // carries a prior silent-auth failure (``#error=…``). In production
495
+ // we shipped clearCache + prompt:'login' but users were STILL stuck
496
+ // because the redirect itself was aborting with
497
+ // ``BrowserAuthError: block_iframe_reload``. The fix:
498
+ // ``startInteractiveRecovery`` must strip the error fragment from
499
+ // the URL with ``history.replaceState`` BEFORE calling
500
+ // ``acquireTokenRedirect`` / ``loginRedirect``.
501
+
502
+ const config = {
503
+ clientId: "test-client",
504
+ authority: "https://login.microsoftonline.com/test-tenant",
505
+ redirectUri: "http://localhost:8010",
506
+ apiScope: "api://test-api/access_as_user",
507
+ };
508
+
509
+ function mockMsal(overrides: Record<string, unknown> = {}) {
510
+ const account = { username: "alice@example.com", localAccountId: "alice-oid" };
511
+ const mock = {
512
+ initialize: vi.fn().mockResolvedValue(undefined),
513
+ handleRedirectPromise: vi.fn().mockResolvedValue(null),
514
+ clearCache: vi.fn().mockResolvedValue(undefined),
515
+ getAllAccounts: vi.fn().mockReturnValue([account]),
516
+ loginRedirect: vi.fn().mockResolvedValue(undefined),
517
+ logoutRedirect: vi.fn().mockResolvedValue(undefined),
518
+ setActiveAccount: vi.fn(),
519
+ acquireTokenSilent: vi.fn().mockResolvedValue({ accessToken: "fresh" }),
520
+ acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
521
+ acquireTokenPopup: vi.fn().mockResolvedValue({ accessToken: "popup" }),
522
+ ...overrides,
523
+ };
524
+ vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(mock);
525
+ vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
526
+ return mock;
527
+ }
528
+
529
+ beforeEach(() => {
530
+ // Reset URL fragment between tests.
531
+ window.history.replaceState(null, "", "/");
532
+ });
533
+
534
+ afterEach(() => {
535
+ vi.restoreAllMocks();
536
+ window.history.replaceState(null, "", "/");
537
+ });
538
+
539
+ it("strips a stale MSAL error fragment before calling acquireTokenRedirect", async () => {
540
+ window.history.replaceState(
541
+ null,
542
+ "",
543
+ "/spaces/some-entity?foo=bar" +
544
+ "#error=interaction_required" +
545
+ "&error_description=AADSTS160021%3a+Application+requested+a+user+session+which+does+not+exist" +
546
+ "&state=eyJpZCI6IngifQ%3D%3D",
547
+ );
548
+ expect(window.location.hash).toContain("error=interaction_required");
549
+
550
+ const msal = mockMsal();
551
+ const reason = new Error("API returned 401 after force-refresh retry");
552
+ let acquireRedirectHash: string | null = null;
553
+ msal.acquireTokenRedirect = vi.fn(async () => {
554
+ acquireRedirectHash = window.location.hash;
555
+ });
556
+
557
+ await expect(authStore.getState().recoverFromHardAuthFailure(reason)).rejects.toBeInstanceOf(AuthInteractionRequiredError);
558
+
559
+ // By the time MSAL's redirect API is called, the page fragment
560
+ // has been wiped. MSAL won't see the stale ``error=`` and won't
561
+ // trip its ``block_iframe_reload`` guard.
562
+ expect(acquireRedirectHash).toBe("");
563
+ // Path + query string are preserved so the post-redirect landing
564
+ // page is the SAME page the user was on when their session went
565
+ // bad.
566
+ expect(window.location.pathname).toBe("/spaces/some-entity");
567
+ expect(window.location.search).toBe("?foo=bar");
568
+ });
569
+
570
+ it("leaves a clean URL alone (no spurious history mutation)", async () => {
571
+ window.history.replaceState(null, "", "/spaces?clean=1");
572
+ const original = window.location.href;
573
+ const msal = mockMsal();
574
+ await expect(authStore
575
+ .getState()
576
+ .recoverFromHardAuthFailure(new Error("policy drift"))).rejects.toBeInstanceOf(AuthInteractionRequiredError);
577
+ // Path + query stay identical (no replaceState invocation).
578
+ expect(window.location.href).toBe(original);
579
+ expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
580
+ });
581
+
582
+ it("strips error_code / error_uri fragments too (not just error_description)", async () => {
583
+ window.history.replaceState(
584
+ null,
585
+ "",
586
+ "/spaces#error_code=AADSTS160021&error_uri=https%3a%2f%2flogin.example",
587
+ );
588
+ const msal = mockMsal();
589
+ let hashAtRedirect: string | null = null;
590
+ msal.acquireTokenRedirect = vi.fn(async () => {
591
+ hashAtRedirect = window.location.hash;
592
+ });
593
+ await expect(authStore.getState().recoverFromHardAuthFailure(new Error("x"))).rejects.toBeInstanceOf(AuthInteractionRequiredError);
594
+ expect(hashAtRedirect).toBe("");
595
+ });
596
+ });
@@ -1,5 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { createServiceFetch } from "../client/service-fetch.ts";
2
+ import {
3
+ createServiceFetch,
4
+ parseClaimsChallengeFromWwwAuthenticate,
5
+ } from "../client/service-fetch.ts";
3
6
 
4
7
  describe("createServiceFetch", () => {
5
8
  let fetchSpy: ReturnType<typeof vi.spyOn>;
@@ -352,6 +355,65 @@ describe("createServiceFetch", () => {
352
355
  expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
353
356
  });
354
357
 
358
+ it("kicks off interactive recovery when acquireToken throws on retry", async () => {
359
+ // Hard expiry path: refresh-token window closed (24h SPA cap) or
360
+ // claims challenge pending. The force-refresh retry's
361
+ // ``acquireToken`` rejects BEFORE the second fetch leaves the
362
+ // browser. Without this branch the throw bubbles up to callers
363
+ // that have bare ``catch {}`` (e.g. useContentTreeStore.fetchSchemas)
364
+ // and the user is stuck in a silent 401 loop with no re-auth UI.
365
+ fetchSpy.mockReset();
366
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
367
+ const msalError = new Error("InteractionRequiredAuthError: refresh token expired");
368
+ const acquireToken = vi
369
+ .fn()
370
+ .mockResolvedValueOnce("stale-token")
371
+ .mockRejectedValueOnce(msalError);
372
+ const recoverFromHardAuthFailure = vi
373
+ .fn()
374
+ .mockRejectedValue(new Error("loginRedirect in flight"));
375
+ const serviceFetch = createServiceFetch({
376
+ acquireToken,
377
+ recoverFromHardAuthFailure,
378
+ });
379
+
380
+ // The wrapped TokenAcquisitionError propagates as the thrown
381
+ // error; recovery has already kicked off by then.
382
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow();
383
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // second fetch never left
384
+ expect(acquireToken).toHaveBeenCalledTimes(2);
385
+ expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
386
+ // Recovery receives the ORIGINAL MSAL error, not the wrapper —
387
+ // telemetry / logging downstream should see the real cause.
388
+ expect(recoverFromHardAuthFailure.mock.calls[0]![0]).toBe(msalError);
389
+ });
390
+
391
+ it("does NOT call recoverFromHardAuthFailure on transport errors during retry", async () => {
392
+ // Regression for review feedback on PR #30: the retry-catch must
393
+ // distinguish auth failures (TokenAcquisitionError) from
394
+ // generic transport failures (network drop, AbortError, CORS
395
+ // preflight reject). The latter must NOT trigger loginRedirect
396
+ // — that would bounce users through Entra ID on any flaky-wifi
397
+ // moment.
398
+ fetchSpy.mockReset();
399
+ const networkErr = new TypeError("NetworkError: Failed to fetch");
400
+ fetchSpy
401
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
402
+ .mockRejectedValueOnce(networkErr);
403
+ const acquireToken = vi.fn().mockResolvedValue("token");
404
+ const recoverFromHardAuthFailure = vi.fn();
405
+ const serviceFetch = createServiceFetch({
406
+ acquireToken,
407
+ recoverFromHardAuthFailure,
408
+ });
409
+
410
+ // Network error propagates as-is; no recovery attempted.
411
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toBe(networkErr);
412
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
413
+ expect(acquireToken).toHaveBeenCalledTimes(2); // both attempts got a token
414
+ expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
415
+ });
416
+
355
417
  it("does NOT call recoverFromHardAuthFailure when the retry recovers", async () => {
356
418
  // Standard recovery — first 401, retry with forceRefresh succeeds.
357
419
  // Recovery callback must not fire (would needlessly bounce the user
@@ -376,3 +438,88 @@ describe("createServiceFetch", () => {
376
438
  });
377
439
  });
378
440
  });
441
+
442
+ describe("parseClaimsChallengeFromWwwAuthenticate", () => {
443
+ it("returns undefined when header is missing", () => {
444
+ expect(parseClaimsChallengeFromWwwAuthenticate(null)).toBeUndefined();
445
+ expect(parseClaimsChallengeFromWwwAuthenticate(undefined)).toBeUndefined();
446
+ expect(parseClaimsChallengeFromWwwAuthenticate("")).toBeUndefined();
447
+ });
448
+
449
+ it("returns undefined when Bearer challenge carries no claims", () => {
450
+ const header = 'Bearer realm="example", error="invalid_token"';
451
+ expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBeUndefined();
452
+ });
453
+
454
+ it("extracts quoted claims payload", () => {
455
+ // Microsoft Identity Platform CAE / Conditional Access challenge
456
+ // shape — base64url payload inside double quotes.
457
+ const claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjAwMDAwMDAwIn19fQ";
458
+ const header = `Bearer realm="example", error="insufficient_claims", claims="${claims}"`;
459
+ expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe(claims);
460
+ });
461
+
462
+ it("extracts unquoted token68 claims payload", () => {
463
+ const claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZX19fQ";
464
+ const header = `Bearer claims=${claims}`;
465
+ expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe(claims);
466
+ });
467
+
468
+ it("ignores non-Bearer challenges", () => {
469
+ const header = 'Basic realm="x", Digest realm="y", claims="ignored"';
470
+ expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBeUndefined();
471
+ });
472
+
473
+ it("picks the Bearer claims even when other challenges are present", () => {
474
+ const header = 'Basic realm="x", Bearer realm="api", error="insufficient_claims", claims="ABC123"';
475
+ expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe("ABC123");
476
+ });
477
+ });
478
+
479
+ describe("createServiceFetch — claims-challenge passthrough", () => {
480
+ let fetchSpy: ReturnType<typeof vi.fn>;
481
+ beforeEach(() => {
482
+ fetchSpy = vi.fn();
483
+ globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
484
+ });
485
+ afterEach(() => {
486
+ vi.restoreAllMocks();
487
+ });
488
+
489
+ it("forwards a WWW-Authenticate claims challenge through recoverFromHardAuthFailure", async () => {
490
+ // Resource server returned 401 with a CAE claims challenge. The
491
+ // retry succeeds at the token level (forceRefresh produced a
492
+ // fresh access token) but the server STILL rejects because the
493
+ // user needs a step-up. Recovery must carry the challenge to
494
+ // MSAL so the next token explicitly satisfies it.
495
+ const claims = "eyJjbGFpbXMiOiJBQkMifQ";
496
+ fetchSpy
497
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
498
+ .mockResolvedValueOnce(
499
+ new Response(null, {
500
+ status: 401,
501
+ headers: {
502
+ "WWW-Authenticate": `Bearer realm="api", error="insufficient_claims", claims="${claims}"`,
503
+ },
504
+ }),
505
+ );
506
+ const acquireToken = vi
507
+ .fn()
508
+ .mockResolvedValueOnce("stale")
509
+ .mockResolvedValueOnce("fresh");
510
+ const recoverFromHardAuthFailure = vi
511
+ .fn()
512
+ .mockRejectedValue(new Error("redirecting"));
513
+ const serviceFetch = createServiceFetch({
514
+ acquireToken,
515
+ recoverFromHardAuthFailure,
516
+ });
517
+
518
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow();
519
+ expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
520
+ const reason = recoverFromHardAuthFailure.mock.calls[0]![0];
521
+ // Stamped onto the recovery reason so auth-store can forward it
522
+ // to MSAL.acquireTokenRedirect({ claims }).
523
+ expect((reason as { claims?: string }).claims).toBe(claims);
524
+ });
525
+ });
@@ -66,6 +66,26 @@ export type ServiceFetch = (
66
66
  init?: RequestInit,
67
67
  ) => Promise<Response>;
68
68
 
69
+ /**
70
+ * Wrapper thrown when ``acquireToken`` rejects inside the fetch
71
+ * interceptor. Lets the outer 401-retry layer distinguish
72
+ * token-acquisition failures (which warrant interactive recovery) from
73
+ * generic ``fetch`` rejections like network drops, aborts, or CORS
74
+ * preflight failures (which do NOT — those would needlessly bounce the
75
+ * user through ``loginRedirect`` on a transient transport error).
76
+ *
77
+ * Exported so hosts that wrap ``serviceFetch`` further can ``instanceof``
78
+ * against the same class without re-declaring it.
79
+ */
80
+ export class TokenAcquisitionError extends Error {
81
+ override readonly name = "TokenAcquisitionError";
82
+ readonly cause: unknown;
83
+ constructor(cause: unknown) {
84
+ super(cause instanceof Error ? cause.message : String(cause));
85
+ this.cause = cause;
86
+ }
87
+ }
88
+
69
89
  const defaultOriginResolver = (): string =>
70
90
  typeof window !== "undefined" ? window.location.origin : "";
71
91
 
@@ -134,11 +154,15 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
134
154
  headers.set("Authorization", `Bearer ${token}`);
135
155
  }
136
156
  } catch (error) {
137
- // A thrown token acquisition error usually means the auth layer
138
- // is starting an interactive recovery. Do not downgrade protected
139
- // API calls to anonymous requests; that creates noisy 401s and
140
- // stale UI.
141
- throw error;
157
+ // A thrown token-acquisition error usually means the auth layer
158
+ // is starting an interactive recovery. Do not downgrade
159
+ // protected API calls to anonymous requests; that creates
160
+ // noisy 401s and stale UI. Wrap the error so the outer
161
+ // 401-retry layer can distinguish it from generic ``fetch``
162
+ // rejections (network drops, aborts, CORS preflight fails)
163
+ // and only escalate to ``recoverFromHardAuthFailure`` for
164
+ // genuine auth failures.
165
+ throw new TokenAcquisitionError(error);
142
166
  }
143
167
  }
144
168
  if (input instanceof Request) {
@@ -173,25 +197,122 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
173
197
  // Drain the failed response body — letting it sit unread keeps the
174
198
  // underlying connection occupied on some runtimes.
175
199
  response.body?.cancel().catch(() => undefined);
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.
200
+
201
+ // Two failure shapes for the retry:
202
+ // (a) The force-refreshed token grant FAILS refresh token gone
203
+ // (24h SPA cap), interaction-required claims challenge, etc.
204
+ // ``acquireToken`` throws inside ``dispatch`` BEFORE the fetch
205
+ // leaves the browser. ``dispatch`` re-throws wrapped in
206
+ // ``TokenAcquisitionError`` so we can distinguish this case
207
+ // from network errors below.
208
+ // (b) The grant succeeds but the resource server STILL rejects
209
+ // the token (audience drift, server-side policy change). The
210
+ // retried response status is 401.
211
+ // Both must funnel into ``recoverFromHardAuthFailure`` so the auth
212
+ // layer can kick off ``loginRedirect`` — the only correct UX for a
213
+ // hard expiry. We treat the two auth paths symmetrically.
214
+ //
215
+ // We deliberately do NOT trigger recovery on generic ``dispatch``
216
+ // throws (TypeError from network drops, AbortError from caller
217
+ // cancellation, CORS-preflight failures, etc.) — those are
218
+ // transient transport problems, not auth state corruption, and
219
+ // bouncing the user through ``loginRedirect`` for a flaky network
220
+ // would be a much worse UX than letting the error propagate.
221
+ let retried: Response;
222
+ try {
223
+ retried = await dispatch(true);
224
+ } catch (err) {
225
+ if (err instanceof TokenAcquisitionError && options.recoverFromHardAuthFailure) {
226
+ // ``recoverFromHardAuthFailure`` is expected to throw once the
227
+ // redirect is in flight; if it returns (e.g. test stub) we
228
+ // re-throw the wrapped original so the caller still sees the
229
+ // auth failure rather than a phantom recovery. We pass the
230
+ // unwrapped ``cause`` to recovery so it sees the real MSAL
231
+ // error (InteractionRequiredAuthError etc.) for telemetry.
232
+ await options.recoverFromHardAuthFailure(err.cause);
233
+ }
234
+ throw err;
235
+ }
236
+
237
+ // Hard auth failure path (b): even the force-refreshed token got
238
+ // rejected. A token minted from the cached refresh token carries
239
+ // the same identity claims as the original, so it'll keep getting
240
+ // rejected for the same reason. Only a brand-new ``loginRedirect``
241
+ // produces a token bound to the current server policy. Without
242
+ // this branch the user sees an endless 401 loop until they
243
+ // manually log out.
186
244
  if (retried.status === 401 && options.recoverFromHardAuthFailure) {
245
+ // Continuous Access Evaluation / Conditional-Access claims
246
+ // challenge passthrough. When the resource server requires a
247
+ // step-up (MFA, device compliance, revocation invalidation),
248
+ // it returns 401 with ``WWW-Authenticate: Bearer ... claims="…"``.
249
+ // We forward the payload through the recovery callback so the
250
+ // auth layer's redirect carries it to Entra ID and the
251
+ // re-minted token satisfies the exact challenge. Without it,
252
+ // Entra re-issues the same already-rejected claims set and the
253
+ // user loops back into the broken state.
254
+ const claims = parseClaimsChallengeFromWwwAuthenticate(
255
+ retried.headers.get("www-authenticate"),
256
+ );
187
257
  retried.body?.cancel().catch(() => undefined);
258
+ const reason = new AuthInteractionRequiredError(
259
+ "API returned 401 after force-refresh retry",
260
+ claims ? { claims } : undefined,
261
+ );
188
262
  // ``recoverFromHardAuthFailure`` throws once the redirect is in
189
263
  // flight; the throw stops the calling pipeline so we don't
190
264
  // 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
- );
265
+ await options.recoverFromHardAuthFailure(reason);
194
266
  }
195
267
  return retried;
196
268
  };
197
269
  }
270
+
271
+ /**
272
+ * Error class the fetch interceptor raises (and forwards through
273
+ * ``recoverFromHardAuthFailure``) when a 401 needs interactive
274
+ * recovery. Exposed so the auth-layer recovery can pick up a
275
+ * ``claims`` field if one was extracted from the
276
+ * ``WWW-Authenticate`` header.
277
+ */
278
+ export class AuthInteractionRequiredError extends Error {
279
+ override readonly name = "AuthInteractionRequiredError";
280
+ readonly claims?: string;
281
+ constructor(message: string, options?: { claims?: string }) {
282
+ super(message);
283
+ this.claims = options?.claims;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Extract the claims challenge string from a resource server's
289
+ * ``WWW-Authenticate: Bearer ... claims="…"`` header. Returns
290
+ * ``undefined`` when the header is missing, malformed, or carries
291
+ * no claims directive.
292
+ *
293
+ * The value is forwarded VERBATIM to MSAL's
294
+ * ``acquireTokenRedirect({ claims })``; MSAL handles the
295
+ * base64-url decode + JSON parse itself. We don't try to validate
296
+ * the inner shape — letting Entra speak for itself avoids drift if
297
+ * the schema evolves.
298
+ *
299
+ * Spec: RFC 6750 ``WWW-Authenticate`` + CAE claims-challenge
300
+ * supplement (Microsoft Identity Platform docs).
301
+ */
302
+ export function parseClaimsChallengeFromWwwAuthenticate(
303
+ header: string | null | undefined,
304
+ ): string | undefined {
305
+ if (!header) return undefined;
306
+ // ``WWW-Authenticate`` can carry multiple challenges separated by
307
+ // commas (e.g. ``Basic realm="x", Bearer ...``). We only care
308
+ // about the Bearer challenge.
309
+ const bearer = header.match(/Bearer\s+([^,]+(?:,(?!\s*[A-Za-z]+\s)[^,]*)*)/i);
310
+ if (!bearer) return undefined;
311
+ // Inside the Bearer params, find ``claims="..."`` (quoted) or
312
+ // ``claims=token68`` (unquoted, base64url).
313
+ const quoted = bearer[1].match(/claims\s*=\s*"([^"]*)"/i);
314
+ if (quoted) return quoted[1].length > 0 ? quoted[1] : undefined;
315
+ const unquoted = bearer[1].match(/claims\s*=\s*([A-Za-z0-9_\-+/=]+)/i);
316
+ if (unquoted) return unquoted[1].length > 0 ? unquoted[1] : undefined;
317
+ return undefined;
318
+ }
@@ -27,7 +27,12 @@ export interface MsalClientApplication {
27
27
  handleRedirectPromise(): Promise<unknown>;
28
28
  clearCache(request?: { account?: MsalAccountInfo }): Promise<void>;
29
29
  getAllAccounts(): MsalAccountInfo[];
30
- loginRedirect(request: { scopes: string[]; prompt?: string }): Promise<void>;
30
+ loginRedirect(request: {
31
+ scopes: string[];
32
+ prompt?: string;
33
+ /** Same claims-challenge passthrough as ``acquireTokenRedirect``. */
34
+ claims?: string;
35
+ }): Promise<void>;
31
36
  logoutRedirect(): Promise<void>;
32
37
  setActiveAccount(account: MsalAccountInfo | null): void;
33
38
  acquireTokenSilent(request: {
@@ -47,6 +52,25 @@ export interface MsalClientApplication {
47
52
  acquireTokenRedirect(request: {
48
53
  scopes: string[];
49
54
  account: MsalAccountInfo;
55
+ /**
56
+ * Force a fresh interactive login regardless of Entra SSO state.
57
+ * Without ``prompt: "login"``, when the user still has a live
58
+ * Entra session, the redirect silently round-trips and returns
59
+ * THE SAME stale token / claims — leaving the SPA back in the
60
+ * exact broken state we tried to recover from. See
61
+ * https://learn.microsoft.com/entra/identity-platform/msal-error-handling-js
62
+ * "Hard expiry / silent redirect loop" pattern.
63
+ */
64
+ prompt?: string;
65
+ /**
66
+ * Optional Continuous Access Evaluation / Conditional Access
67
+ * claims challenge payload, parsed from a resource-server 401
68
+ * response's ``WWW-Authenticate: Bearer ... claims="..."``
69
+ * header. When present, MSAL passes it through to Entra so the
70
+ * issued token explicitly satisfies the challenge (MFA step-up,
71
+ * device-compliance refresh, revocation invalidation, etc.).
72
+ */
73
+ claims?: string;
50
74
  }): Promise<void>;
51
75
  acquireTokenPopup(request: {
52
76
  scopes: string[];
@@ -237,38 +237,113 @@ async function startInteractiveRecovery(
237
237
  return activeRecoveryPromise;
238
238
  }
239
239
 
240
+ // Extract a claims challenge from the failure reason (if any).
241
+ // Production scenarios that surface here with a payload:
242
+ // * Conditional Access re-eval (MFA step-up required).
243
+ // * Continuous Access Evaluation revocation.
244
+ // * Device-compliance change mid-session.
245
+ // The service-fetch interceptor parses WWW-Authenticate when it
246
+ // can and stamps the payload onto the recovery reason — we pass
247
+ // it through to MSAL so the next token explicitly satisfies the
248
+ // challenge. Without this, Entra would silently re-issue the same
249
+ // already-rejected claims set and we'd loop right back.
250
+ const claims = extractClaimsChallenge(reason);
251
+
240
252
  activeRecoveryPromise = (async () => {
241
253
  let redirectError: unknown;
242
254
  authStore.setState({ user: null, isAuthenticated: false });
243
255
 
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).
256
+ // Step 0: strip a lingering MSAL error hash from the URL before
257
+ // attempting any redirect. MSAL.js's redirect primitives refuse
258
+ // to navigate with ``BrowserAuthError: block_iframe_reload`` when
259
+ // the current page URL still carries a previous silent-auth
260
+ // failure in its fragment (e.g.
261
+ // ``#error=interaction_required&error_description=AADSTS160021…``
262
+ // from an earlier ``handleRedirectPromise`` that surfaced
263
+ // "user session does not exist"). The SDK's own anti-loop guard
264
+ // sees the unconsumed error and aborts — leaving the user
265
+ // permanently stuck unless they manually clear browser state.
266
+ //
267
+ // We can defuse that guard cleanly by replacing the URL with a
268
+ // fragment-less copy via ``history.replaceState`` before the
269
+ // redirect call. MSAL then sees a "clean" page and proceeds.
270
+ // The original page state (path + search) is preserved so the
271
+ // user lands back where they started after re-auth.
272
+ //
273
+ // Reference:
274
+ // https://aka.ms/msal.js.errors#block_iframe_reload
275
+ // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/5623
276
+ stripStaleMsalErrorHash();
277
+
278
+ // Step 1: surgically evict the bad account record from MSAL's
279
+ // own token cache BEFORE redirecting. Two reasons:
280
+ //
281
+ // (a) The bad refresh token / claims set is what's causing the
282
+ // hard-auth failure. Leaving it in cache means MSAL's
283
+ // acquireTokenSilent (called by every other in-flight or
284
+ // post-redirect request) hands out the SAME poisoned token
285
+ // until the entry naturally expires.
286
+ // (b) Microsoft's "orphaned-state" anti-pattern is about
287
+ // clearing AFTER initiating a redirect (i.e. clearing while
288
+ // MSAL is mid-flight). Clearing the SPECIFIC account
289
+ // BEFORE the redirect is the documented pattern — see
290
+ // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/6840
291
+ // and the SDK's own ``IPublicClientApplication.clearCache``
292
+ // contract which accepts ``{ account }``.
293
+ //
294
+ // Wrapped in a try/catch so a cache-removal hiccup doesn't
295
+ // strand the user without ever attempting the redirect.
251
296
  if (account) {
252
297
  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,
298
+ await msal.clearCache({ account });
299
+ } catch (err) {
300
+ // Telemetry only keep flowing into the redirect path.
301
+ // eslint-disable-next-line no-console
302
+ console.warn(
303
+ "[msal-recovery] clearCache(account) failed; continuing with redirect",
304
+ err,
305
+ );
306
+ }
307
+ }
308
+
309
+ // Step 2: ``acquireTokenRedirect`` with ``prompt: "login"`` —
310
+ // forces a fresh interactive authentication even if Entra still
311
+ // has a live SSO session for the user. Without ``prompt: "login"``
312
+ // the redirect silently round-trips through Entra and returns a
313
+ // token built from the SAME state we just tried to escape from
314
+ // (this is the "silent redirect loop" users actually experience:
315
+ // the page bounces to login.microsoftonline.com and back, still
316
+ // broken). The full re-auth path produces a fresh refresh-token
317
+ // + fresh claims bound to current server policy.
318
+ if (account) {
319
+ try {
320
+ await msal.acquireTokenRedirect({
321
+ scopes: [scope],
322
+ account,
323
+ prompt: "login",
324
+ ...(claims ? { claims } : {}),
325
+ });
326
+ // If we reach here without navigating, MSAL settled the
327
+ // promise before browser navigation kicked in (test envs,
256
328
  // blocked redirects, etc.). Fall through to loginRedirect.
257
329
  } catch (err) {
258
330
  redirectError = err;
259
331
  }
260
332
  }
261
333
 
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).
334
+ // Step 3: full ``loginRedirect`` fallback. Same ``prompt: "login"``
335
+ // contract so even this path can't loop back into broken state.
336
+ // Used when:
337
+ // * No account was passed (orphaned-state caller already
338
+ // wiped localStorage upstream).
339
+ // * acquireTokenRedirect threw a non-navigation error.
340
+ // * acquireTokenRedirect settled without navigating.
270
341
  try {
271
- await msal.loginRedirect({ scopes: [scope] });
342
+ await msal.loginRedirect({
343
+ scopes: [scope],
344
+ prompt: "login",
345
+ ...(claims ? { claims } : {}),
346
+ });
272
347
  } catch (err) {
273
348
  redirectError = err;
274
349
  }
@@ -283,6 +358,75 @@ async function startInteractiveRecovery(
283
358
  }
284
359
  }
285
360
 
361
+ /**
362
+ * Pull a CAE / Conditional-Access claims-challenge payload out of a
363
+ * recovery-trigger error. Two shapes are produced upstream:
364
+ *
365
+ * * The fetch interceptor (``service-fetch``) parses the
366
+ * ``WWW-Authenticate`` header on a 401 response and constructs
367
+ * an error whose ``claims`` (or ``cause.claims``) field carries
368
+ * the base64 payload it found.
369
+ * * MSAL.js's own ``InteractionRequiredAuthError`` instances expose
370
+ * ``claims`` directly when Azure returned one.
371
+ *
372
+ * Return ``undefined`` when none is present — callers should NOT
373
+ * pass an empty string to MSAL (the SDK treats that as "ignore" but
374
+ * older versions choke on the empty key).
375
+ */
376
+ /**
377
+ * Replace the page's URL fragment with an empty one when it carries
378
+ * a leftover MSAL/Entra error response (``#error=…``,
379
+ * ``#error_description=…``, ``#error_code=…``, or
380
+ * ``#error_uri=…``). Preserves path + query string so the user
381
+ * lands back on the same page after the imminent recovery redirect.
382
+ *
383
+ * Why this is necessary:
384
+ * MSAL.js's redirect primitives include an anti-loop guard that
385
+ * refuses to navigate when the current URL fragment encodes a
386
+ * prior auth error — the SDK raises ``block_iframe_reload``
387
+ * instead of redirecting. In production we hit this when an
388
+ * earlier ``acquireTokenSilent`` had its hidden iframe receive an
389
+ * ``AADSTS160021: Application requested a user session which does
390
+ * not exist`` response, MSAL surfaced it via
391
+ * ``handleRedirectPromise`` but left the fragment intact.
392
+ * Subsequent recovery redirects all aborted with
393
+ * ``block_iframe_reload`` and the user was stuck until they
394
+ * manually cleared browser storage.
395
+ *
396
+ * This helper is a no-op on non-browser environments and a no-op
397
+ * when no error fragment is present.
398
+ */
399
+ function stripStaleMsalErrorHash(): void {
400
+ if (typeof window === "undefined" || typeof history === "undefined") return;
401
+ const hash = window.location.hash || "";
402
+ if (!hash) return;
403
+ // ``hash`` is e.g. ``#error=interaction_required&error_description=…``.
404
+ // We look for any of the error-shaped keys MSAL emits.
405
+ if (!/(?:^|[#&])error(?:_description|_code|_uri)?=/.test(hash)) return;
406
+ try {
407
+ const cleanUrl = window.location.pathname + window.location.search;
408
+ history.replaceState(null, "", cleanUrl);
409
+ } catch {
410
+ // Some embedded environments lock down history.replaceState
411
+ // (sandboxed iframes etc.). If that happens we still try the
412
+ // redirect — MSAL may or may not succeed; nothing we can do.
413
+ }
414
+ }
415
+
416
+ function extractClaimsChallenge(reason: unknown): string | undefined {
417
+ if (!reason || typeof reason !== "object") return undefined;
418
+ const direct = (reason as { claims?: unknown }).claims;
419
+ if (typeof direct === "string" && direct.length > 0) return direct;
420
+ const cause = (reason as { cause?: unknown }).cause;
421
+ if (cause && typeof cause === "object") {
422
+ const fromCause = (cause as { claims?: unknown }).claims;
423
+ if (typeof fromCause === "string" && fromCause.length > 0) {
424
+ return fromCause;
425
+ }
426
+ }
427
+ return undefined;
428
+ }
429
+
286
430
  export const authStore = createStore<AuthState>((set) => ({
287
431
  user: null,
288
432
  isAuthenticated: false,