@iloveagents/foundry-agent 0.1.1 → 0.1.3

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,35 @@
1
1
  # @iloveagents/foundry-agent
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - d9b0460: fix(agent): escalate to loginRedirect after second 401
8
+
9
+ Follow-up to the auth-stale-token-401-retry fix in 0.1.2. The first version retried with `forceRefresh: true` and bounced to `loginRedirect` only when MSAL itself reported `InteractionRequiredAuthError`. But there's a more common production failure mode the silent path can't recover: the resource server rejects the **freshly-refreshed** token too — server-side policy drift, audience mismatch, conditional-access re-evaluation, claims challenge, tenant-policy change. MSAL has no way to know about any of this; it produces a clean refreshed token and calls it a day. The user is left in an endless silent 401 loop because nothing kicks them to `loginRedirect`.
10
+
11
+ Adds `recoverFromHardAuthFailure(reason)` on `authStore`. The fetch interceptor calls it when a SECOND consecutive 401 fires (i.e. even the force-refresh retry didn't help). It clears the MSAL cache and starts `loginRedirect` so a brand-new session mints a token bound to current server policy.
12
+
13
+ `createServiceFetch` now accepts an optional `recoverFromHardAuthFailure` callback. The default consumer (`@iloveagents/foundry-web-shell`) wires it to the new method on `authStore`. Same pattern in `tokenFetch`.
14
+
15
+ Pair: `lastspace#TBD` (forwards the new option through `spacesFetch`).
16
+
17
+ ## 0.1.2
18
+
19
+ ### Patch Changes
20
+
21
+ - 30a4346: fix(agent): retry protected fetch with `forceRefresh` on 401
22
+
23
+ Closes the long-lived-tab 401 loop where the only recovery the user had was logging out or clearing localStorage.
24
+
25
+ The token-fetch wrapper used to ask MSAL for a cached token and never retry. If the resource server rejected that token (claims challenge, conditional-access re-eval, audience drift, or staleness MSAL's clock-offset check missed), every subsequent request kept re-sending the same token.
26
+
27
+ Fix: on 401 from a protected API, retry once with `acquireTokenSilent({ forceRefresh: true })`. If that also fails, the existing `InteractionRequiredAuthError` path bounces the user through Entra ID. Single-retry cap so we never loop. Request bodies are cloned per-attempt (ReadableStream is single-consume) so POST/PUT payloads survive the retry intact.
28
+
29
+ The `acquireToken` callback type now accepts an optional `{ forceRefresh? }` argument — non-breaking; existing callbacks continue to work, but they should forward the option to their token source so the retry path actually reaches the auth backend.
30
+
31
+ Pair: `lastspace#205` (forwards the option through `spacesFetch.acquireToken`).
32
+
3
33
  ## 0.1.1
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -6,7 +6,7 @@ import type { AgentSubscriber } from "@ag-ui/client";
6
6
  // runAgent fires them in order, synchronously yielding the loop between
7
7
  // each one so the runner's queue can drain.
8
8
 
9
- type ScriptedRun = (subscriber: AgentSubscriber) => Promise<void>;
9
+ type ScriptedRun = (subscriber: AgentSubscriber, agent: { messages: unknown[] }) => Promise<void>;
10
10
 
11
11
  let nextScript: ScriptedRun[] = [];
12
12
  let runCount = 0;
@@ -38,7 +38,7 @@ vi.mock("@ag-ui/client", () => {
38
38
  const turnIdx = runCount;
39
39
  runCount++;
40
40
  const script = nextScript[turnIdx];
41
- if (script) await script(subscriber);
41
+ if (script) await script(subscriber, this);
42
42
  }
43
43
 
44
44
  abortRun() {
@@ -52,7 +52,10 @@ import { AGUIRunner } from "../client/agui-runner.ts";
52
52
  import { clientToolRegistry } from "../tools/registry.ts";
53
53
  import type { RunnerEvent } from "../client/runner-events.ts";
54
54
 
55
- function makeRegistry(client: { isRegistered: (name: string) => boolean; executeTool?: (name: string, args: string) => Promise<string> }) {
55
+ function makeRegistry(client: {
56
+ isRegistered: (name: string) => boolean;
57
+ executeTool?: (name: string, args: string) => Promise<string>;
58
+ }) {
56
59
  return {
57
60
  isRegistered: client.isRegistered,
58
61
  executeTool: client.executeTool ?? (async () => JSON.stringify({})),
@@ -60,7 +63,10 @@ function makeRegistry(client: { isRegistered: (name: string) => boolean; execute
60
63
  };
61
64
  }
62
65
 
63
- async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegistry>): Promise<RunnerEvent[]> {
66
+ async function collect(
67
+ runner: AGUIRunner,
68
+ registry: ReturnType<typeof makeRegistry>,
69
+ ): Promise<RunnerEvent[]> {
64
70
  const out: RunnerEvent[] = [];
65
71
  for await (const evt of runner.run({ messages: [], registry })) {
66
72
  out.push(evt);
@@ -68,6 +74,18 @@ async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegis
68
74
  return out;
69
75
  }
70
76
 
77
+ async function collectWithMessages(
78
+ runner: AGUIRunner,
79
+ messages: Parameters<AGUIRunner["run"]>[0]["messages"],
80
+ registry: ReturnType<typeof makeRegistry>,
81
+ ): Promise<RunnerEvent[]> {
82
+ const out: RunnerEvent[] = [];
83
+ for await (const evt of runner.run({ messages, registry })) {
84
+ out.push(evt);
85
+ }
86
+ return out;
87
+ }
88
+
71
89
  describe("AGUIRunner", () => {
72
90
  beforeEach(() => {
73
91
  nextScript = [];
@@ -103,8 +121,8 @@ describe("AGUIRunner", () => {
103
121
  expect(types).toContain("text-message-end");
104
122
  expect(types).toContain("run-finished");
105
123
 
106
- const deltas = events.filter((e): e is Extract<RunnerEvent, { type: "text-delta" }> =>
107
- e.type === "text-delta",
124
+ const deltas = events.filter(
125
+ (e): e is Extract<RunnerEvent, { type: "text-delta" }> => e.type === "text-delta",
108
126
  );
109
127
  expect(deltas.map((d) => d.delta).join("")).toBe("Hello world");
110
128
  });
@@ -121,7 +139,7 @@ describe("AGUIRunner", () => {
121
139
  } as never,
122
140
  } as never);
123
141
  await s.onToolCallArgsEvent?.({
124
- event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: "{\"q\":\"x\"}" } as never,
142
+ event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: '{"q":"x"}' } as never,
125
143
  } as never);
126
144
  await s.onToolCallEndEvent?.({
127
145
  event: { type: "TOOL_CALL_END", toolCallId: "tc-1" } as never,
@@ -140,8 +158,8 @@ describe("AGUIRunner", () => {
140
158
  const runner = new AGUIRunner();
141
159
  const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
142
160
 
143
- const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
144
- e.type === "tool-call-start",
161
+ const start = events.find(
162
+ (e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
145
163
  );
146
164
  expect(start).toBeDefined();
147
165
  expect(start!.isClientSide).toBe(false);
@@ -212,8 +230,8 @@ describe("AGUIRunner", () => {
212
230
  expect(lastAssistantMsg?.toolCalls?.[0]?.id).toBe("tc-1");
213
231
 
214
232
  // tool-call-start should have isClientSide=true
215
- const start = events.find((e): e is Extract<RunnerEvent, { type: "tool-call-start" }> =>
216
- e.type === "tool-call-start",
233
+ const start = events.find(
234
+ (e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
217
235
  );
218
236
  expect(start!.isClientSide).toBe(true);
219
237
  });
@@ -276,6 +294,63 @@ describe("AGUIRunner", () => {
276
294
  expect(lastAssistant?.content).toBe("Navigating...");
277
295
  });
278
296
 
297
+ it("preserves hidden protocol messages from AG-UI snapshots across visible UI turns", async () => {
298
+ const registry = makeRegistry({ isRegistered: () => false });
299
+ const runner = new AGUIRunner();
300
+
301
+ nextScript = [
302
+ async (s, agent) => {
303
+ agent.messages = [
304
+ {
305
+ id: "spaces-global-guidance:abc",
306
+ role: "system",
307
+ content: "Tenant guidance",
308
+ },
309
+ { id: "u-1", role: "user", content: "What is your name?" },
310
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
311
+ ];
312
+ await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
313
+ },
314
+ ];
315
+
316
+ await collectWithMessages(
317
+ runner,
318
+ [{ id: "u-1", role: "user", content: "What is your name?" }],
319
+ registry,
320
+ );
321
+
322
+ nextScript = [
323
+ async (s) => {
324
+ await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
325
+ },
326
+ ];
327
+
328
+ const events = await collectWithMessages(
329
+ runner,
330
+ [
331
+ { id: "u-1", role: "user", content: "What is your name?" },
332
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
333
+ { id: "u-2", role: "user", content: "Say your name again." },
334
+ ],
335
+ registry,
336
+ );
337
+
338
+ const request = events.find(
339
+ (event): event is Extract<RunnerEvent, { type: "request-sent" }> =>
340
+ event.type === "request-sent",
341
+ );
342
+ expect(request?.input.messages).toEqual([
343
+ {
344
+ id: "spaces-global-guidance:abc",
345
+ role: "system",
346
+ content: "Tenant guidance",
347
+ },
348
+ { id: "u-1", role: "user", content: "What is your name?" },
349
+ { id: "a-1", role: "assistant", content: "Magic Luna." },
350
+ { id: "u-2", role: "user", content: "Say your name again." },
351
+ ]);
352
+ });
353
+
279
354
  it("yields turn-started exactly once per run iteration", async () => {
280
355
  nextScript = [
281
356
  async (s) => {
@@ -320,8 +395,8 @@ describe("AGUIRunner", () => {
320
395
  const runner = new AGUIRunner();
321
396
  const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
322
397
 
323
- const err = events.find((e): e is Extract<RunnerEvent, { type: "run-error" }> =>
324
- e.type === "run-error",
398
+ const err = events.find(
399
+ (e): e is Extract<RunnerEvent, { type: "run-error" }> => e.type === "run-error",
325
400
  );
326
401
  expect(err).toBeDefined();
327
402
  expect(err!.message).toBe("boom");
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { authStore } from "../msal/auth-store.ts";
2
+ import { AuthInteractionRequiredError, authStore } from "../msal/auth-store.ts";
3
3
  import * as authConfig from "../msal/auth-config.ts";
4
4
 
5
5
  const store = () => authStore.getState();
@@ -51,6 +51,7 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
51
51
  const mock = {
52
52
  initialize: vi.fn().mockResolvedValue(undefined),
53
53
  handleRedirectPromise: vi.fn().mockResolvedValue(null),
54
+ clearCache: vi.fn().mockResolvedValue(undefined),
54
55
  getAllAccounts: vi.fn().mockReturnValue([account]),
55
56
  loginRedirect: vi.fn().mockResolvedValue(undefined),
56
57
  logoutRedirect: vi.fn().mockResolvedValue(undefined),
@@ -65,6 +66,30 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
65
66
  return mock;
66
67
  }
67
68
 
69
+ async function expectInteractiveRecoveryError(
70
+ promise: Promise<unknown>,
71
+ expected: {
72
+ code?: string;
73
+ cause: unknown;
74
+ messageIncludes: string[];
75
+ },
76
+ ) {
77
+ let thrown: unknown;
78
+ try {
79
+ await promise;
80
+ } catch (err) {
81
+ thrown = err;
82
+ }
83
+
84
+ expect(thrown).toBeInstanceOf(AuthInteractionRequiredError);
85
+ const authError = thrown as AuthInteractionRequiredError;
86
+ expect(authError.code).toBe(expected.code);
87
+ expect(authError.cause).toBe(expected.cause);
88
+ for (const value of expected.messageIncludes) {
89
+ expect(authError.message).toContain(value);
90
+ }
91
+ }
92
+
68
93
  afterEach(() => {
69
94
  vi.restoreAllMocks();
70
95
  });
@@ -79,10 +104,47 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
79
104
  const msal = mockMsal();
80
105
  expect(await store().getAccessToken()).toBe("fresh-token");
81
106
  expect(msal.acquireTokenSilent).toHaveBeenCalledOnce();
107
+ expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
108
+ expect.objectContaining({ forceRefresh: false }),
109
+ );
82
110
  expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
83
111
  expect(msal.loginRedirect).not.toHaveBeenCalled();
84
112
  });
85
113
 
114
+ it("forwards forceRefresh to acquireTokenSilent on the 401-retry path", async () => {
115
+ // The fetch interceptor passes ``{ forceRefresh: true }`` after a
116
+ // protected API returns 401. MSAL must skip its local cache and round-
117
+ // trip the token endpoint so we stop sending the same stale-but-cached
118
+ // access token.
119
+ const msal = mockMsal();
120
+ expect(await store().getAccessToken("api", { forceRefresh: true })).toBe(
121
+ "fresh-token",
122
+ );
123
+ expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
124
+ expect.objectContaining({ forceRefresh: true }),
125
+ );
126
+ });
127
+
128
+ it("recoverFromHardAuthFailure clears cache + starts loginRedirect", async () => {
129
+ // When the fetch interceptor's force-refresh retry STILL gets 401,
130
+ // the only correct UX is a fresh ``loginRedirect`` (server policy
131
+ // drift, audience mismatch, etc.). Same recovery primitive used by
132
+ // the silent path, but invoked from the resource-server signal
133
+ // rather than an MSAL exception.
134
+ const msal = mockMsal();
135
+ const reason = new Error("API returned 401 after force-refresh retry");
136
+ await expectInteractiveRecoveryError(
137
+ store().recoverFromHardAuthFailure(reason),
138
+ {
139
+ code: undefined,
140
+ cause: reason,
141
+ messageIncludes: ["interaction required"],
142
+ },
143
+ );
144
+ expect(msal.clearCache).toHaveBeenCalled();
145
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
146
+ });
147
+
86
148
  it("returns null without redirecting when no account is cached", async () => {
87
149
  // AuthGuard calls loginRedirect when accounts.length === 0; double-
88
150
  // redirecting from getAccessToken would race with AuthGuard.
@@ -93,31 +155,48 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
93
155
  expect(msal.loginRedirect).not.toHaveBeenCalled();
94
156
  });
95
157
 
96
- it("triggers acquireTokenRedirect on InteractionRequiredAuthError (canonical pattern)", async () => {
158
+ it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
97
159
  const err = Object.assign(new Error("MFA required"), {
98
160
  name: "InteractionRequiredAuthError",
99
161
  errorCode: "interaction_required",
100
162
  });
101
163
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
102
- expect(await store().getAccessToken()).toBeNull();
103
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
104
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
105
- scopes: [config.apiScope],
164
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
165
+ code: "interaction_required",
166
+ cause: err,
167
+ messageIncludes: [
168
+ "Authentication interaction required",
169
+ "InteractionRequiredAuthError",
170
+ "interaction_required",
171
+ "MFA required",
172
+ ],
173
+ });
174
+ expect(msal.clearCache).toHaveBeenCalledWith({
106
175
  account: { username: "alice@example.com", localAccountId: "alice-oid" },
107
176
  });
177
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
178
+ expect(msal.loginRedirect).toHaveBeenCalledWith({
179
+ scopes: [config.apiScope],
180
+ });
181
+ expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
108
182
  });
109
183
 
110
- it("triggers acquireTokenRedirect on consent_required", async () => {
184
+ it("clears stale cache and redirects on consent_required", async () => {
111
185
  const err = Object.assign(new Error("consent required"), {
112
186
  name: "InteractionRequiredAuthError",
113
187
  errorCode: "consent_required",
114
188
  });
115
189
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
116
- expect(await store().getAccessToken()).toBeNull();
117
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
190
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
191
+ code: "consent_required",
192
+ cause: err,
193
+ messageIncludes: ["Authentication interaction required", "consent_required"],
194
+ });
195
+ expect(msal.clearCache).toHaveBeenCalledOnce();
196
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
118
197
  });
119
198
 
120
- it("triggers acquireTokenRedirect on monitor_window_timeout (third-party-iframe block)", async () => {
199
+ it("clears stale cache and redirects on monitor_window_timeout", async () => {
121
200
  // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
122
201
  // → documented remedy includes "Invoke an interactive API".
123
202
  const err = Object.assign(new Error("monitor_window_timeout"), {
@@ -125,8 +204,13 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
125
204
  errorCode: "monitor_window_timeout",
126
205
  });
127
206
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
128
- expect(await store().getAccessToken()).toBeNull();
129
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
207
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
208
+ code: "monitor_window_timeout",
209
+ cause: err,
210
+ messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
211
+ });
212
+ expect(msal.clearCache).toHaveBeenCalledOnce();
213
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
130
214
  });
131
215
 
132
216
  it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
@@ -163,7 +247,40 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
163
247
  name: "InteractionRequiredAuthError",
164
248
  });
165
249
  const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
166
- expect(await store().getAccessToken()).toBeNull();
167
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
250
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
251
+ cause: err,
252
+ messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
253
+ });
254
+ expect(msal.clearCache).toHaveBeenCalledOnce();
255
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
256
+ });
257
+
258
+ it("preserves redirect failure details when interactive recovery cannot start", async () => {
259
+ const tokenErr = Object.assign(new Error("MFA required"), {
260
+ name: "InteractionRequiredAuthError",
261
+ errorCode: "interaction_required",
262
+ });
263
+ const redirectErr = Object.assign(new Error("redirect blocked"), {
264
+ name: "BrowserAuthError",
265
+ errorCode: "redirect_failed",
266
+ });
267
+ const msal = mockMsal({
268
+ acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
269
+ loginRedirect: vi.fn().mockRejectedValue(redirectErr),
270
+ });
271
+
272
+ await expectInteractiveRecoveryError(store().getAccessToken(), {
273
+ code: "redirect_failed",
274
+ cause: redirectErr,
275
+ messageIncludes: [
276
+ "Authentication interaction required",
277
+ "login redirect failed",
278
+ "redirect_failed",
279
+ "Original token error",
280
+ "interaction_required",
281
+ ],
282
+ });
283
+ expect(msal.clearCache).toHaveBeenCalledOnce();
284
+ expect(msal.loginRedirect).toHaveBeenCalledOnce();
168
285
  });
169
286
  });
@@ -172,15 +172,207 @@ describe("createServiceFetch", () => {
172
172
  expect(merged.get("X-Init-Only")).toBe("init");
173
173
  });
174
174
 
175
- it("recovers when acquireToken throws", async () => {
175
+ it("does not downgrade token failures to anonymous protected calls", async () => {
176
176
  const acquireToken = vi.fn().mockRejectedValue(new Error("token boom"));
177
177
  const serviceFetch = createServiceFetch({ acquireToken });
178
178
 
179
- await serviceFetch("/api/spaces/entities");
179
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow("token boom");
180
180
 
181
- expect(fetchSpy).toHaveBeenCalledTimes(1);
182
- const [, init] = fetchSpy.mock.calls[0]!;
183
- const headers = new Headers(init?.headers);
184
- expect(headers.get("Authorization")).toBeNull();
181
+ expect(fetchSpy).not.toHaveBeenCalled();
182
+ });
183
+
184
+ describe("401 retry with forceRefresh", () => {
185
+ it("retries once with forceRefresh when first attempt returns 401", async () => {
186
+ // Resource server rejects the first (stale-cached) token, then accepts
187
+ // the freshly-acquired one. This is the long-lived-tab recovery path.
188
+ fetchSpy.mockReset();
189
+ fetchSpy
190
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
191
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
192
+ const acquireToken = vi
193
+ .fn()
194
+ .mockResolvedValueOnce("stale-token")
195
+ .mockResolvedValueOnce("fresh-token");
196
+ const serviceFetch = createServiceFetch({ acquireToken });
197
+
198
+ const res = await serviceFetch("/api/spaces/entities");
199
+
200
+ expect(res.status).toBe(200);
201
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
202
+ expect(acquireToken).toHaveBeenCalledTimes(2);
203
+ // First call: no forceRefresh (use cached token)
204
+ expect(acquireToken).toHaveBeenNthCalledWith(1, undefined);
205
+ // Second call: forceRefresh: true (skip MSAL cache, hit token endpoint)
206
+ expect(acquireToken).toHaveBeenNthCalledWith(2, { forceRefresh: true });
207
+
208
+ const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
209
+ "Authorization",
210
+ );
211
+ const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
212
+ "Authorization",
213
+ );
214
+ expect(firstAuth).toBe("Bearer stale-token");
215
+ expect(secondAuth).toBe("Bearer fresh-token");
216
+ });
217
+
218
+ it("does not retry on non-401 responses", async () => {
219
+ fetchSpy.mockReset();
220
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }));
221
+ const acquireToken = vi.fn().mockResolvedValue("token");
222
+ const serviceFetch = createServiceFetch({ acquireToken });
223
+
224
+ const res = await serviceFetch("/api/spaces/entities");
225
+
226
+ expect(res.status).toBe(500);
227
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
228
+ expect(acquireToken).toHaveBeenCalledTimes(1);
229
+ });
230
+
231
+ it("does not retry when caller supplied their own Authorization header", async () => {
232
+ // The caller owns the token in this case; retrying would let our auth
233
+ // layer overwrite their explicit header on the second attempt.
234
+ fetchSpy.mockReset();
235
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
236
+ const acquireToken = vi.fn();
237
+ const serviceFetch = createServiceFetch({ acquireToken });
238
+
239
+ const res = await serviceFetch("/api/spaces/entities", {
240
+ headers: { Authorization: "Bearer caller-supplied" },
241
+ });
242
+
243
+ expect(res.status).toBe(401);
244
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
245
+ expect(acquireToken).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("preserves a POST body across the 401 retry", async () => {
249
+ // Regression for the body-stream-consumed bug: ReadableStream bodies
250
+ // are single-consume, so the retry would otherwise see an empty body
251
+ // and a server-side validation failure that masks the real auth
252
+ // recovery. We assert the second attempt receives the same body.
253
+ fetchSpy.mockReset();
254
+ fetchSpy
255
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
256
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
257
+ const acquireToken = vi
258
+ .fn()
259
+ .mockResolvedValueOnce("stale-token")
260
+ .mockResolvedValueOnce("fresh-token");
261
+ const serviceFetch = createServiceFetch({ acquireToken });
262
+
263
+ const payload = JSON.stringify({ name: "create-this", id: 42 });
264
+ const request = new Request("https://example.com/api/items", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: payload,
268
+ });
269
+
270
+ const res = await serviceFetch(request);
271
+
272
+ expect(res.status).toBe(200);
273
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
274
+
275
+ // Each fetch call's body must be readable and equal to the original.
276
+ // Awaiting both confirms neither call's body stream was already
277
+ // drained by the time fetch received it.
278
+ const firstAttempt = fetchSpy.mock.calls[0]![1];
279
+ const secondAttempt = fetchSpy.mock.calls[1]![1];
280
+ const firstBody = await new Response(firstAttempt?.body as BodyInit).text();
281
+ const secondBody = await new Response(secondAttempt?.body as BodyInit).text();
282
+ expect(firstBody).toBe(payload);
283
+ expect(secondBody).toBe(payload);
284
+ });
285
+
286
+ it("only retries once even if the second attempt also returns 401", async () => {
287
+ // Hard auth failure (refresh token expired). With no
288
+ // ``recoverFromHardAuthFailure`` configured, the second 401
289
+ // propagates to the caller. (Tests cover the recovery path
290
+ // separately below.)
291
+ fetchSpy.mockReset();
292
+ fetchSpy
293
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
294
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
295
+ const acquireToken = vi.fn().mockResolvedValue("token");
296
+ const serviceFetch = createServiceFetch({ acquireToken });
297
+
298
+ const res = await serviceFetch("/api/spaces/entities");
299
+
300
+ expect(res.status).toBe(401);
301
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
302
+ expect(acquireToken).toHaveBeenCalledTimes(2);
303
+ });
304
+
305
+ it("kicks off interactive recovery after a second 401", async () => {
306
+ // Server-policy drift: even the force-refreshed token is rejected.
307
+ // The fetch interceptor must escalate to loginRedirect via the
308
+ // recoverFromHardAuthFailure callback — without it the user is
309
+ // stuck in a silent 401 loop forever (the bug this fix exists for).
310
+ fetchSpy.mockReset();
311
+ fetchSpy
312
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
313
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
314
+ const acquireToken = vi
315
+ .fn()
316
+ .mockResolvedValueOnce("stale-token")
317
+ .mockResolvedValueOnce("force-refreshed-but-still-bad-token");
318
+ const recoverFromHardAuthFailure = vi
319
+ .fn()
320
+ .mockRejectedValue(new Error("loginRedirect in flight"));
321
+ const serviceFetch = createServiceFetch({
322
+ acquireToken,
323
+ recoverFromHardAuthFailure,
324
+ });
325
+
326
+ await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow(
327
+ "loginRedirect in flight",
328
+ );
329
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
330
+ expect(acquireToken).toHaveBeenCalledTimes(2);
331
+ expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
332
+ // Reason carries enough context to log/alert without leaking tokens.
333
+ const reason = recoverFromHardAuthFailure.mock.calls[0]![0];
334
+ expect(reason).toBeInstanceOf(Error);
335
+ expect((reason as Error).message).toMatch(/401/);
336
+ });
337
+
338
+ it("does NOT call recoverFromHardAuthFailure on first-attempt success", async () => {
339
+ // Sanity: when the first request succeeds, recovery callback must
340
+ // never fire.
341
+ fetchSpy.mockReset();
342
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 }));
343
+ const acquireToken = vi.fn().mockResolvedValue("good-token");
344
+ const recoverFromHardAuthFailure = vi.fn();
345
+ const serviceFetch = createServiceFetch({
346
+ acquireToken,
347
+ recoverFromHardAuthFailure,
348
+ });
349
+
350
+ const res = await serviceFetch("/api/spaces/entities");
351
+ expect(res.status).toBe(200);
352
+ expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
353
+ });
354
+
355
+ it("does NOT call recoverFromHardAuthFailure when the retry recovers", async () => {
356
+ // Standard recovery — first 401, retry with forceRefresh succeeds.
357
+ // Recovery callback must not fire (would needlessly bounce the user
358
+ // through loginRedirect).
359
+ fetchSpy.mockReset();
360
+ fetchSpy
361
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
362
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
363
+ const acquireToken = vi
364
+ .fn()
365
+ .mockResolvedValueOnce("stale")
366
+ .mockResolvedValueOnce("fresh");
367
+ const recoverFromHardAuthFailure = vi.fn();
368
+ const serviceFetch = createServiceFetch({
369
+ acquireToken,
370
+ recoverFromHardAuthFailure,
371
+ });
372
+
373
+ const res = await serviceFetch("/api/spaces/entities");
374
+ expect(res.status).toBe(200);
375
+ expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
376
+ });
185
377
  });
186
378
  });
@@ -1,11 +1,13 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  const getAccessToken = vi.fn();
4
+ const recoverFromHardAuthFailure = vi.fn();
4
5
 
5
6
  vi.mock("../msal/auth-store.ts", () => ({
6
7
  authStore: {
7
8
  getState: () => ({
8
9
  getAccessToken,
10
+ recoverFromHardAuthFailure,
9
11
  }),
10
12
  },
11
13
  }));
@@ -15,6 +17,7 @@ import { tokenFetch } from "../msal/token-fetch.ts";
15
17
  describe("tokenFetch", () => {
16
18
  beforeEach(() => {
17
19
  getAccessToken.mockReset();
20
+ recoverFromHardAuthFailure.mockReset();
18
21
  vi.restoreAllMocks();
19
22
  });
20
23
 
@@ -62,4 +65,70 @@ describe("tokenFetch", () => {
62
65
  const [, init] = fetchSpy.mock.calls[0]!;
63
66
  expect(init).toBeUndefined();
64
67
  });
68
+
69
+ it("retries once with forceRefresh when first attempt returns 401", async () => {
70
+ // Long-lived tab path: cached token is rejected by the API → MSAL is
71
+ // asked to round-trip the token endpoint with the refresh token.
72
+ getAccessToken
73
+ .mockResolvedValueOnce("stale-token")
74
+ .mockResolvedValueOnce("fresh-token");
75
+ const fetchSpy = vi
76
+ .spyOn(globalThis, "fetch")
77
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
78
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
79
+
80
+ const res = await tokenFetch("https://example.com/api/items");
81
+
82
+ expect(res.status).toBe(200);
83
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
84
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
85
+ expect(getAccessToken).toHaveBeenNthCalledWith(1, "api", undefined);
86
+ expect(getAccessToken).toHaveBeenNthCalledWith(2, "api", { forceRefresh: true });
87
+
88
+ const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
89
+ "Authorization",
90
+ );
91
+ const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
92
+ "Authorization",
93
+ );
94
+ expect(firstAuth).toBe("Bearer stale-token");
95
+ expect(secondAuth).toBe("Bearer fresh-token");
96
+ });
97
+
98
+ it("does not retry on non-401 responses", async () => {
99
+ getAccessToken.mockResolvedValue("token");
100
+ const fetchSpy = vi
101
+ .spyOn(globalThis, "fetch")
102
+ .mockResolvedValueOnce(new Response(null, { status: 500 }));
103
+
104
+ const res = await tokenFetch("https://example.com/api/items");
105
+
106
+ expect(res.status).toBe(500);
107
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
108
+ expect(getAccessToken).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it("escalates to interactive recovery on a hard 401-then-401 path", async () => {
112
+ // Server-policy drift: even the force-refreshed token is rejected.
113
+ // The fetch interceptor must call ``recoverFromHardAuthFailure`` so
114
+ // the auth layer can kick off ``loginRedirect``. The recovery
115
+ // throws once the redirect is in flight; the throw stops the
116
+ // calling pipeline.
117
+ getAccessToken.mockResolvedValue("token");
118
+ recoverFromHardAuthFailure.mockRejectedValue(
119
+ new Error("loginRedirect in flight"),
120
+ );
121
+ const fetchSpy = vi
122
+ .spyOn(globalThis, "fetch")
123
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
124
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
125
+
126
+ await expect(tokenFetch("https://example.com/api/items")).rejects.toThrow(
127
+ "loginRedirect in flight",
128
+ );
129
+
130
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
131
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
132
+ expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
133
+ });
65
134
  });
@@ -48,6 +48,51 @@ export interface AGUIRunInput {
48
48
  context?: Context[];
49
49
  }
50
50
 
51
+ function shouldPreserveAcrossVisibleHistory(message: Message): boolean {
52
+ return message.role === "system" || message.role === "developer" || message.role === "reasoning";
53
+ }
54
+
55
+ function mergeProtocolMessagesFromSnapshot(
56
+ previousMessages: Message[],
57
+ visibleMessages: Message[],
58
+ ): Message[] {
59
+ if (previousMessages.length === 0) return [...visibleMessages];
60
+
61
+ const visibleById = new Map(visibleMessages.map((message) => [message.id, message]));
62
+ const previousVisibleIds = new Set(
63
+ previousMessages
64
+ .filter((message) => !shouldPreserveAcrossVisibleHistory(message))
65
+ .map((message) => message.id),
66
+ );
67
+ const isSameVisibleThread = visibleMessages.some((message) => previousVisibleIds.has(message.id));
68
+ if (!isSameVisibleThread) return [...visibleMessages];
69
+
70
+ const merged: Message[] = [];
71
+ const emitted = new Set<string>();
72
+
73
+ for (const previous of previousMessages) {
74
+ const currentVisible = visibleById.get(previous.id);
75
+ if (currentVisible) {
76
+ merged.push(currentVisible);
77
+ emitted.add(currentVisible.id);
78
+ continue;
79
+ }
80
+
81
+ if (shouldPreserveAcrossVisibleHistory(previous)) {
82
+ merged.push(previous);
83
+ emitted.add(previous.id);
84
+ }
85
+ }
86
+
87
+ for (const message of visibleMessages) {
88
+ if (!emitted.has(message.id)) {
89
+ merged.push(message);
90
+ }
91
+ }
92
+
93
+ return merged;
94
+ }
95
+
51
96
  /**
52
97
  * Bridges callback-driven `AgentSubscriber` events into an async-iterable
53
98
  * queue the runner's generator drains. Single-producer / single-consumer.
@@ -126,10 +171,17 @@ export class AGUIRunner {
126
171
  async *run(input: AGUIRunInput): AsyncGenerator<RunnerEvent> {
127
172
  const { messages, state, registry, abortSignal, context } = input;
128
173
  const toolCalls = new Map<string, ToolCallState>();
129
- let currentMessages: Message[] = [...messages];
174
+ let currentMessages: Message[] = mergeProtocolMessagesFromSnapshot(
175
+ this.httpAgent.messages,
176
+ messages,
177
+ );
130
178
 
131
- // Replace once at the top: subsequent turns append to currentMessages
132
- // (per AG-UI convention see footgun in apps/web AGENTS.md cross-link).
179
+ // Replace once at the top with the reconciled AG-UI history. assistant-ui
180
+ // stores only visible chat turns, while AG-UI snapshots may contain
181
+ // model-visible but UI-hidden protocol messages such as system/developer
182
+ // guidance. Preserve those messages across turns when the visible history
183
+ // belongs to the same thread so the next request remains a complete AG-UI
184
+ // conversation without leaking system messages into the rendered chat.
133
185
  this.httpAgent.setMessages(currentMessages);
134
186
  if (state) this.httpAgent.setState(state);
135
187
 
@@ -17,8 +17,35 @@ export interface ServiceFetchOptions {
17
17
  * Acquire an access token for outgoing requests. Return `null` to skip
18
18
  * token attachment (callers without auth — e.g. local dev — pass through
19
19
  * to native fetch).
20
+ *
21
+ * The optional ``{ forceRefresh: true }`` argument is passed by the
22
+ * fetch interceptor on a 401 retry — the auth layer should bypass
23
+ * its local token cache and round-trip the token endpoint so we
24
+ * stop re-sending an access token the resource server has already
25
+ * rejected (canonical MSAL.js fix for tab-open-overnight 401 loops).
20
26
  */
21
- acquireToken: () => Promise<string | null>;
27
+ acquireToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>;
28
+
29
+ /**
30
+ * Force interactive recovery (e.g. ``loginRedirect``) when even a
31
+ * force-refreshed access token gets rejected by the resource server.
32
+ * The fetch interceptor calls this after a SECOND consecutive 401 —
33
+ * at that point we know the silent refresh produced a token the
34
+ * server still won't accept (audience drift, conditional-access
35
+ * re-eval, tenant-policy change), and the only correct UX is to
36
+ * mint a fresh session.
37
+ *
38
+ * Implementations should clear cached auth state and start a redirect
39
+ * to the IdP. They MUST throw rather than return so the fetch caller
40
+ * can stop processing the in-flight request — when this resolves
41
+ * normally the redirect is in flight and the page is about to
42
+ * navigate away.
43
+ *
44
+ * Optional: when omitted, the fetch interceptor lets the second 401
45
+ * propagate as-is. Hosts without an interactive recovery path (e.g.
46
+ * tests, embedded apps) should leave it unset.
47
+ */
48
+ recoverFromHardAuthFailure?: (reason: unknown) => Promise<never>;
22
49
 
23
50
  /**
24
51
  * Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
@@ -66,47 +93,105 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
66
93
  // Request-input branch must preserve method / body / credentials /
67
94
  // signal / etc. — silently dropping them by reading only `.url` would
68
95
  // turn POST/PUT into GET and drop required headers.
69
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
96
+ const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
70
97
  if (init?.headers) {
71
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
72
- }
73
- if (!headers.has("Authorization")) {
74
- try {
75
- const token = await options.acquireToken();
76
- if (token) {
77
- headers.set("Authorization", `Bearer ${token}`);
78
- }
79
- } catch {
80
- // Token acquisition failed — proceed without
81
- }
98
+ new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
82
99
  }
100
+ const callerSuppliedAuth = baseHeaders.has("Authorization");
83
101
 
84
- if (input instanceof Request) {
85
- // Build a fresh Request against the rewritten URL, copying every
86
- // field the runtime exposes on the originalexplicit field copy
87
- // is more portable than `new Request(url, originalRequest)`, which
88
- // some runtimes (older undici/JSDOM in particular) do not honor
89
- // for method/body. `init` then overrides anything the caller passed.
90
- const cloned = input.clone();
91
- const requestInit: RequestInit = {
92
- method: cloned.method,
93
- body:
94
- cloned.method === "GET" || cloned.method === "HEAD" ? undefined : cloned.body,
95
- headers,
96
- credentials: cloned.credentials,
97
- mode: cloned.mode,
98
- cache: cloned.cache,
99
- redirect: cloned.redirect,
100
- referrer: cloned.referrer,
101
- integrity: cloned.integrity,
102
- signal: cloned.signal,
102
+ function buildRequestInitFrom(req: Request): RequestInit {
103
+ // ReadableStream bodies are single-consume. ``req.clone()`` returns a
104
+ // fresh Request whose body stream is independentcall it once per
105
+ // dispatch attempt so the 401 retry can replay POST/PUT bodies
106
+ // intact.
107
+ const fresh = req.clone();
108
+ const ri: RequestInit = {
109
+ method: fresh.method,
110
+ body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
111
+ credentials: fresh.credentials,
112
+ mode: fresh.mode,
113
+ cache: fresh.cache,
114
+ redirect: fresh.redirect,
115
+ referrer: fresh.referrer,
116
+ integrity: fresh.integrity,
117
+ signal: fresh.signal,
103
118
  };
104
119
  // Streaming bodies need duplex: "half"; harmless when there's no body.
105
- if (cloned.body !== null) {
106
- (requestInit as RequestInit & { duplex?: string }).duplex = "half";
120
+ if (fresh.body !== null) {
121
+ (ri as RequestInit & { duplex?: string }).duplex = "half";
122
+ }
123
+ return ri;
124
+ }
125
+
126
+ async function dispatch(forceRefreshToken: boolean): Promise<Response> {
127
+ const headers = new Headers(baseHeaders);
128
+ if (!callerSuppliedAuth) {
129
+ try {
130
+ const token = await options.acquireToken(
131
+ forceRefreshToken ? { forceRefresh: true } : undefined,
132
+ );
133
+ if (token) {
134
+ headers.set("Authorization", `Bearer ${token}`);
135
+ }
136
+ } 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;
142
+ }
107
143
  }
108
- return fetch(resolvedUrl, { ...requestInit, ...init, headers });
144
+ if (input instanceof Request) {
145
+ // Re-clone for THIS attempt — the body stream of the original is
146
+ // either still pristine (first attempt) or already consumed
147
+ // (second attempt); ``clone()`` always returns a fresh, replayable
148
+ // copy.
149
+ const requestInit = buildRequestInitFrom(input);
150
+ return fetch(resolvedUrl, { ...requestInit, ...init, headers });
151
+ }
152
+ return fetch(resolvedUrl, { ...init, headers });
153
+ }
154
+
155
+ const response = await dispatch(false);
156
+
157
+ // Long-lived tab recovery: when the resource server returns 401 the
158
+ // local MSAL cache may still hold a token MSAL itself thinks is valid
159
+ // (claims challenge, conditional-access re-eval, audience drift, or
160
+ // the user simply left the tab open past the cached access token's
161
+ // server-side validity). Force-refresh the token via the refresh
162
+ // token grant and replay the request once. If the refresh-token is
163
+ // also gone (24h SPA cap), ``acquireToken`` will throw an
164
+ // interaction-required error and the auth layer kicks off
165
+ // ``loginRedirect`` — that's the only correct UX for a hard expiry.
166
+ //
167
+ // Conditions for retry: 401, no caller-supplied auth header (we own
168
+ // the token), and the server didn't already see a fresh token (we
169
+ // only retry once).
170
+ if (response.status !== 401 || callerSuppliedAuth) {
171
+ return response;
172
+ }
173
+ // Drain the failed response body — letting it sit unread keeps the
174
+ // underlying connection occupied on some runtimes.
175
+ 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.
186
+ if (retried.status === 401 && options.recoverFromHardAuthFailure) {
187
+ retried.body?.cancel().catch(() => undefined);
188
+ // ``recoverFromHardAuthFailure`` throws once the redirect is in
189
+ // flight; the throw stops the calling pipeline so we don't
190
+ // return a stale 401 the caller might handle as a real failure.
191
+ await options.recoverFromHardAuthFailure(
192
+ new Error("API returned 401 after force-refresh retry"),
193
+ );
109
194
  }
110
- return fetch(resolvedUrl, { ...init, headers });
195
+ return retried;
111
196
  };
112
197
  }
@@ -25,6 +25,7 @@ export interface MsalAccountInfo {
25
25
  export interface MsalClientApplication {
26
26
  initialize(): Promise<void>;
27
27
  handleRedirectPromise(): Promise<unknown>;
28
+ clearCache(request?: { account?: MsalAccountInfo }): Promise<void>;
28
29
  getAllAccounts(): MsalAccountInfo[];
29
30
  loginRedirect(request: { scopes: string[]; prompt?: string }): Promise<void>;
30
31
  logoutRedirect(): Promise<void>;
@@ -32,6 +33,16 @@ export interface MsalClientApplication {
32
33
  acquireTokenSilent(request: {
33
34
  scopes: string[];
34
35
  account: MsalAccountInfo;
36
+ /**
37
+ * Skip MSAL's local cache and force a round-trip to the token
38
+ * endpoint using the cached refresh token. The fetch interceptor
39
+ * uses this on a 401 retry — the first attempt may have served a
40
+ * cached access token MSAL still thought valid (within
41
+ * ``tokenRenewalOffsetSeconds``) that the resource server has
42
+ * since rejected (claims challenge, conditional access re-eval,
43
+ * audience drift).
44
+ */
45
+ forceRefresh?: boolean;
35
46
  }): Promise<{ accessToken: string }>;
36
47
  acquireTokenRedirect(request: {
37
48
  scopes: string[];
@@ -1,5 +1,6 @@
1
1
  import { createStore } from "zustand/vanilla";
2
2
  import { getMsalInstance, getMsalConfig } from "./auth-config.ts";
3
+ import type { MsalAccountInfo, MsalClientApplication } from "./auth-config.ts";
3
4
 
4
5
  export interface AuthUser {
5
6
  name: string;
@@ -8,6 +9,18 @@ export interface AuthUser {
8
9
  oid?: string;
9
10
  }
10
11
 
12
+ export class AuthInteractionRequiredError extends Error {
13
+ readonly code?: string;
14
+ readonly cause?: unknown;
15
+
16
+ constructor(message: string, options: { code?: string; cause?: unknown } = {}) {
17
+ super(message);
18
+ this.name = "AuthInteractionRequiredError";
19
+ this.code = options.code;
20
+ this.cause = options.cause;
21
+ }
22
+ }
23
+
11
24
  interface AuthState {
12
25
  user: AuthUser | null;
13
26
  isAuthenticated: boolean;
@@ -25,13 +38,8 @@ interface AuthState {
25
38
  * Recovery semantics — follows the canonical MSAL.js pattern documented
26
39
  * at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
27
40
  *
28
- * try {
29
- * await msal.acquireTokenSilent(req);
30
- * } catch (error) {
31
- * if (error instanceof InteractionRequiredAuthError) {
32
- * await msal.acquireTokenRedirect(req);
33
- * }
34
- * }
41
+ * Try acquireTokenSilent first, then use an interactive redirect when
42
+ * MSAL reports that user interaction is required.
35
43
  *
36
44
  * Plus one extra recoverable case explicitly called out in those docs:
37
45
  * `BrowserAuthError: monitor_window_timeout`. Microsoft's recommendation
@@ -41,6 +49,16 @@ interface AuthState {
41
49
  * blocking on Chrome 120+ / Edge / Safari (the silent iframe times
42
50
  * out because the cross-site cookie is partitioned).
43
51
  *
52
+ * Before starting that redirect, clear the stale local account/token
53
+ * cache. This avoids the broken half-authenticated state where the SPA
54
+ * still renders an account but every API call goes out without a bearer
55
+ * token after MSAL returned `interaction_required`.
56
+ *
57
+ * Recoverable auth failures reject with `AuthInteractionRequiredError`
58
+ * after recovery has been started. The error exposes `code` and `cause`
59
+ * so callers can distinguish a normal interaction-required redirect from
60
+ * a blocked/failed redirect attempt.
61
+ *
44
62
  * Other `BrowserAuthError` codes (`interaction_in_progress`,
45
63
  * `hash_empty_error`, `hash_does_not_contain_known_properties`,
46
64
  * `block_iframe_reload`) are config / race-condition bugs that another
@@ -54,8 +72,29 @@ interface AuthState {
54
72
  * Errors are matched by `name` / `errorCode` rather than `instanceof`
55
73
  * because `@azure/msal-browser` is loaded via dynamic import; the
56
74
  * error class identity isn't shared across module boundaries.
75
+ *
76
+ * `forceRefresh: true` skips MSAL's local cache and goes back to the
77
+ * token endpoint with the cached refresh token. Use it from the
78
+ * fetch interceptor when a protected API returns 401 — the first
79
+ * attempt may have used a stale cached access token (claims
80
+ * challenge, conditional-access re-eval, audience drift, etc.) that
81
+ * MSAL still considered valid against its own clock.
82
+ */
83
+ getAccessToken: (
84
+ audience?: "api" | "spaces",
85
+ options?: { forceRefresh?: boolean },
86
+ ) => Promise<string | null>;
87
+
88
+ /**
89
+ * Force interactive recovery: clear MSAL's cache for the active account
90
+ * and start a ``loginRedirect``. Use this from the fetch interceptor
91
+ * when even a force-refreshed access token still gets rejected by the
92
+ * resource server (the silent path can't tell us "this is fundamentally
93
+ * the wrong token" — it has to come from the protected API saying 401
94
+ * after we already retried). Throws ``AuthInteractionRequiredError``
95
+ * once the redirect has been kicked off so the caller can stop processing.
57
96
  */
58
- getAccessToken: (audience?: "api" | "spaces") => Promise<string | null>;
97
+ recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
59
98
  }
60
99
 
61
100
  /** Recoverable error codes per MSAL.js docs — every one of these has the
@@ -77,6 +116,8 @@ const RECOVERABLE_ERROR_CODES = new Set([
77
116
  * class name as a fallback when the error code isn't set. */
78
117
  const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
79
118
 
119
+ let interactiveRecoveryStarted = false;
120
+
80
121
  function isRecoverableAuthError(err: unknown): boolean {
81
122
  if (!err || typeof err !== "object") return false;
82
123
  const name = (err as { name?: string }).name ?? "";
@@ -84,6 +125,81 @@ function isRecoverableAuthError(err: unknown): boolean {
84
125
  return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
85
126
  }
86
127
 
128
+ function authErrorName(err: unknown): string | undefined {
129
+ return err && typeof err === "object" ? (err as { name?: string }).name : undefined;
130
+ }
131
+
132
+ function authErrorCode(err: unknown): string | undefined {
133
+ return err && typeof err === "object" ? (err as { errorCode?: string }).errorCode : undefined;
134
+ }
135
+
136
+ function authErrorMessage(err: unknown): string | undefined {
137
+ if (err instanceof Error) return err.message;
138
+ return err && typeof err === "object" ? (err as { message?: string }).message : undefined;
139
+ }
140
+
141
+ function describeAuthError(err: unknown): string {
142
+ const parts = [
143
+ authErrorName(err) ? `name=${authErrorName(err)}` : undefined,
144
+ authErrorCode(err) ? `code=${authErrorCode(err)}` : undefined,
145
+ authErrorMessage(err) ? `message=${authErrorMessage(err)}` : undefined,
146
+ ].filter(Boolean);
147
+ return parts.join(", ");
148
+ }
149
+
150
+ function createInteractionRequiredError(
151
+ tokenError: unknown,
152
+ redirectError?: unknown,
153
+ ): AuthInteractionRequiredError {
154
+ const tokenDetail = describeAuthError(tokenError) || "unknown token acquisition error";
155
+ const redirectDetail = redirectError ? describeAuthError(redirectError) : "";
156
+
157
+ if (redirectError) {
158
+ return new AuthInteractionRequiredError(
159
+ `Authentication interaction required, but login redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
160
+ {
161
+ code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
162
+ cause: redirectError,
163
+ },
164
+ );
165
+ }
166
+
167
+ return new AuthInteractionRequiredError(
168
+ `Authentication interaction required after token acquisition failed (${tokenDetail}).`,
169
+ {
170
+ code: authErrorCode(tokenError),
171
+ cause: tokenError,
172
+ },
173
+ );
174
+ }
175
+
176
+ async function startInteractiveRecovery(
177
+ msal: MsalClientApplication,
178
+ account: MsalAccountInfo,
179
+ scope: string,
180
+ reason: unknown,
181
+ ): Promise<never> {
182
+ let redirectError: unknown;
183
+ if (!interactiveRecoveryStarted) {
184
+ interactiveRecoveryStarted = true;
185
+ authStore.setState({ user: null, isAuthenticated: false });
186
+ msal.setActiveAccount(null);
187
+ await msal.clearCache({ account }).catch(() => undefined);
188
+ try {
189
+ await msal.loginRedirect({ scopes: [scope] });
190
+ } catch (err) {
191
+ // The redirect is expected to navigate away; if it settles by throwing,
192
+ // keep that failure attached so logs/UI can show what blocked recovery.
193
+ redirectError = err;
194
+ } finally {
195
+ // In tests or blocked-popup environments the promise can settle without
196
+ // navigation. Allow a later user action/retry to start recovery again.
197
+ interactiveRecoveryStarted = false;
198
+ }
199
+ }
200
+ throw createInteractionRequiredError(reason, redirectError);
201
+ }
202
+
87
203
  export const authStore = createStore<AuthState>((set) => ({
88
204
  user: null,
89
205
  isAuthenticated: false,
@@ -98,7 +214,7 @@ export const authStore = createStore<AuthState>((set) => ({
98
214
  }
99
215
  },
100
216
 
101
- getAccessToken: async (_audience = "api") => {
217
+ getAccessToken: async (_audience = "api", options) => {
102
218
  const msal = getMsalInstance();
103
219
  const config = getMsalConfig();
104
220
  if (!msal || !config) return null;
@@ -112,25 +228,45 @@ export const authStore = createStore<AuthState>((set) => ({
112
228
 
113
229
  // Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
114
230
  const scope = config.apiScope;
231
+ const forceRefresh = options?.forceRefresh === true;
115
232
 
116
233
  try {
117
234
  const result = await msal.acquireTokenSilent({
118
235
  scopes: [scope],
119
236
  account: accounts[0],
237
+ forceRefresh,
120
238
  });
121
239
  return result.accessToken;
122
240
  } catch (err) {
123
241
  if (isRecoverableAuthError(err)) {
124
- try {
125
- await msal.acquireTokenRedirect({
126
- scopes: [scope],
127
- account: accounts[0],
128
- });
129
- } catch {
130
- // Redirect navigates away; nothing to return.
131
- }
242
+ return startInteractiveRecovery(msal, accounts[0], scope, err);
132
243
  }
133
244
  return null;
134
245
  }
135
246
  },
247
+
248
+ recoverFromHardAuthFailure: async (reason) => {
249
+ // The silent path produced a token but the resource server rejected
250
+ // it (audience drift, conditional-access re-eval, claims challenge,
251
+ // tenant-policy change, etc.). The cached refresh token can't help
252
+ // — a token minted from it would carry the same identity claims —
253
+ // so the only correct UX is a full ``loginRedirect`` that produces
254
+ // a fresh token bound to the current server policy.
255
+ const msal = getMsalInstance();
256
+ const config = getMsalConfig();
257
+ if (!msal || !config) {
258
+ throw new AuthInteractionRequiredError(
259
+ "Authentication interaction required (no MSAL instance configured).",
260
+ { cause: reason },
261
+ );
262
+ }
263
+ const accounts = msal.getAllAccounts();
264
+ if (accounts.length === 0) {
265
+ throw new AuthInteractionRequiredError(
266
+ "Authentication interaction required (no cached account).",
267
+ { cause: reason },
268
+ );
269
+ }
270
+ return startInteractiveRecovery(msal, accounts[0], config.apiScope, reason);
271
+ },
136
272
  }));
package/src/msal/index.ts CHANGED
@@ -1,4 +1,8 @@
1
- export { authStore, type AuthUser } from "./auth-store.ts";
1
+ export {
2
+ AuthInteractionRequiredError,
3
+ authStore,
4
+ type AuthUser,
5
+ } from "./auth-store.ts";
2
6
  export {
3
7
  type MsalAuthConfig,
4
8
  type MsalAccountInfo,
@@ -7,6 +7,11 @@
7
7
  * downstream services (e.g., Spaces).
8
8
  *
9
9
  * When MSAL is not configured, behaves identically to native `fetch()`.
10
+ *
11
+ * Long-lived tab recovery: on 401 the wrapper retries once with
12
+ * ``forceRefresh: true`` so the resource server doesn't keep seeing a
13
+ * stale-but-cached access token. See ``service-fetch.ts`` for the same
14
+ * pattern with URL rewriting + the deeper rationale.
10
15
  */
11
16
 
12
17
  import { authStore } from "./auth-store.ts";
@@ -15,16 +20,49 @@ export async function tokenFetch(
15
20
  input: string | URL | Request,
16
21
  init?: RequestInit,
17
22
  ): Promise<Response> {
18
- const token = await authStore.getState().getAccessToken("api");
23
+ async function dispatch(forceRefreshToken: boolean): Promise<Response> {
24
+ const token = await authStore
25
+ .getState()
26
+ .getAccessToken("api", forceRefreshToken ? { forceRefresh: true } : undefined);
27
+
28
+ // Clone Request inputs per-attempt: ReadableStream bodies are single-
29
+ // consume, so the 401 retry would otherwise see an empty body.
30
+ // ``input.clone()`` returns a fresh Request whose body stream is
31
+ // independent of the original.
32
+ const target = input instanceof Request ? input.clone() : input;
19
33
 
20
- if (!token) {
21
- return fetch(input, init);
34
+ if (!token) {
35
+ return fetch(target, init);
36
+ }
37
+
38
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
39
+ if (init?.headers) {
40
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
41
+ }
42
+ headers.set("Authorization", `Bearer ${token}`);
43
+ return fetch(target, { ...init, headers });
22
44
  }
23
45
 
24
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
25
- if (init?.headers) {
26
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
46
+ const response = await dispatch(false);
47
+ if (response.status !== 401) {
48
+ return response;
49
+ }
50
+ response.body?.cancel().catch(() => undefined);
51
+ const retried = await dispatch(true);
52
+ if (retried.status === 401) {
53
+ // Second 401 after a force-refresh retry — the silent token path
54
+ // can't recover this (refresh-token grant produces the same
55
+ // identity claims that just got rejected). Kick off interactive
56
+ // recovery so a fresh ``loginRedirect`` mints a token bound to
57
+ // current server policy. ``recoverFromHardAuthFailure`` throws
58
+ // once the redirect is in flight so we don't return a stale 401
59
+ // the caller might handle as a real failure.
60
+ retried.body?.cancel().catch(() => undefined);
61
+ await authStore
62
+ .getState()
63
+ .recoverFromHardAuthFailure(
64
+ new Error("API returned 401 after force-refresh retry"),
65
+ );
27
66
  }
28
- headers.set("Authorization", `Bearer ${token}`);
29
- return fetch(input, { ...init, headers });
67
+ return retried;
30
68
  }