@iloveagents/foundry-agent 0.1.1 → 0.1.2
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 +16 -0
- package/package.json +1 -1
- package/src/__tests__/agui-runner.test.ts +88 -13
- package/src/__tests__/auth-store.test.ts +111 -14
- package/src/__tests__/service-fetch.test.ts +124 -6
- package/src/__tests__/token-fetch.test.ts +56 -0
- package/src/client/agui-runner.ts +55 -3
- package/src/client/service-fetch.ts +81 -36
- package/src/msal/auth-config.ts +11 -0
- package/src/msal/auth-store.ts +117 -17
- package/src/msal/index.ts +5 -1
- package/src/msal/token-fetch.ts +30 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @iloveagents/foundry-agent
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 30a4346: fix(agent): retry protected fetch with `forceRefresh` on 401
|
|
8
|
+
|
|
9
|
+
Closes the long-lived-tab 401 loop where the only recovery the user had was logging out or clearing localStorage.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
Pair: `lastspace#205` (forwards the option through `spacesFetch.acquireToken`).
|
|
18
|
+
|
|
3
19
|
## 0.1.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -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: {
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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,27 @@ 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
|
+
|
|
86
128
|
it("returns null without redirecting when no account is cached", async () => {
|
|
87
129
|
// AuthGuard calls loginRedirect when accounts.length === 0; double-
|
|
88
130
|
// redirecting from getAccessToken would race with AuthGuard.
|
|
@@ -93,31 +135,48 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
93
135
|
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
94
136
|
});
|
|
95
137
|
|
|
96
|
-
it("
|
|
138
|
+
it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
|
|
97
139
|
const err = Object.assign(new Error("MFA required"), {
|
|
98
140
|
name: "InteractionRequiredAuthError",
|
|
99
141
|
errorCode: "interaction_required",
|
|
100
142
|
});
|
|
101
143
|
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
144
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
145
|
+
code: "interaction_required",
|
|
146
|
+
cause: err,
|
|
147
|
+
messageIncludes: [
|
|
148
|
+
"Authentication interaction required",
|
|
149
|
+
"InteractionRequiredAuthError",
|
|
150
|
+
"interaction_required",
|
|
151
|
+
"MFA required",
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(msal.clearCache).toHaveBeenCalledWith({
|
|
106
155
|
account: { username: "alice@example.com", localAccountId: "alice-oid" },
|
|
107
156
|
});
|
|
157
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
158
|
+
expect(msal.loginRedirect).toHaveBeenCalledWith({
|
|
159
|
+
scopes: [config.apiScope],
|
|
160
|
+
});
|
|
161
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
108
162
|
});
|
|
109
163
|
|
|
110
|
-
it("
|
|
164
|
+
it("clears stale cache and redirects on consent_required", async () => {
|
|
111
165
|
const err = Object.assign(new Error("consent required"), {
|
|
112
166
|
name: "InteractionRequiredAuthError",
|
|
113
167
|
errorCode: "consent_required",
|
|
114
168
|
});
|
|
115
169
|
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
171
|
+
code: "consent_required",
|
|
172
|
+
cause: err,
|
|
173
|
+
messageIncludes: ["Authentication interaction required", "consent_required"],
|
|
174
|
+
});
|
|
175
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
176
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
118
177
|
});
|
|
119
178
|
|
|
120
|
-
it("
|
|
179
|
+
it("clears stale cache and redirects on monitor_window_timeout", async () => {
|
|
121
180
|
// Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
|
|
122
181
|
// → documented remedy includes "Invoke an interactive API".
|
|
123
182
|
const err = Object.assign(new Error("monitor_window_timeout"), {
|
|
@@ -125,8 +184,13 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
125
184
|
errorCode: "monitor_window_timeout",
|
|
126
185
|
});
|
|
127
186
|
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
188
|
+
code: "monitor_window_timeout",
|
|
189
|
+
cause: err,
|
|
190
|
+
messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
|
|
191
|
+
});
|
|
192
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
193
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
130
194
|
});
|
|
131
195
|
|
|
132
196
|
it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
|
|
@@ -163,7 +227,40 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
163
227
|
name: "InteractionRequiredAuthError",
|
|
164
228
|
});
|
|
165
229
|
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
166
|
-
|
|
167
|
-
|
|
230
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
231
|
+
cause: err,
|
|
232
|
+
messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
|
|
233
|
+
});
|
|
234
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
235
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("preserves redirect failure details when interactive recovery cannot start", async () => {
|
|
239
|
+
const tokenErr = Object.assign(new Error("MFA required"), {
|
|
240
|
+
name: "InteractionRequiredAuthError",
|
|
241
|
+
errorCode: "interaction_required",
|
|
242
|
+
});
|
|
243
|
+
const redirectErr = Object.assign(new Error("redirect blocked"), {
|
|
244
|
+
name: "BrowserAuthError",
|
|
245
|
+
errorCode: "redirect_failed",
|
|
246
|
+
});
|
|
247
|
+
const msal = mockMsal({
|
|
248
|
+
acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
|
|
249
|
+
loginRedirect: vi.fn().mockRejectedValue(redirectErr),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
253
|
+
code: "redirect_failed",
|
|
254
|
+
cause: redirectErr,
|
|
255
|
+
messageIncludes: [
|
|
256
|
+
"Authentication interaction required",
|
|
257
|
+
"login redirect failed",
|
|
258
|
+
"redirect_failed",
|
|
259
|
+
"Original token error",
|
|
260
|
+
"interaction_required",
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
264
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
168
265
|
});
|
|
169
266
|
});
|
|
@@ -172,15 +172,133 @@ describe("createServiceFetch", () => {
|
|
|
172
172
|
expect(merged.get("X-Init-Only")).toBe("init");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
it("
|
|
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).
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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). The second 401 propagates
|
|
288
|
+
// to the caller; the auth layer's interaction-required path is what
|
|
289
|
+
// kicks the user to loginRedirect, not an infinite retry loop here.
|
|
290
|
+
fetchSpy.mockReset();
|
|
291
|
+
fetchSpy
|
|
292
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
293
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
294
|
+
const acquireToken = vi.fn().mockResolvedValue("token");
|
|
295
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
296
|
+
|
|
297
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
298
|
+
|
|
299
|
+
expect(res.status).toBe(401);
|
|
300
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
301
|
+
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
302
|
+
});
|
|
185
303
|
});
|
|
186
304
|
});
|
|
@@ -62,4 +62,60 @@ describe("tokenFetch", () => {
|
|
|
62
62
|
const [, init] = fetchSpy.mock.calls[0]!;
|
|
63
63
|
expect(init).toBeUndefined();
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it("retries once with forceRefresh when first attempt returns 401", async () => {
|
|
67
|
+
// Long-lived tab path: cached token is rejected by the API → MSAL is
|
|
68
|
+
// asked to round-trip the token endpoint with the refresh token.
|
|
69
|
+
getAccessToken
|
|
70
|
+
.mockResolvedValueOnce("stale-token")
|
|
71
|
+
.mockResolvedValueOnce("fresh-token");
|
|
72
|
+
const fetchSpy = vi
|
|
73
|
+
.spyOn(globalThis, "fetch")
|
|
74
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
75
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
76
|
+
|
|
77
|
+
const res = await tokenFetch("https://example.com/api/items");
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
81
|
+
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
82
|
+
expect(getAccessToken).toHaveBeenNthCalledWith(1, "api", undefined);
|
|
83
|
+
expect(getAccessToken).toHaveBeenNthCalledWith(2, "api", { forceRefresh: true });
|
|
84
|
+
|
|
85
|
+
const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
|
|
86
|
+
"Authorization",
|
|
87
|
+
);
|
|
88
|
+
const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
|
|
89
|
+
"Authorization",
|
|
90
|
+
);
|
|
91
|
+
expect(firstAuth).toBe("Bearer stale-token");
|
|
92
|
+
expect(secondAuth).toBe("Bearer fresh-token");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not retry on non-401 responses", async () => {
|
|
96
|
+
getAccessToken.mockResolvedValue("token");
|
|
97
|
+
const fetchSpy = vi
|
|
98
|
+
.spyOn(globalThis, "fetch")
|
|
99
|
+
.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
|
100
|
+
|
|
101
|
+
const res = await tokenFetch("https://example.com/api/items");
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(500);
|
|
104
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(getAccessToken).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("only retries once on a hard 401-then-401 path", async () => {
|
|
109
|
+
getAccessToken.mockResolvedValue("token");
|
|
110
|
+
const fetchSpy = vi
|
|
111
|
+
.spyOn(globalThis, "fetch")
|
|
112
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
113
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
114
|
+
|
|
115
|
+
const res = await tokenFetch("https://example.com/api/items");
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(401);
|
|
118
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
119
|
+
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
120
|
+
});
|
|
65
121
|
});
|
|
@@ -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[] =
|
|
174
|
+
let currentMessages: Message[] = mergeProtocolMessagesFromSnapshot(
|
|
175
|
+
this.httpAgent.messages,
|
|
176
|
+
messages,
|
|
177
|
+
);
|
|
130
178
|
|
|
131
|
-
// Replace once at the top
|
|
132
|
-
//
|
|
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,14 @@ 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>;
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
@@ -66,47 +72,86 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
|
|
|
66
72
|
// Request-input branch must preserve method / body / credentials /
|
|
67
73
|
// signal / etc. — silently dropping them by reading only `.url` would
|
|
68
74
|
// turn POST/PUT into GET and drop required headers.
|
|
69
|
-
const
|
|
75
|
+
const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
|
|
70
76
|
if (init?.headers) {
|
|
71
|
-
new Headers(init.headers).forEach((value, key) =>
|
|
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
|
-
}
|
|
77
|
+
new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
|
|
82
78
|
}
|
|
79
|
+
const callerSuppliedAuth = baseHeaders.has("Authorization");
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
method:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
referrer: cloned.referrer,
|
|
101
|
-
integrity: cloned.integrity,
|
|
102
|
-
signal: cloned.signal,
|
|
81
|
+
function buildRequestInitFrom(req: Request): RequestInit {
|
|
82
|
+
// ReadableStream bodies are single-consume. ``req.clone()`` returns a
|
|
83
|
+
// fresh Request whose body stream is independent — call it once per
|
|
84
|
+
// dispatch attempt so the 401 retry can replay POST/PUT bodies
|
|
85
|
+
// intact.
|
|
86
|
+
const fresh = req.clone();
|
|
87
|
+
const ri: RequestInit = {
|
|
88
|
+
method: fresh.method,
|
|
89
|
+
body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
|
|
90
|
+
credentials: fresh.credentials,
|
|
91
|
+
mode: fresh.mode,
|
|
92
|
+
cache: fresh.cache,
|
|
93
|
+
redirect: fresh.redirect,
|
|
94
|
+
referrer: fresh.referrer,
|
|
95
|
+
integrity: fresh.integrity,
|
|
96
|
+
signal: fresh.signal,
|
|
103
97
|
};
|
|
104
98
|
// Streaming bodies need duplex: "half"; harmless when there's no body.
|
|
105
|
-
if (
|
|
106
|
-
(
|
|
99
|
+
if (fresh.body !== null) {
|
|
100
|
+
(ri as RequestInit & { duplex?: string }).duplex = "half";
|
|
107
101
|
}
|
|
108
|
-
return
|
|
102
|
+
return ri;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function dispatch(forceRefreshToken: boolean): Promise<Response> {
|
|
106
|
+
const headers = new Headers(baseHeaders);
|
|
107
|
+
if (!callerSuppliedAuth) {
|
|
108
|
+
try {
|
|
109
|
+
const token = await options.acquireToken(
|
|
110
|
+
forceRefreshToken ? { forceRefresh: true } : undefined,
|
|
111
|
+
);
|
|
112
|
+
if (token) {
|
|
113
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// A thrown token acquisition error usually means the auth layer
|
|
117
|
+
// is starting an interactive recovery. Do not downgrade protected
|
|
118
|
+
// API calls to anonymous requests; that creates noisy 401s and
|
|
119
|
+
// stale UI.
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (input instanceof Request) {
|
|
124
|
+
// Re-clone for THIS attempt — the body stream of the original is
|
|
125
|
+
// either still pristine (first attempt) or already consumed
|
|
126
|
+
// (second attempt); ``clone()`` always returns a fresh, replayable
|
|
127
|
+
// copy.
|
|
128
|
+
const requestInit = buildRequestInitFrom(input);
|
|
129
|
+
return fetch(resolvedUrl, { ...requestInit, ...init, headers });
|
|
130
|
+
}
|
|
131
|
+
return fetch(resolvedUrl, { ...init, headers });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const response = await dispatch(false);
|
|
135
|
+
|
|
136
|
+
// Long-lived tab recovery: when the resource server returns 401 the
|
|
137
|
+
// local MSAL cache may still hold a token MSAL itself thinks is valid
|
|
138
|
+
// (claims challenge, conditional-access re-eval, audience drift, or
|
|
139
|
+
// the user simply left the tab open past the cached access token's
|
|
140
|
+
// server-side validity). Force-refresh the token via the refresh
|
|
141
|
+
// token grant and replay the request once. If the refresh-token is
|
|
142
|
+
// also gone (24h SPA cap), ``acquireToken`` will throw an
|
|
143
|
+
// interaction-required error and the auth layer kicks off
|
|
144
|
+
// ``loginRedirect`` — that's the only correct UX for a hard expiry.
|
|
145
|
+
//
|
|
146
|
+
// Conditions for retry: 401, no caller-supplied auth header (we own
|
|
147
|
+
// the token), and the server didn't already see a fresh token (we
|
|
148
|
+
// only retry once).
|
|
149
|
+
if (response.status !== 401 || callerSuppliedAuth) {
|
|
150
|
+
return response;
|
|
109
151
|
}
|
|
110
|
-
|
|
152
|
+
// Drain the failed response body — letting it sit unread keeps the
|
|
153
|
+
// underlying connection occupied on some runtimes.
|
|
154
|
+
response.body?.cancel().catch(() => undefined);
|
|
155
|
+
return dispatch(true);
|
|
111
156
|
};
|
|
112
157
|
}
|
package/src/msal/auth-config.ts
CHANGED
|
@@ -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[];
|
package/src/msal/auth-store.ts
CHANGED
|
@@ -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
|
-
*
|
|
29
|
-
*
|
|
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,18 @@ 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.
|
|
57
82
|
*/
|
|
58
|
-
getAccessToken: (
|
|
83
|
+
getAccessToken: (
|
|
84
|
+
audience?: "api" | "spaces",
|
|
85
|
+
options?: { forceRefresh?: boolean },
|
|
86
|
+
) => Promise<string | null>;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
@@ -77,6 +105,8 @@ const RECOVERABLE_ERROR_CODES = new Set([
|
|
|
77
105
|
* class name as a fallback when the error code isn't set. */
|
|
78
106
|
const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
|
|
79
107
|
|
|
108
|
+
let interactiveRecoveryStarted = false;
|
|
109
|
+
|
|
80
110
|
function isRecoverableAuthError(err: unknown): boolean {
|
|
81
111
|
if (!err || typeof err !== "object") return false;
|
|
82
112
|
const name = (err as { name?: string }).name ?? "";
|
|
@@ -84,6 +114,81 @@ function isRecoverableAuthError(err: unknown): boolean {
|
|
|
84
114
|
return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
|
|
85
115
|
}
|
|
86
116
|
|
|
117
|
+
function authErrorName(err: unknown): string | undefined {
|
|
118
|
+
return err && typeof err === "object" ? (err as { name?: string }).name : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function authErrorCode(err: unknown): string | undefined {
|
|
122
|
+
return err && typeof err === "object" ? (err as { errorCode?: string }).errorCode : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function authErrorMessage(err: unknown): string | undefined {
|
|
126
|
+
if (err instanceof Error) return err.message;
|
|
127
|
+
return err && typeof err === "object" ? (err as { message?: string }).message : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeAuthError(err: unknown): string {
|
|
131
|
+
const parts = [
|
|
132
|
+
authErrorName(err) ? `name=${authErrorName(err)}` : undefined,
|
|
133
|
+
authErrorCode(err) ? `code=${authErrorCode(err)}` : undefined,
|
|
134
|
+
authErrorMessage(err) ? `message=${authErrorMessage(err)}` : undefined,
|
|
135
|
+
].filter(Boolean);
|
|
136
|
+
return parts.join(", ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createInteractionRequiredError(
|
|
140
|
+
tokenError: unknown,
|
|
141
|
+
redirectError?: unknown,
|
|
142
|
+
): AuthInteractionRequiredError {
|
|
143
|
+
const tokenDetail = describeAuthError(tokenError) || "unknown token acquisition error";
|
|
144
|
+
const redirectDetail = redirectError ? describeAuthError(redirectError) : "";
|
|
145
|
+
|
|
146
|
+
if (redirectError) {
|
|
147
|
+
return new AuthInteractionRequiredError(
|
|
148
|
+
`Authentication interaction required, but login redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
|
|
149
|
+
{
|
|
150
|
+
code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
|
|
151
|
+
cause: redirectError,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return new AuthInteractionRequiredError(
|
|
157
|
+
`Authentication interaction required after token acquisition failed (${tokenDetail}).`,
|
|
158
|
+
{
|
|
159
|
+
code: authErrorCode(tokenError),
|
|
160
|
+
cause: tokenError,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function startInteractiveRecovery(
|
|
166
|
+
msal: MsalClientApplication,
|
|
167
|
+
account: MsalAccountInfo,
|
|
168
|
+
scope: string,
|
|
169
|
+
reason: unknown,
|
|
170
|
+
): Promise<never> {
|
|
171
|
+
let redirectError: unknown;
|
|
172
|
+
if (!interactiveRecoveryStarted) {
|
|
173
|
+
interactiveRecoveryStarted = true;
|
|
174
|
+
authStore.setState({ user: null, isAuthenticated: false });
|
|
175
|
+
msal.setActiveAccount(null);
|
|
176
|
+
await msal.clearCache({ account }).catch(() => undefined);
|
|
177
|
+
try {
|
|
178
|
+
await msal.loginRedirect({ scopes: [scope] });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// The redirect is expected to navigate away; if it settles by throwing,
|
|
181
|
+
// keep that failure attached so logs/UI can show what blocked recovery.
|
|
182
|
+
redirectError = err;
|
|
183
|
+
} finally {
|
|
184
|
+
// In tests or blocked-popup environments the promise can settle without
|
|
185
|
+
// navigation. Allow a later user action/retry to start recovery again.
|
|
186
|
+
interactiveRecoveryStarted = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw createInteractionRequiredError(reason, redirectError);
|
|
190
|
+
}
|
|
191
|
+
|
|
87
192
|
export const authStore = createStore<AuthState>((set) => ({
|
|
88
193
|
user: null,
|
|
89
194
|
isAuthenticated: false,
|
|
@@ -98,7 +203,7 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
98
203
|
}
|
|
99
204
|
},
|
|
100
205
|
|
|
101
|
-
getAccessToken: async (_audience = "api") => {
|
|
206
|
+
getAccessToken: async (_audience = "api", options) => {
|
|
102
207
|
const msal = getMsalInstance();
|
|
103
208
|
const config = getMsalConfig();
|
|
104
209
|
if (!msal || !config) return null;
|
|
@@ -112,23 +217,18 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
112
217
|
|
|
113
218
|
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
|
|
114
219
|
const scope = config.apiScope;
|
|
220
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
115
221
|
|
|
116
222
|
try {
|
|
117
223
|
const result = await msal.acquireTokenSilent({
|
|
118
224
|
scopes: [scope],
|
|
119
225
|
account: accounts[0],
|
|
226
|
+
forceRefresh,
|
|
120
227
|
});
|
|
121
228
|
return result.accessToken;
|
|
122
229
|
} catch (err) {
|
|
123
230
|
if (isRecoverableAuthError(err)) {
|
|
124
|
-
|
|
125
|
-
await msal.acquireTokenRedirect({
|
|
126
|
-
scopes: [scope],
|
|
127
|
-
account: accounts[0],
|
|
128
|
-
});
|
|
129
|
-
} catch {
|
|
130
|
-
// Redirect navigates away; nothing to return.
|
|
131
|
-
}
|
|
231
|
+
return startInteractiveRecovery(msal, accounts[0], scope, err);
|
|
132
232
|
}
|
|
133
233
|
return null;
|
|
134
234
|
}
|
package/src/msal/index.ts
CHANGED
package/src/msal/token-fetch.ts
CHANGED
|
@@ -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,33 @@ export async function tokenFetch(
|
|
|
15
20
|
input: string | URL | Request,
|
|
16
21
|
init?: RequestInit,
|
|
17
22
|
): Promise<Response> {
|
|
18
|
-
|
|
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;
|
|
33
|
+
|
|
34
|
+
if (!token) {
|
|
35
|
+
return fetch(target, init);
|
|
36
|
+
}
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
if (
|
|
26
|
-
|
|
46
|
+
const response = await dispatch(false);
|
|
47
|
+
if (response.status !== 401) {
|
|
48
|
+
return response;
|
|
27
49
|
}
|
|
28
|
-
|
|
29
|
-
return
|
|
50
|
+
response.body?.cancel().catch(() => undefined);
|
|
51
|
+
return dispatch(true);
|
|
30
52
|
}
|