@iloveagents/foundry-agent 0.3.1 → 0.4.0
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/README.md +16 -0
- package/dist/client/agui-runner.d.ts +53 -0
- package/dist/client/agui-runner.js +320 -0
- package/dist/client/runner-events.d.ts +54 -0
- package/dist/client/runner-events.js +1 -0
- package/dist/client/service-fetch.d.ts +112 -0
- package/dist/client/service-fetch.js +244 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/msal/auth-config.d.ts +91 -0
- package/dist/msal/auth-config.js +70 -0
- package/dist/msal/auth-store.d.ts +95 -0
- package/dist/msal/auth-store.js +372 -0
- package/dist/msal/index.d.ts +3 -0
- package/dist/msal/index.js +3 -0
- package/dist/msal/token-fetch.d.ts +16 -0
- package/dist/msal/token-fetch.js +57 -0
- package/dist/store/citation-store.d.ts +42 -0
- package/dist/store/citation-store.js +14 -0
- package/dist/store/link-store.d.ts +29 -0
- package/dist/store/link-store.js +28 -0
- package/dist/store/streaming-status-store.d.ts +15 -0
- package/dist/store/streaming-status-store.js +9 -0
- package/dist/tools/registry.d.ts +48 -0
- package/dist/tools/registry.js +50 -0
- package/package.json +23 -9
- package/AGENTS.md +0 -91
- package/CHANGELOG.md +0 -182
- package/CLAUDE.md +0 -1
- package/src/__tests__/agui-runner.test.ts +0 -404
- package/src/__tests__/auth-store.test.ts +0 -596
- package/src/__tests__/citation-store.test.ts +0 -52
- package/src/__tests__/client-tool-registry.test.ts +0 -84
- package/src/__tests__/link-store.test.ts +0 -48
- package/src/__tests__/service-fetch.test.ts +0 -525
- package/src/__tests__/streaming-status-store.test.ts +0 -22
- package/src/__tests__/token-fetch.test.ts +0 -134
- package/src/client/agui-runner.ts +0 -382
- package/src/client/runner-events.ts +0 -27
- package/src/client/service-fetch.ts +0 -318
- package/src/index.ts +0 -27
- package/src/msal/auth-config.ts +0 -150
- package/src/msal/auth-store.ts +0 -517
- package/src/msal/index.ts +0 -14
- package/src/msal/token-fetch.ts +0 -68
- package/src/store/citation-store.ts +0 -52
- package/src/store/link-store.ts +0 -53
- package/src/store/streaming-status-store.ts +0 -21
- package/src/tools/registry.ts +0 -112
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -8
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-service fetch factory.
|
|
3
|
+
*
|
|
4
|
+
* Returns an authenticated `fetch`-shaped function that:
|
|
5
|
+
* 1. Rewrites local-relative URLs to a configured router base in production
|
|
6
|
+
* (Vite proxy in dev → httpRouteConfigs FQDN in prod).
|
|
7
|
+
* 2. Acquires a Bearer token via the supplied `acquireToken` callback and
|
|
8
|
+
* attaches it as the `Authorization` header.
|
|
9
|
+
*
|
|
10
|
+
* `@iloveagents/foundry-agent` stays auth-mechanism-agnostic — `acquireToken` is
|
|
11
|
+
* supplied by the host (apps/web wires it to MSAL via the `/msal` subpath;
|
|
12
|
+
* future shells could plug in a different token source).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Wrapper thrown when ``acquireToken`` rejects inside the fetch
|
|
16
|
+
* interceptor. Lets the outer 401-retry layer distinguish
|
|
17
|
+
* token-acquisition failures (which warrant interactive recovery) from
|
|
18
|
+
* generic ``fetch`` rejections like network drops, aborts, or CORS
|
|
19
|
+
* preflight failures (which do NOT — those would needlessly bounce the
|
|
20
|
+
* user through ``loginRedirect`` on a transient transport error).
|
|
21
|
+
*
|
|
22
|
+
* Exported so hosts that wrap ``serviceFetch`` further can ``instanceof``
|
|
23
|
+
* against the same class without re-declaring it.
|
|
24
|
+
*/
|
|
25
|
+
export class TokenAcquisitionError extends Error {
|
|
26
|
+
constructor(cause) {
|
|
27
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
28
|
+
this.name = "TokenAcquisitionError";
|
|
29
|
+
this.cause = cause;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const defaultOriginResolver = () => typeof window !== "undefined" ? window.location.origin : "";
|
|
33
|
+
/**
|
|
34
|
+
* Build a service-fetch function. Returns a function with the same shape as
|
|
35
|
+
* `fetch` that rewrites URLs + attaches the Bearer token from `acquireToken`.
|
|
36
|
+
*/
|
|
37
|
+
export function createServiceFetch(options) {
|
|
38
|
+
const routerBase = (options.baseUrl ?? "").replace(/\/+$/, "");
|
|
39
|
+
const resolveOrigin = options.originResolver ?? defaultOriginResolver;
|
|
40
|
+
function resolveUrl(url) {
|
|
41
|
+
if (!routerBase)
|
|
42
|
+
return url; // dev: Vite proxy handles routing
|
|
43
|
+
const origin = resolveOrigin();
|
|
44
|
+
const rel = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
|
45
|
+
return `${routerBase}${rel}`;
|
|
46
|
+
}
|
|
47
|
+
return async function serviceFetch(input, init) {
|
|
48
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
49
|
+
const resolvedUrl = resolveUrl(url);
|
|
50
|
+
// Merge headers from a `Request` input (if provided), then `init`. The
|
|
51
|
+
// Request-input branch must preserve method / body / credentials /
|
|
52
|
+
// signal / etc. — silently dropping them by reading only `.url` would
|
|
53
|
+
// turn POST/PUT into GET and drop required headers.
|
|
54
|
+
const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
|
|
55
|
+
if (init?.headers) {
|
|
56
|
+
new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
|
|
57
|
+
}
|
|
58
|
+
const callerSuppliedAuth = baseHeaders.has("Authorization");
|
|
59
|
+
function buildRequestInitFrom(req) {
|
|
60
|
+
// ReadableStream bodies are single-consume. ``req.clone()`` returns a
|
|
61
|
+
// fresh Request whose body stream is independent — call it once per
|
|
62
|
+
// dispatch attempt so the 401 retry can replay POST/PUT bodies
|
|
63
|
+
// intact.
|
|
64
|
+
const fresh = req.clone();
|
|
65
|
+
const ri = {
|
|
66
|
+
method: fresh.method,
|
|
67
|
+
body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
|
|
68
|
+
credentials: fresh.credentials,
|
|
69
|
+
mode: fresh.mode,
|
|
70
|
+
cache: fresh.cache,
|
|
71
|
+
redirect: fresh.redirect,
|
|
72
|
+
referrer: fresh.referrer,
|
|
73
|
+
integrity: fresh.integrity,
|
|
74
|
+
signal: fresh.signal,
|
|
75
|
+
};
|
|
76
|
+
// Streaming bodies need duplex: "half"; harmless when there's no body.
|
|
77
|
+
if (fresh.body !== null) {
|
|
78
|
+
ri.duplex = "half";
|
|
79
|
+
}
|
|
80
|
+
return ri;
|
|
81
|
+
}
|
|
82
|
+
async function dispatch(forceRefreshToken) {
|
|
83
|
+
const headers = new Headers(baseHeaders);
|
|
84
|
+
if (!callerSuppliedAuth) {
|
|
85
|
+
try {
|
|
86
|
+
const token = await options.acquireToken(forceRefreshToken ? { forceRefresh: true } : undefined);
|
|
87
|
+
if (token) {
|
|
88
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// A thrown token-acquisition error usually means the auth layer
|
|
93
|
+
// is starting an interactive recovery. Do not downgrade
|
|
94
|
+
// protected API calls to anonymous requests; that creates
|
|
95
|
+
// noisy 401s and stale UI. Wrap the error so the outer
|
|
96
|
+
// 401-retry layer can distinguish it from generic ``fetch``
|
|
97
|
+
// rejections (network drops, aborts, CORS preflight fails)
|
|
98
|
+
// and only escalate to ``recoverFromHardAuthFailure`` for
|
|
99
|
+
// genuine auth failures.
|
|
100
|
+
throw new TokenAcquisitionError(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (input instanceof Request) {
|
|
104
|
+
// Re-clone for THIS attempt — the body stream of the original is
|
|
105
|
+
// either still pristine (first attempt) or already consumed
|
|
106
|
+
// (second attempt); ``clone()`` always returns a fresh, replayable
|
|
107
|
+
// copy.
|
|
108
|
+
const requestInit = buildRequestInitFrom(input);
|
|
109
|
+
return fetch(resolvedUrl, { ...requestInit, ...init, headers });
|
|
110
|
+
}
|
|
111
|
+
return fetch(resolvedUrl, { ...init, headers });
|
|
112
|
+
}
|
|
113
|
+
const response = await dispatch(false);
|
|
114
|
+
// Long-lived tab recovery: when the resource server returns 401 the
|
|
115
|
+
// local MSAL cache may still hold a token MSAL itself thinks is valid
|
|
116
|
+
// (claims challenge, conditional-access re-eval, audience drift, or
|
|
117
|
+
// the user simply left the tab open past the cached access token's
|
|
118
|
+
// server-side validity). Force-refresh the token via the refresh
|
|
119
|
+
// token grant and replay the request once. If the refresh-token is
|
|
120
|
+
// also gone (24h SPA cap), ``acquireToken`` will throw an
|
|
121
|
+
// interaction-required error and the auth layer kicks off
|
|
122
|
+
// ``loginRedirect`` — that's the only correct UX for a hard expiry.
|
|
123
|
+
//
|
|
124
|
+
// Conditions for retry: 401, no caller-supplied auth header (we own
|
|
125
|
+
// the token), and the server didn't already see a fresh token (we
|
|
126
|
+
// only retry once).
|
|
127
|
+
if (response.status !== 401 || callerSuppliedAuth) {
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
// Drain the failed response body — letting it sit unread keeps the
|
|
131
|
+
// underlying connection occupied on some runtimes.
|
|
132
|
+
response.body?.cancel().catch(() => undefined);
|
|
133
|
+
// Two failure shapes for the retry:
|
|
134
|
+
// (a) The force-refreshed token grant FAILS — refresh token gone
|
|
135
|
+
// (24h SPA cap), interaction-required claims challenge, etc.
|
|
136
|
+
// ``acquireToken`` throws inside ``dispatch`` BEFORE the fetch
|
|
137
|
+
// leaves the browser. ``dispatch`` re-throws wrapped in
|
|
138
|
+
// ``TokenAcquisitionError`` so we can distinguish this case
|
|
139
|
+
// from network errors below.
|
|
140
|
+
// (b) The grant succeeds but the resource server STILL rejects
|
|
141
|
+
// the token (audience drift, server-side policy change). The
|
|
142
|
+
// retried response status is 401.
|
|
143
|
+
// Both must funnel into ``recoverFromHardAuthFailure`` so the auth
|
|
144
|
+
// layer can kick off ``loginRedirect`` — the only correct UX for a
|
|
145
|
+
// hard expiry. We treat the two auth paths symmetrically.
|
|
146
|
+
//
|
|
147
|
+
// We deliberately do NOT trigger recovery on generic ``dispatch``
|
|
148
|
+
// throws (TypeError from network drops, AbortError from caller
|
|
149
|
+
// cancellation, CORS-preflight failures, etc.) — those are
|
|
150
|
+
// transient transport problems, not auth state corruption, and
|
|
151
|
+
// bouncing the user through ``loginRedirect`` for a flaky network
|
|
152
|
+
// would be a much worse UX than letting the error propagate.
|
|
153
|
+
let retried;
|
|
154
|
+
try {
|
|
155
|
+
retried = await dispatch(true);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err instanceof TokenAcquisitionError && options.recoverFromHardAuthFailure) {
|
|
159
|
+
// ``recoverFromHardAuthFailure`` is expected to throw once the
|
|
160
|
+
// redirect is in flight; if it returns (e.g. test stub) we
|
|
161
|
+
// re-throw the wrapped original so the caller still sees the
|
|
162
|
+
// auth failure rather than a phantom recovery. We pass the
|
|
163
|
+
// unwrapped ``cause`` to recovery so it sees the real MSAL
|
|
164
|
+
// error (InteractionRequiredAuthError etc.) for telemetry.
|
|
165
|
+
await options.recoverFromHardAuthFailure(err.cause);
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
// Hard auth failure path (b): even the force-refreshed token got
|
|
170
|
+
// rejected. A token minted from the cached refresh token carries
|
|
171
|
+
// the same identity claims as the original, so it'll keep getting
|
|
172
|
+
// rejected for the same reason. Only a brand-new ``loginRedirect``
|
|
173
|
+
// produces a token bound to the current server policy. Without
|
|
174
|
+
// this branch the user sees an endless 401 loop until they
|
|
175
|
+
// manually log out.
|
|
176
|
+
if (retried.status === 401 && options.recoverFromHardAuthFailure) {
|
|
177
|
+
// Continuous Access Evaluation / Conditional-Access claims
|
|
178
|
+
// challenge passthrough. When the resource server requires a
|
|
179
|
+
// step-up (MFA, device compliance, revocation invalidation),
|
|
180
|
+
// it returns 401 with ``WWW-Authenticate: Bearer ... claims="…"``.
|
|
181
|
+
// We forward the payload through the recovery callback so the
|
|
182
|
+
// auth layer's redirect carries it to Entra ID and the
|
|
183
|
+
// re-minted token satisfies the exact challenge. Without it,
|
|
184
|
+
// Entra re-issues the same already-rejected claims set and the
|
|
185
|
+
// user loops back into the broken state.
|
|
186
|
+
const claims = parseClaimsChallengeFromWwwAuthenticate(retried.headers.get("www-authenticate"));
|
|
187
|
+
retried.body?.cancel().catch(() => undefined);
|
|
188
|
+
const reason = new AuthInteractionRequiredError("API returned 401 after force-refresh retry", claims ? { claims } : undefined);
|
|
189
|
+
// ``recoverFromHardAuthFailure`` throws once the redirect is in
|
|
190
|
+
// flight; the throw stops the calling pipeline so we don't
|
|
191
|
+
// return a stale 401 the caller might handle as a real failure.
|
|
192
|
+
await options.recoverFromHardAuthFailure(reason);
|
|
193
|
+
}
|
|
194
|
+
return retried;
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Error class the fetch interceptor raises (and forwards through
|
|
199
|
+
* ``recoverFromHardAuthFailure``) when a 401 needs interactive
|
|
200
|
+
* recovery. Exposed so the auth-layer recovery can pick up a
|
|
201
|
+
* ``claims`` field if one was extracted from the
|
|
202
|
+
* ``WWW-Authenticate`` header.
|
|
203
|
+
*/
|
|
204
|
+
export class AuthInteractionRequiredError extends Error {
|
|
205
|
+
constructor(message, options) {
|
|
206
|
+
super(message);
|
|
207
|
+
this.name = "AuthInteractionRequiredError";
|
|
208
|
+
this.claims = options?.claims;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Extract the claims challenge string from a resource server's
|
|
213
|
+
* ``WWW-Authenticate: Bearer ... claims="…"`` header. Returns
|
|
214
|
+
* ``undefined`` when the header is missing, malformed, or carries
|
|
215
|
+
* no claims directive.
|
|
216
|
+
*
|
|
217
|
+
* The value is forwarded VERBATIM to MSAL's
|
|
218
|
+
* ``acquireTokenRedirect({ claims })``; MSAL handles the
|
|
219
|
+
* base64-url decode + JSON parse itself. We don't try to validate
|
|
220
|
+
* the inner shape — letting Entra speak for itself avoids drift if
|
|
221
|
+
* the schema evolves.
|
|
222
|
+
*
|
|
223
|
+
* Spec: RFC 6750 ``WWW-Authenticate`` + CAE claims-challenge
|
|
224
|
+
* supplement (Microsoft Identity Platform docs).
|
|
225
|
+
*/
|
|
226
|
+
export function parseClaimsChallengeFromWwwAuthenticate(header) {
|
|
227
|
+
if (!header)
|
|
228
|
+
return undefined;
|
|
229
|
+
// ``WWW-Authenticate`` can carry multiple challenges separated by
|
|
230
|
+
// commas (e.g. ``Basic realm="x", Bearer ...``). We only care
|
|
231
|
+
// about the Bearer challenge.
|
|
232
|
+
const bearer = header.match(/Bearer\s+([^,]+(?:,(?!\s*[A-Za-z]+\s)[^,]*)*)/i);
|
|
233
|
+
if (!bearer)
|
|
234
|
+
return undefined;
|
|
235
|
+
// Inside the Bearer params, find ``claims="..."`` (quoted) or
|
|
236
|
+
// ``claims=token68`` (unquoted, base64url).
|
|
237
|
+
const quoted = bearer[1].match(/claims\s*=\s*"([^"]*)"/i);
|
|
238
|
+
if (quoted)
|
|
239
|
+
return quoted[1].length > 0 ? quoted[1] : undefined;
|
|
240
|
+
const unquoted = bearer[1].match(/claims\s*=\s*([A-Za-z0-9_\-+/=]+)/i);
|
|
241
|
+
if (unquoted)
|
|
242
|
+
return unquoted[1].length > 0 ? unquoted[1] : undefined;
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { AGUIRunner, type AGUIRunnerOptions, type AGUIRunInput } from "./client/agui-runner.js";
|
|
2
|
+
export type { RunnerEvent } from "./client/runner-events.js";
|
|
3
|
+
export { createServiceFetch, type ServiceFetch, type ServiceFetchOptions, } from "./client/service-fetch.js";
|
|
4
|
+
export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.js";
|
|
5
|
+
export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.js";
|
|
6
|
+
export { citationStore, type CitationResult, type CitationHandler, } from "./store/citation-store.js";
|
|
7
|
+
export { linkStore, resolveLinkHandler, type LinkHandler, type ResolvedLinkHandler, } from "./store/link-store.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// --- AG-UI runner ---
|
|
2
|
+
export { AGUIRunner } from "./client/agui-runner.js";
|
|
3
|
+
// --- Service fetch factory ---
|
|
4
|
+
export { createServiceFetch, } from "./client/service-fetch.js";
|
|
5
|
+
// --- Tool registry ---
|
|
6
|
+
export { clientToolRegistry } from "./tools/registry.js";
|
|
7
|
+
// --- Stores (vanilla) ---
|
|
8
|
+
export { streamingStatusStore } from "./store/streaming-status-store.js";
|
|
9
|
+
export { citationStore, } from "./store/citation-store.js";
|
|
10
|
+
export { linkStore, resolveLinkHandler, } from "./store/link-store.js";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL configuration types and PublicClientApplication singleton.
|
|
3
|
+
*
|
|
4
|
+
* `initializeMsal()` creates and caches the singleton PublicClientApplication.
|
|
5
|
+
* `getMsalInstance()` returns null until initialization has completed.
|
|
6
|
+
*
|
|
7
|
+
* `@azure/msal-browser` is loaded via dynamic import so `@iloveagents/foundry-agent`
|
|
8
|
+
* does not pull MSAL into bundles that don't need it.
|
|
9
|
+
*/
|
|
10
|
+
export interface MsalAuthConfig {
|
|
11
|
+
clientId: string;
|
|
12
|
+
authority: string;
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
apiScope: string;
|
|
15
|
+
postLogoutRedirectUri?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MsalAccountInfo {
|
|
18
|
+
username: string;
|
|
19
|
+
localAccountId: string;
|
|
20
|
+
name?: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface MsalClientApplication {
|
|
23
|
+
initialize(): Promise<void>;
|
|
24
|
+
handleRedirectPromise(): Promise<unknown>;
|
|
25
|
+
clearCache(request?: {
|
|
26
|
+
account?: MsalAccountInfo;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
getAllAccounts(): MsalAccountInfo[];
|
|
29
|
+
loginRedirect(request: {
|
|
30
|
+
scopes: string[];
|
|
31
|
+
prompt?: string;
|
|
32
|
+
/** Same claims-challenge passthrough as ``acquireTokenRedirect``. */
|
|
33
|
+
claims?: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
logoutRedirect(): Promise<void>;
|
|
36
|
+
setActiveAccount(account: MsalAccountInfo | null): void;
|
|
37
|
+
acquireTokenSilent(request: {
|
|
38
|
+
scopes: string[];
|
|
39
|
+
account: MsalAccountInfo;
|
|
40
|
+
/**
|
|
41
|
+
* Skip MSAL's local cache and force a round-trip to the token
|
|
42
|
+
* endpoint using the cached refresh token. The fetch interceptor
|
|
43
|
+
* uses this on a 401 retry — the first attempt may have served a
|
|
44
|
+
* cached access token MSAL still thought valid (within
|
|
45
|
+
* ``tokenRenewalOffsetSeconds``) that the resource server has
|
|
46
|
+
* since rejected (claims challenge, conditional access re-eval,
|
|
47
|
+
* audience drift).
|
|
48
|
+
*/
|
|
49
|
+
forceRefresh?: boolean;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
accessToken: string;
|
|
52
|
+
}>;
|
|
53
|
+
acquireTokenRedirect(request: {
|
|
54
|
+
scopes: string[];
|
|
55
|
+
account: MsalAccountInfo;
|
|
56
|
+
/**
|
|
57
|
+
* Force a fresh interactive login regardless of Entra SSO state.
|
|
58
|
+
* Without ``prompt: "login"``, when the user still has a live
|
|
59
|
+
* Entra session, the redirect silently round-trips and returns
|
|
60
|
+
* THE SAME stale token / claims — leaving the SPA back in the
|
|
61
|
+
* exact broken state we tried to recover from. See
|
|
62
|
+
* https://learn.microsoft.com/entra/identity-platform/msal-error-handling-js
|
|
63
|
+
* "Hard expiry / silent redirect loop" pattern.
|
|
64
|
+
*/
|
|
65
|
+
prompt?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Optional Continuous Access Evaluation / Conditional Access
|
|
68
|
+
* claims challenge payload, parsed from a resource-server 401
|
|
69
|
+
* response's ``WWW-Authenticate: Bearer ... claims="..."``
|
|
70
|
+
* header. When present, MSAL passes it through to Entra so the
|
|
71
|
+
* issued token explicitly satisfies the challenge (MFA step-up,
|
|
72
|
+
* device-compliance refresh, revocation invalidation, etc.).
|
|
73
|
+
*/
|
|
74
|
+
claims?: string;
|
|
75
|
+
}): Promise<void>;
|
|
76
|
+
acquireTokenPopup(request: {
|
|
77
|
+
scopes: string[];
|
|
78
|
+
account: MsalAccountInfo;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
accessToken: string;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Initialize the MSAL singleton. Must be called once before rendering.
|
|
85
|
+
* Dynamically imports `@azure/msal-browser` so the dependency stays optional.
|
|
86
|
+
*/
|
|
87
|
+
export declare function initializeMsal(config: MsalAuthConfig): Promise<MsalClientApplication>;
|
|
88
|
+
/** Get the MSAL instance (null when not configured). */
|
|
89
|
+
export declare function getMsalInstance(): MsalClientApplication | null;
|
|
90
|
+
/** Get the MSAL auth config (null when not configured). */
|
|
91
|
+
export declare function getMsalConfig(): MsalAuthConfig | null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL configuration types and PublicClientApplication singleton.
|
|
3
|
+
*
|
|
4
|
+
* `initializeMsal()` creates and caches the singleton PublicClientApplication.
|
|
5
|
+
* `getMsalInstance()` returns null until initialization has completed.
|
|
6
|
+
*
|
|
7
|
+
* `@azure/msal-browser` is loaded via dynamic import so `@iloveagents/foundry-agent`
|
|
8
|
+
* does not pull MSAL into bundles that don't need it.
|
|
9
|
+
*/
|
|
10
|
+
let msalInstance = null;
|
|
11
|
+
let msalConfig = null;
|
|
12
|
+
/**
|
|
13
|
+
* Initialize the MSAL singleton. Must be called once before rendering.
|
|
14
|
+
* Dynamically imports `@azure/msal-browser` so the dependency stays optional.
|
|
15
|
+
*/
|
|
16
|
+
export async function initializeMsal(config) {
|
|
17
|
+
if (msalInstance)
|
|
18
|
+
return msalInstance;
|
|
19
|
+
const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
|
|
20
|
+
// Token-renewal config — see Microsoft Learn:
|
|
21
|
+
// https://learn.microsoft.com/entra/msal/javascript/browser/errors
|
|
22
|
+
//
|
|
23
|
+
// SPA refresh tokens are 24 h, non-sliding, non-renewable. After that
|
|
24
|
+
// window the user MUST re-auth; nothing the SPA can do silently saves
|
|
25
|
+
// it. Goal of these knobs is to make the unavoidable interactive
|
|
26
|
+
// bounce predictable and to keep the silent path healthy in between.
|
|
27
|
+
const msalConfiguration = {
|
|
28
|
+
auth: {
|
|
29
|
+
clientId: config.clientId,
|
|
30
|
+
authority: config.authority,
|
|
31
|
+
redirectUri: config.redirectUri,
|
|
32
|
+
postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,
|
|
33
|
+
},
|
|
34
|
+
cache: {
|
|
35
|
+
// localStorage is required for Playwright E2E tests — sessionStorage
|
|
36
|
+
// is not preserved across page navigations in the Playwright context.
|
|
37
|
+
cacheLocation: "localStorage",
|
|
38
|
+
// Cache key includes a hash of any `claims` parameter. Without
|
|
39
|
+
// this, MSAL serves the same cached access token even after a
|
|
40
|
+
// claims-challenge / token revocation / role change. The MSAL
|
|
41
|
+
// team has signalled this will become the default; opt in early.
|
|
42
|
+
claimsBasedCachingEnabled: true,
|
|
43
|
+
},
|
|
44
|
+
system: {
|
|
45
|
+
// Treat access tokens as "expired" 10 min before the actual exp
|
|
46
|
+
// claim instead of MSAL's default 5 min. Eliminates the race
|
|
47
|
+
// where the SPA's clock thinks the token is still valid but the
|
|
48
|
+
// resource server rejects it as expired (clock skew, slow request
|
|
49
|
+
// queueing, etc.).
|
|
50
|
+
tokenRenewalOffsetSeconds: 600,
|
|
51
|
+
// Default 6 s is too tight on modern browsers — third-party
|
|
52
|
+
// storage partitioning + slower CPUs in the silent iframe can
|
|
53
|
+
// push the round-trip past it. 10 s is the value MSAL Angular
|
|
54
|
+
// and React samples ship with.
|
|
55
|
+
iframeHashTimeout: 10000,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
msalInstance = new PCA(msalConfiguration);
|
|
59
|
+
await msalInstance.initialize();
|
|
60
|
+
msalConfig = config;
|
|
61
|
+
return msalInstance;
|
|
62
|
+
}
|
|
63
|
+
/** Get the MSAL instance (null when not configured). */
|
|
64
|
+
export function getMsalInstance() {
|
|
65
|
+
return msalInstance;
|
|
66
|
+
}
|
|
67
|
+
/** Get the MSAL auth config (null when not configured). */
|
|
68
|
+
export function getMsalConfig() {
|
|
69
|
+
return msalConfig;
|
|
70
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface AuthUser {
|
|
2
|
+
name: string;
|
|
3
|
+
email: string;
|
|
4
|
+
avatar?: string;
|
|
5
|
+
oid?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class AuthInteractionRequiredError extends Error {
|
|
8
|
+
readonly code?: string;
|
|
9
|
+
readonly cause?: unknown;
|
|
10
|
+
constructor(message: string, options?: {
|
|
11
|
+
code?: string;
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
interface AuthState {
|
|
16
|
+
user: AuthUser | null;
|
|
17
|
+
isAuthenticated: boolean;
|
|
18
|
+
/** Sign in with the given user. Called by AuthProvider after MSAL login. */
|
|
19
|
+
signIn: (user: AuthUser) => void;
|
|
20
|
+
/** Sign out and clear user state. Triggers MSAL logout when configured. */
|
|
21
|
+
signOut: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Acquire an access token for the given audience.
|
|
24
|
+
* Returns null when MSAL is not configured (local dev).
|
|
25
|
+
*
|
|
26
|
+
* Recovery semantics — follows the canonical MSAL.js pattern documented
|
|
27
|
+
* at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
|
|
28
|
+
*
|
|
29
|
+
* 1. ``acquireTokenSilent`` first. Pass-through on success.
|
|
30
|
+
* 2. On a recoverable error (``InteractionRequiredAuthError``,
|
|
31
|
+
* ``monitor_window_timeout`` from blocked silent-SSO iframes,
|
|
32
|
+
* ``login_required`` / ``consent_required``), call
|
|
33
|
+
* :func:`startInteractiveRecovery` to navigate the user through a
|
|
34
|
+
* fresh interactive auth.
|
|
35
|
+
*
|
|
36
|
+
* Recovery uses ``acquireTokenRedirect`` (NOT ``loginRedirect``) as
|
|
37
|
+
* the first step — that's the documented primitive for refreshing a
|
|
38
|
+
* known account's tokens without forcing a full re-login. Falls back
|
|
39
|
+
* to ``loginRedirect`` only when ``acquireTokenRedirect`` itself
|
|
40
|
+
* fails to navigate (browser policy, popup blocker, etc.).
|
|
41
|
+
*
|
|
42
|
+
* Critically: cache is NEVER cleared before the redirect. The
|
|
43
|
+
* previous version cleared the account record before calling
|
|
44
|
+
* ``loginRedirect``; when the redirect failed silently (browser
|
|
45
|
+
* blocking the navigation), the account record was gone but the
|
|
46
|
+
* tokens lingered, leaving every subsequent API call to find
|
|
47
|
+
* ``getAllAccounts() === []``, return null, and ship anonymously
|
|
48
|
+
* to the API — endless 401s, only manual ``localStorage.clear()``
|
|
49
|
+
* recovers. This bug is documented in
|
|
50
|
+
* AzureAD/microsoft-authentication-library-for-js#7551.
|
|
51
|
+
*
|
|
52
|
+
* Recoverable auth failures reject with ``AuthInteractionRequiredError``
|
|
53
|
+
* after recovery has been started. The error exposes ``code`` and
|
|
54
|
+
* ``cause`` so callers can distinguish a normal interaction-required
|
|
55
|
+
* redirect from a blocked/failed redirect attempt.
|
|
56
|
+
*
|
|
57
|
+
* Other ``BrowserAuthError`` codes (``hash_empty_error``,
|
|
58
|
+
* ``hash_does_not_contain_known_properties``, ``block_iframe_reload``)
|
|
59
|
+
* are config / race-condition bugs that another redirect won't fix —
|
|
60
|
+
* propagate them as null without navigating.
|
|
61
|
+
*
|
|
62
|
+
* The "no account in cache" case is handled by detecting ORPHANED
|
|
63
|
+
* STATE (no accounts but localStorage has MSAL token entries) and
|
|
64
|
+
* cleaning the half-corrupted cache before redirecting. Without this,
|
|
65
|
+
* the page renders authenticated UI but every API call goes anonymous.
|
|
66
|
+
*
|
|
67
|
+
* Errors are matched by ``name`` / ``errorCode`` rather than
|
|
68
|
+
* ``instanceof`` because ``@azure/msal-browser`` is loaded via
|
|
69
|
+
* dynamic import; the error class identity isn't shared across
|
|
70
|
+
* module boundaries.
|
|
71
|
+
*
|
|
72
|
+
* ``forceRefresh: true`` skips MSAL's local cache and goes back to the
|
|
73
|
+
* token endpoint with the cached refresh token. Use it from the
|
|
74
|
+
* fetch interceptor when a protected API returns 401 — the first
|
|
75
|
+
* attempt may have used a stale cached access token (claims
|
|
76
|
+
* challenge, conditional-access re-eval, audience drift, etc.) that
|
|
77
|
+
* MSAL still considered valid against its own clock.
|
|
78
|
+
*/
|
|
79
|
+
getAccessToken: (audience?: "api" | "spaces", options?: {
|
|
80
|
+
forceRefresh?: boolean;
|
|
81
|
+
}) => Promise<string | null>;
|
|
82
|
+
/**
|
|
83
|
+
* Force interactive recovery: start an ``acquireTokenRedirect`` (or
|
|
84
|
+
* ``loginRedirect`` fallback). Use this from the fetch interceptor
|
|
85
|
+
* when even a force-refreshed access token still gets rejected by
|
|
86
|
+
* the resource server (the silent path can't tell us "this is
|
|
87
|
+
* fundamentally the wrong token" — it has to come from the protected
|
|
88
|
+
* API saying 401 after we already retried). Throws
|
|
89
|
+
* ``AuthInteractionRequiredError`` once the redirect has been kicked
|
|
90
|
+
* off so the caller can stop processing.
|
|
91
|
+
*/
|
|
92
|
+
recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
|
|
93
|
+
}
|
|
94
|
+
export declare const authStore: import("zustand/vanilla").StoreApi<AuthState>;
|
|
95
|
+
export {};
|