@iloveagents/foundry-agent 0.1.2 → 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 +14 -0
- package/package.json +1 -1
- package/src/__tests__/auth-store.test.ts +20 -0
- package/src/__tests__/service-fetch.test.ts +77 -3
- package/src/__tests__/token-fetch.test.ts +16 -3
- package/src/client/service-fetch.ts +41 -1
- package/src/msal/auth-store.ts +36 -0
- package/src/msal/token-fetch.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.1.2
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -125,6 +125,26 @@ describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
|
125
125
|
);
|
|
126
126
|
});
|
|
127
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
|
+
|
|
128
148
|
it("returns null without redirecting when no account is cached", async () => {
|
|
129
149
|
// AuthGuard calls loginRedirect when accounts.length === 0; double-
|
|
130
150
|
// redirecting from getAccessToken would race with AuthGuard.
|
|
@@ -284,9 +284,10 @@ describe("createServiceFetch", () => {
|
|
|
284
284
|
});
|
|
285
285
|
|
|
286
286
|
it("only retries once even if the second attempt also returns 401", async () => {
|
|
287
|
-
// Hard auth failure (refresh token expired).
|
|
288
|
-
//
|
|
289
|
-
//
|
|
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.)
|
|
290
291
|
fetchSpy.mockReset();
|
|
291
292
|
fetchSpy
|
|
292
293
|
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
@@ -300,5 +301,78 @@ describe("createServiceFetch", () => {
|
|
|
300
301
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
301
302
|
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
302
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
|
+
});
|
|
303
377
|
});
|
|
304
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
|
|
|
@@ -105,17 +108,27 @@ describe("tokenFetch", () => {
|
|
|
105
108
|
expect(getAccessToken).toHaveBeenCalledTimes(1);
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
it("
|
|
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.
|
|
109
117
|
getAccessToken.mockResolvedValue("token");
|
|
118
|
+
recoverFromHardAuthFailure.mockRejectedValue(
|
|
119
|
+
new Error("loginRedirect in flight"),
|
|
120
|
+
);
|
|
110
121
|
const fetchSpy = vi
|
|
111
122
|
.spyOn(globalThis, "fetch")
|
|
112
123
|
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
113
124
|
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
await expect(tokenFetch("https://example.com/api/items")).rejects.toThrow(
|
|
127
|
+
"loginRedirect in flight",
|
|
128
|
+
);
|
|
116
129
|
|
|
117
|
-
expect(res.status).toBe(401);
|
|
118
130
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
119
131
|
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
132
|
+
expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
|
|
120
133
|
});
|
|
121
134
|
});
|
|
@@ -26,6 +26,27 @@ export interface ServiceFetchOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
acquireToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
28
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>;
|
|
49
|
+
|
|
29
50
|
/**
|
|
30
51
|
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
31
52
|
* Empty / undefined leaves the URL untouched (Vite proxy handles routing
|
|
@@ -152,6 +173,25 @@ export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
|
|
|
152
173
|
// Drain the failed response body — letting it sit unread keeps the
|
|
153
174
|
// underlying connection occupied on some runtimes.
|
|
154
175
|
response.body?.cancel().catch(() => undefined);
|
|
155
|
-
|
|
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
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return retried;
|
|
156
196
|
};
|
|
157
197
|
}
|
package/src/msal/auth-store.ts
CHANGED
|
@@ -84,6 +84,17 @@ interface AuthState {
|
|
|
84
84
|
audience?: "api" | "spaces",
|
|
85
85
|
options?: { forceRefresh?: boolean },
|
|
86
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.
|
|
96
|
+
*/
|
|
97
|
+
recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
@@ -233,4 +244,29 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
233
244
|
return null;
|
|
234
245
|
}
|
|
235
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
|
+
},
|
|
236
272
|
}));
|
package/src/msal/token-fetch.ts
CHANGED
|
@@ -48,5 +48,21 @@ export async function tokenFetch(
|
|
|
48
48
|
return response;
|
|
49
49
|
}
|
|
50
50
|
response.body?.cancel().catch(() => undefined);
|
|
51
|
-
|
|
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
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return retried;
|
|
52
68
|
}
|