@iloveagents/foundry-agent 0.1.4 → 0.2.1
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 +69 -0
- package/package.json +1 -1
- package/src/__tests__/auth-store.test.ts +197 -29
- package/src/__tests__/citation-store.test.ts +9 -3
- package/src/__tests__/service-fetch.test.ts +148 -1
- package/src/client/service-fetch.ts +139 -18
- package/src/msal/auth-config.ts +25 -1
- package/src/msal/auth-store.ts +163 -19
- package/src/store/citation-store.ts +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @iloveagents/foundry-agent
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- No source changes. The 0.2.0 publish for `@iloveagents/foundry-agent` and
|
|
8
|
+
`@iloveagents/foundry-web-primitives` failed because npm had `0.2.0`
|
|
9
|
+
reserved for those two packages from an older publish-then-unpublish
|
|
10
|
+
cycle (the version was reserved before the recent v1 incident — see
|
|
11
|
+
git tag history). `@iloveagents/foundry-web-shell@0.2.0` and
|
|
12
|
+
`@iloveagents/foundry-web-ui@0.2.0` published cleanly. Bumping the
|
|
13
|
+
whole fixed group to 0.2.1 so all four publish together at a version
|
|
14
|
+
number that's free across all four packages.
|
|
15
|
+
|
|
16
|
+
## 0.2.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- shell: ChatConversationConfig API + sticky-runtime threadId
|
|
21
|
+
|
|
22
|
+
Lets a downstream module (e.g. `@ltwlf/spaces-web-ui`) attach a
|
|
23
|
+
`ChatConversationConfig` to its `ChatModule`. The shell then:
|
|
24
|
+
- Pre-mints a fresh thread UUID via `useRef` so the AG-UI runtime's
|
|
25
|
+
`threadId` is defined from first render — no `undefined → defined`
|
|
26
|
+
remount mid-conversation when the user sends the first message.
|
|
27
|
+
- Calls `config.useStickyConversationId()` (when declared) and
|
|
28
|
+
feeds the resulting id into the runtime so the chat stays "live"
|
|
29
|
+
while the user browses non-chat routes (Workspaces, Jobs, etc.).
|
|
30
|
+
- Per-config keyed child components (`StickyAwareRuntime` vs
|
|
31
|
+
`UrlOnlyRuntime`) so optional hooks satisfy Rules-of-Hooks across
|
|
32
|
+
multi-module config swaps.
|
|
33
|
+
|
|
34
|
+
UI: collapsible nav groups in `sidebar.tsx` with persisted state via
|
|
35
|
+
`useSyncExternalStore` + localStorage, plus stable group ordering via
|
|
36
|
+
new `priority` field in `nav-config.ts`.
|
|
37
|
+
|
|
38
|
+
`ag-ui-runtime-provider.tsx` accepts an external `threadId` and
|
|
39
|
+
`historyAdapterFactory` so the consuming app can wire its own
|
|
40
|
+
persistence (e.g. `ThreadHistoryAdapter` → REST `/api/conversations`).
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- Fix `citationStore.clear()` so the registered handler survives per-conversation resets.
|
|
45
|
+
|
|
46
|
+
The previous implementation wiped both `results` AND `handler`. `useNewConversation` calls `clear()` on every "New Thread" click. Combined with the `registerOnce`-style handler registration in feature modules (e.g. SPACES' `registerSpacesCitationHandler`), the result was that all `[n]` citation markers in chat became inert non-clickable spans for the rest of the session after the first new-thread click. `clear()` now resets `results` only — the handler is module-level state and must persist.
|
|
47
|
+
|
|
48
|
+
- Move sibling `@iloveagents/foundry-*` declarations from `peerDependencies`
|
|
49
|
+
to `dependencies` in `foundry-web-shell` and `foundry-web-ui`. These four
|
|
50
|
+
packages always ship together as a coordinated fixed group from the same
|
|
51
|
+
monorepo — they were never independently-versioned peers, so declaring
|
|
52
|
+
them as peerDeps caused changesets' `shouldBumpMajor` cascade to promote
|
|
53
|
+
the entire group to a major version on every minor changeset (a single
|
|
54
|
+
minor on any of the four would exit the sibling's `^0.x.y` peerDep range
|
|
55
|
+
→ all dependents promoted to major → fixed group synced all four to
|
|
56
|
+
major → 0.1.5 unexpectedly jumped to 1.0.0). Moving to `dependencies`
|
|
57
|
+
eliminates the cascade entirely; `dependencies` never trigger
|
|
58
|
+
`shouldBumpMajor`. Consumers that already install each foundry-\* package
|
|
59
|
+
directly are unaffected — pnpm hoists by name, so a single instance
|
|
60
|
+
resolves across the dep graph regardless of declaration site.
|
|
61
|
+
|
|
62
|
+
### NOTE: 1.0.0 / 1.0.1 were accidental
|
|
63
|
+
|
|
64
|
+
These version numbers were published briefly on 2026-05-27 due to the
|
|
65
|
+
peerDep cascade bug described above, then unpublished. The version
|
|
66
|
+
numbers themselves are now permanently reserved on npm and CANNOT be
|
|
67
|
+
re-published. This 0.2.0 release captures all the work that was in
|
|
68
|
+
the accidental 1.0.x releases. Do not depend on 1.0.x.
|
|
69
|
+
|
|
70
|
+
## 0.1.5
|
|
71
|
+
|
|
3
72
|
## 0.1.4
|
|
4
73
|
|
|
5
74
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -125,13 +125,20 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
125
125
|
);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
it("recoverFromHardAuthFailure
|
|
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.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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.
|
|
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
|
|
160
|
-
// Canonical 2026 MSAL.js pattern
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
|
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.
|
|
226
|
-
|
|
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
|
|
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.
|
|
243
|
-
|
|
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.
|
|
285
|
-
expect(msal.
|
|
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).
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
});
|
|
@@ -33,14 +33,20 @@ describe("citationStore", () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe("clear", () => {
|
|
36
|
-
it("resets results
|
|
36
|
+
it("resets results but preserves the registered handler", () => {
|
|
37
|
+
// The handler is registered once at app boot by a feature module
|
|
38
|
+
// (e.g. SPACES). It MUST survive "New Thread" / per-conversation
|
|
39
|
+
// resets — otherwise every [n] marker in chat becomes inert after
|
|
40
|
+
// the first new-thread click, since the registration guard
|
|
41
|
+
// suppresses re-binding for the rest of the session.
|
|
42
|
+
const handler: CitationHandler = { openCitation: () => {} };
|
|
37
43
|
citationStore.getState().setResults([mockResult]);
|
|
38
|
-
citationStore.getState().setHandler(
|
|
44
|
+
citationStore.getState().setHandler(handler);
|
|
39
45
|
|
|
40
46
|
citationStore.getState().clear();
|
|
41
47
|
|
|
42
48
|
expect(citationStore.getState().results).toEqual([]);
|
|
43
|
-
expect(citationStore.getState().handler).
|
|
49
|
+
expect(citationStore.getState().handler).toBe(handler);
|
|
44
50
|
});
|
|
45
51
|
});
|
|
46
52
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
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
|
|
138
|
-
// is starting an interactive recovery. Do not downgrade
|
|
139
|
-
// API calls to anonymous requests; that creates
|
|
140
|
-
// stale UI.
|
|
141
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
+
}
|
package/src/msal/auth-config.ts
CHANGED
|
@@ -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: {
|
|
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[];
|
package/src/msal/auth-store.ts
CHANGED
|
@@ -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
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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.
|
|
254
|
-
|
|
255
|
-
//
|
|
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
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// *
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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({
|
|
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,
|
|
@@ -26,7 +26,14 @@ interface CitationState {
|
|
|
26
26
|
setResults: (results: CitationResult[]) => void;
|
|
27
27
|
/** Register a handler for citation click actions */
|
|
28
28
|
setHandler: (handler: CitationHandler) => void;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Reset per-conversation state. Clears ``results`` only — the handler
|
|
31
|
+
* is module-level state registered once at app boot by feature modules
|
|
32
|
+
* (e.g. ``registerSpacesCitationHandler``) and must persist across
|
|
33
|
+
* conversations. Wiping it on "New Thread" produced inert ``[n]``
|
|
34
|
+
* markers for the rest of the session, since the registration guard
|
|
35
|
+
* (``registerOnce`` in spaces ``use-spaces-init``) suppresses re-binding.
|
|
36
|
+
*/
|
|
30
37
|
clear: () => void;
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -41,5 +48,5 @@ export const citationStore = createStore<CitationState>((set) => ({
|
|
|
41
48
|
handler: null,
|
|
42
49
|
setResults: (results) => set({ results }),
|
|
43
50
|
setHandler: (handler) => set({ handler }),
|
|
44
|
-
clear: () => set({ results: []
|
|
51
|
+
clear: () => set({ results: [] }),
|
|
45
52
|
}));
|