@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.
@@ -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[] = [...messages];
174
+ let currentMessages: Message[] = mergeProtocolMessagesFromSnapshot(
175
+ this.httpAgent.messages,
176
+ messages,
177
+ );
130
178
 
131
- // Replace once at the top: subsequent turns append to currentMessages
132
- // (per AG-UI convention see footgun in apps/web AGENTS.md cross-link).
179
+ // Replace once at the top with the reconciled AG-UI history. assistant-ui
180
+ // stores only visible chat turns, while AG-UI snapshots may contain
181
+ // model-visible but UI-hidden protocol messages such as system/developer
182
+ // guidance. Preserve those messages across turns when the visible history
183
+ // belongs to the same thread so the next request remains a complete AG-UI
184
+ // conversation without leaking system messages into the rendered chat.
133
185
  this.httpAgent.setMessages(currentMessages);
134
186
  if (state) this.httpAgent.setState(state);
135
187
 
@@ -17,8 +17,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 headers = new Headers(input instanceof Request ? input.headers : undefined);
75
+ const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
70
76
  if (init?.headers) {
71
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
72
- }
73
- if (!headers.has("Authorization")) {
74
- try {
75
- const token = await options.acquireToken();
76
- if (token) {
77
- headers.set("Authorization", `Bearer ${token}`);
78
- }
79
- } catch {
80
- // Token acquisition failed — proceed without
81
- }
77
+ new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
82
78
  }
79
+ const callerSuppliedAuth = baseHeaders.has("Authorization");
83
80
 
84
- if (input instanceof Request) {
85
- // Build a fresh Request against the rewritten URL, copying every
86
- // field the runtime exposes on the originalexplicit field copy
87
- // is more portable than `new Request(url, originalRequest)`, which
88
- // some runtimes (older undici/JSDOM in particular) do not honor
89
- // for method/body. `init` then overrides anything the caller passed.
90
- const cloned = input.clone();
91
- const requestInit: RequestInit = {
92
- method: cloned.method,
93
- body:
94
- cloned.method === "GET" || cloned.method === "HEAD" ? undefined : cloned.body,
95
- headers,
96
- credentials: cloned.credentials,
97
- mode: cloned.mode,
98
- cache: cloned.cache,
99
- redirect: cloned.redirect,
100
- referrer: cloned.referrer,
101
- integrity: cloned.integrity,
102
- signal: cloned.signal,
81
+ function buildRequestInitFrom(req: Request): RequestInit {
82
+ // ReadableStream bodies are single-consume. ``req.clone()`` returns a
83
+ // fresh Request whose body stream is independentcall 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 (cloned.body !== null) {
106
- (requestInit as RequestInit & { duplex?: string }).duplex = "half";
99
+ if (fresh.body !== null) {
100
+ (ri as RequestInit & { duplex?: string }).duplex = "half";
107
101
  }
108
- return fetch(resolvedUrl, { ...requestInit, ...init, headers });
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
- return fetch(resolvedUrl, { ...init, headers });
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";
@@ -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
 
@@ -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: (audience?: "api" | "spaces") => Promise<string | null>;
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) return null;
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
- // Silent acquisition failed — trigger interactive redirect
61
- try {
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
@@ -1,4 +1,8 @@
1
- export { authStore, type AuthUser } from "./auth-store.ts";
1
+ export {
2
+ AuthInteractionRequiredError,
3
+ authStore,
4
+ type AuthUser,
5
+ } from "./auth-store.ts";
2
6
  export {
3
7
  type MsalAuthConfig,
4
8
  type MsalAccountInfo,
@@ -7,6 +7,11 @@
7
7
  * downstream services (e.g., Spaces).
8
8
  *
9
9
  * When MSAL is not configured, behaves identically to native `fetch()`.
10
+ *
11
+ * Long-lived tab recovery: on 401 the wrapper retries once with
12
+ * ``forceRefresh: true`` so the resource server doesn't keep seeing a
13
+ * stale-but-cached access token. See ``service-fetch.ts`` for the same
14
+ * pattern with URL rewriting + the deeper rationale.
10
15
  */
11
16
 
12
17
  import { authStore } from "./auth-store.ts";
@@ -15,16 +20,33 @@ export async function tokenFetch(
15
20
  input: string | URL | Request,
16
21
  init?: RequestInit,
17
22
  ): Promise<Response> {
18
- const token = await authStore.getState().getAccessToken("api");
23
+ async function dispatch(forceRefreshToken: boolean): Promise<Response> {
24
+ const token = await authStore
25
+ .getState()
26
+ .getAccessToken("api", forceRefreshToken ? { forceRefresh: true } : undefined);
27
+
28
+ // Clone Request inputs per-attempt: ReadableStream bodies are single-
29
+ // consume, so the 401 retry would otherwise see an empty body.
30
+ // ``input.clone()`` returns a fresh Request whose body stream is
31
+ // independent of the original.
32
+ const target = input instanceof Request ? input.clone() : input;
33
+
34
+ if (!token) {
35
+ return fetch(target, init);
36
+ }
19
37
 
20
- if (!token) {
21
- return fetch(input, init);
38
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
39
+ if (init?.headers) {
40
+ new Headers(init.headers).forEach((value, key) => headers.set(key, value));
41
+ }
42
+ headers.set("Authorization", `Bearer ${token}`);
43
+ return fetch(target, { ...init, headers });
22
44
  }
23
45
 
24
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
25
- if (init?.headers) {
26
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
46
+ const response = await dispatch(false);
47
+ if (response.status !== 401) {
48
+ return response;
27
49
  }
28
- headers.set("Authorization", `Bearer ${token}`);
29
- return fetch(input, { ...init, headers });
50
+ response.body?.cancel().catch(() => undefined);
51
+ return dispatch(true);
30
52
  }