@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 +30 -0
- package/package.json +1 -1
- package/src/__tests__/agui-runner.test.ts +88 -13
- package/src/__tests__/auth-store.test.ts +131 -14
- package/src/__tests__/service-fetch.test.ts +198 -6
- package/src/__tests__/token-fetch.test.ts +69 -0
- package/src/client/agui-runner.ts +55 -3
- package/src/client/service-fetch.ts +121 -36
- package/src/msal/auth-config.ts +11 -0
- package/src/msal/auth-store.ts +153 -17
- package/src/msal/index.ts +5 -1
- package/src/msal/token-fetch.ts +46 -8
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
|
@@ -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,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("
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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("
|
|
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
|
-
|
|
117
|
-
|
|
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("
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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("
|
|
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). 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[] =
|
|
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,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
|
|
96
|
+
const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
|
|
70
97
|
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
|
-
}
|
|
98
|
+
new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
|
|
82
99
|
}
|
|
100
|
+
const callerSuppliedAuth = baseHeaders.has("Authorization");
|
|
83
101
|
|
|
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,
|
|
102
|
+
function buildRequestInitFrom(req: Request): RequestInit {
|
|
103
|
+
// ReadableStream bodies are single-consume. ``req.clone()`` returns a
|
|
104
|
+
// fresh Request whose body stream is independent — call 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 (
|
|
106
|
-
(
|
|
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
|
-
|
|
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
|
|
195
|
+
return retried;
|
|
111
196
|
};
|
|
112
197
|
}
|
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,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
|
-
|
|
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
|
-
|
|
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
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,49 @@ 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;
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
if (
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
return fetch(input, { ...init, headers });
|
|
67
|
+
return retried;
|
|
30
68
|
}
|