@iloveagents/foundry-agent 0.1.0 → 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 +40 -0
- package/package.json +1 -1
- package/src/__tests__/agui-runner.test.ts +88 -13
- package/src/__tests__/auth-store.test.ts +231 -2
- package/src/__tests__/link-store.test.ts +48 -0
- 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/index.ts +8 -9
- package/src/msal/auth-config.ts +36 -0
- package/src/msal/auth-store.ts +177 -13
- package/src/msal/index.ts +5 -1
- package/src/msal/token-fetch.ts +30 -8
- package/src/store/link-store.ts +53 -0
|
@@ -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/index.ts
CHANGED
|
@@ -10,19 +10,18 @@ export {
|
|
|
10
10
|
} from "./client/service-fetch.ts";
|
|
11
11
|
|
|
12
12
|
// --- Tool registry ---
|
|
13
|
-
export {
|
|
14
|
-
clientToolRegistry,
|
|
15
|
-
type ClientToolEntry,
|
|
16
|
-
type ToolRegistry,
|
|
17
|
-
} from "./tools/registry.ts";
|
|
13
|
+
export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.ts";
|
|
18
14
|
|
|
19
15
|
// --- Stores (vanilla) ---
|
|
20
|
-
export {
|
|
21
|
-
streamingStatusStore,
|
|
22
|
-
type StreamingStatus,
|
|
23
|
-
} from "./store/streaming-status-store.ts";
|
|
16
|
+
export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.ts";
|
|
24
17
|
export {
|
|
25
18
|
citationStore,
|
|
26
19
|
type CitationResult,
|
|
27
20
|
type CitationHandler,
|
|
28
21
|
} from "./store/citation-store.ts";
|
|
22
|
+
export {
|
|
23
|
+
linkStore,
|
|
24
|
+
resolveLinkHandler,
|
|
25
|
+
type LinkHandler,
|
|
26
|
+
type ResolvedLinkHandler,
|
|
27
|
+
} from "./store/link-store.ts";
|
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[];
|
|
@@ -57,6 +68,13 @@ export async function initializeMsal(
|
|
|
57
68
|
|
|
58
69
|
const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
|
|
59
70
|
|
|
71
|
+
// Token-renewal config — see Microsoft Learn:
|
|
72
|
+
// https://learn.microsoft.com/entra/msal/javascript/browser/errors
|
|
73
|
+
//
|
|
74
|
+
// SPA refresh tokens are 24 h, non-sliding, non-renewable. After that
|
|
75
|
+
// window the user MUST re-auth; nothing the SPA can do silently saves
|
|
76
|
+
// it. Goal of these knobs is to make the unavoidable interactive
|
|
77
|
+
// bounce predictable and to keep the silent path healthy in between.
|
|
60
78
|
const msalConfiguration = {
|
|
61
79
|
auth: {
|
|
62
80
|
clientId: config.clientId,
|
|
@@ -68,6 +86,24 @@ export async function initializeMsal(
|
|
|
68
86
|
// localStorage is required for Playwright E2E tests — sessionStorage
|
|
69
87
|
// is not preserved across page navigations in the Playwright context.
|
|
70
88
|
cacheLocation: "localStorage",
|
|
89
|
+
// Cache key includes a hash of any `claims` parameter. Without
|
|
90
|
+
// this, MSAL serves the same cached access token even after a
|
|
91
|
+
// claims-challenge / token revocation / role change. The MSAL
|
|
92
|
+
// team has signalled this will become the default; opt in early.
|
|
93
|
+
claimsBasedCachingEnabled: true,
|
|
94
|
+
},
|
|
95
|
+
system: {
|
|
96
|
+
// Treat access tokens as "expired" 10 min before the actual exp
|
|
97
|
+
// claim instead of MSAL's default 5 min. Eliminates the race
|
|
98
|
+
// where the SPA's clock thinks the token is still valid but the
|
|
99
|
+
// resource server rejects it as expired (clock skew, slow request
|
|
100
|
+
// queueing, etc.).
|
|
101
|
+
tokenRenewalOffsetSeconds: 600,
|
|
102
|
+
// Default 6 s is too tight on modern browsers — third-party
|
|
103
|
+
// storage partitioning + slower CPUs in the silent iframe can
|
|
104
|
+
// push the round-trip past it. 10 s is the value MSAL Angular
|
|
105
|
+
// and React samples ship with.
|
|
106
|
+
iframeHashTimeout: 10000,
|
|
71
107
|
},
|
|
72
108
|
};
|
|
73
109
|
|
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;
|
|
@@ -21,8 +34,159 @@ interface AuthState {
|
|
|
21
34
|
/**
|
|
22
35
|
* Acquire an access token for the given audience.
|
|
23
36
|
* Returns null when MSAL is not configured (local dev).
|
|
37
|
+
*
|
|
38
|
+
* Recovery semantics — follows the canonical MSAL.js pattern documented
|
|
39
|
+
* at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
|
|
40
|
+
*
|
|
41
|
+
* Try acquireTokenSilent first, then use an interactive redirect when
|
|
42
|
+
* MSAL reports that user interaction is required.
|
|
43
|
+
*
|
|
44
|
+
* Plus one extra recoverable case explicitly called out in those docs:
|
|
45
|
+
* `BrowserAuthError: monitor_window_timeout`. Microsoft's recommendation
|
|
46
|
+
* is to either backoff, fix the redirectUri page, or "invoke an
|
|
47
|
+
* interactive API such as acquireTokenPopup or acquireTokenRedirect" —
|
|
48
|
+
* we take the last option, which matches third-party-iframe storage
|
|
49
|
+
* blocking on Chrome 120+ / Edge / Safari (the silent iframe times
|
|
50
|
+
* out because the cross-site cookie is partitioned).
|
|
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
|
+
*
|
|
62
|
+
* Other `BrowserAuthError` codes (`interaction_in_progress`,
|
|
63
|
+
* `hash_empty_error`, `hash_does_not_contain_known_properties`,
|
|
64
|
+
* `block_iframe_reload`) are config / race-condition bugs that another
|
|
65
|
+
* redirect won't fix — propagate them as null without navigating.
|
|
66
|
+
*
|
|
67
|
+
* The "no account in cache" case is handled at boot by `AuthGuard`,
|
|
68
|
+
* which calls `loginRedirect` when `accounts.length === 0`. If we
|
|
69
|
+
* still reach this method without an account, it's an unusual state;
|
|
70
|
+
* return null and let the next AuthGuard render recover.
|
|
71
|
+
*
|
|
72
|
+
* Errors are matched by `name` / `errorCode` rather than `instanceof`
|
|
73
|
+
* because `@azure/msal-browser` is loaded via dynamic import; the
|
|
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.
|
|
24
82
|
*/
|
|
25
|
-
getAccessToken: (
|
|
83
|
+
getAccessToken: (
|
|
84
|
+
audience?: "api" | "spaces",
|
|
85
|
+
options?: { forceRefresh?: boolean },
|
|
86
|
+
) => Promise<string | null>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
90
|
+
* documented remedy "invoke an interactive API". */
|
|
91
|
+
const RECOVERABLE_ERROR_CODES = new Set([
|
|
92
|
+
// InteractionRequiredAuthError — canonical fallback case.
|
|
93
|
+
"interaction_required",
|
|
94
|
+
"login_required",
|
|
95
|
+
"consent_required",
|
|
96
|
+
// BrowserAuthError: monitor_window_timeout. Documented remedy includes
|
|
97
|
+
// "Invoke an interactive API" (Microsoft Learn → "Common errors in
|
|
98
|
+
// MSAL JS" → monitor_window_timeout → "Throttling" + "X-Frame-Options
|
|
99
|
+
// Deny"). Real-world trigger on Chrome 120+ is third-party-iframe
|
|
100
|
+
// storage partitioning blocking the silent SSO frame.
|
|
101
|
+
"monitor_window_timeout",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/** `InteractionRequiredAuthError` always triggers the redirect — match by
|
|
105
|
+
* class name as a fallback when the error code isn't set. */
|
|
106
|
+
const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
|
|
107
|
+
|
|
108
|
+
let interactiveRecoveryStarted = false;
|
|
109
|
+
|
|
110
|
+
function isRecoverableAuthError(err: unknown): boolean {
|
|
111
|
+
if (!err || typeof err !== "object") return false;
|
|
112
|
+
const name = (err as { name?: string }).name ?? "";
|
|
113
|
+
const code = (err as { errorCode?: string }).errorCode ?? "";
|
|
114
|
+
return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
|
|
115
|
+
}
|
|
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);
|
|
26
190
|
}
|
|
27
191
|
|
|
28
192
|
export const authStore = createStore<AuthState>((set) => ({
|
|
@@ -39,32 +203,32 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
39
203
|
}
|
|
40
204
|
},
|
|
41
205
|
|
|
42
|
-
getAccessToken: async (_audience = "api") => {
|
|
206
|
+
getAccessToken: async (_audience = "api", options) => {
|
|
43
207
|
const msal = getMsalInstance();
|
|
44
208
|
const config = getMsalConfig();
|
|
45
209
|
if (!msal || !config) return null;
|
|
46
210
|
|
|
47
211
|
const accounts = msal.getAllAccounts();
|
|
48
|
-
if (accounts.length === 0)
|
|
212
|
+
if (accounts.length === 0) {
|
|
213
|
+
// No cached account — `AuthGuard` will call `loginRedirect` on
|
|
214
|
+
// its next render. Don't double-redirect from here.
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
49
217
|
|
|
50
|
-
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT)
|
|
218
|
+
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
|
|
51
219
|
const scope = config.apiScope;
|
|
220
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
52
221
|
|
|
53
222
|
try {
|
|
54
223
|
const result = await msal.acquireTokenSilent({
|
|
55
224
|
scopes: [scope],
|
|
56
225
|
account: accounts[0],
|
|
226
|
+
forceRefresh,
|
|
57
227
|
});
|
|
58
228
|
return result.accessToken;
|
|
59
|
-
} catch {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await msal.acquireTokenRedirect({
|
|
63
|
-
scopes: [scope],
|
|
64
|
-
account: accounts[0],
|
|
65
|
-
});
|
|
66
|
-
} catch {
|
|
67
|
-
// Redirect will navigate away; nothing to return
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (isRecoverableAuthError(err)) {
|
|
231
|
+
return startInteractiveRecovery(msal, accounts[0], scope, err);
|
|
68
232
|
}
|
|
69
233
|
return null;
|
|
70
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
|
}
|