@iloveagents/foundry-agent 0.3.0 → 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 -180
- 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
|
@@ -1,318 +0,0 @@
|
|
|
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
|
-
export interface ServiceFetchOptions {
|
|
16
|
-
/**
|
|
17
|
-
* Acquire an access token for outgoing requests. Return `null` to skip
|
|
18
|
-
* token attachment (callers without auth — e.g. local dev — pass through
|
|
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).
|
|
26
|
-
*/
|
|
27
|
-
acquireToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Force interactive recovery (e.g. ``loginRedirect``) when even a
|
|
31
|
-
* force-refreshed access token gets rejected by the resource server.
|
|
32
|
-
* The fetch interceptor calls this after a SECOND consecutive 401 —
|
|
33
|
-
* at that point we know the silent refresh produced a token the
|
|
34
|
-
* server still won't accept (audience drift, conditional-access
|
|
35
|
-
* re-eval, tenant-policy change), and the only correct UX is to
|
|
36
|
-
* mint a fresh session.
|
|
37
|
-
*
|
|
38
|
-
* Implementations should clear cached auth state and start a redirect
|
|
39
|
-
* to the IdP. They MUST throw rather than return so the fetch caller
|
|
40
|
-
* can stop processing the in-flight request — when this resolves
|
|
41
|
-
* normally the redirect is in flight and the page is about to
|
|
42
|
-
* navigate away.
|
|
43
|
-
*
|
|
44
|
-
* Optional: when omitted, the fetch interceptor lets the second 401
|
|
45
|
-
* propagate as-is. Hosts without an interactive recovery path (e.g.
|
|
46
|
-
* tests, embedded apps) should leave it unset.
|
|
47
|
-
*/
|
|
48
|
-
recoverFromHardAuthFailure?: (reason: unknown) => Promise<never>;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
52
|
-
* Empty / undefined leaves the URL untouched (Vite proxy handles routing
|
|
53
|
-
* in dev).
|
|
54
|
-
*/
|
|
55
|
-
baseUrl?: string;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Resolve `window.location.origin` (or equivalent) for the current runtime.
|
|
59
|
-
* Defaults to a browser-aware lookup; non-browser callers can override.
|
|
60
|
-
*/
|
|
61
|
-
originResolver?: () => string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type ServiceFetch = (
|
|
65
|
-
input: string | URL | Request,
|
|
66
|
-
init?: RequestInit,
|
|
67
|
-
) => Promise<Response>;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Wrapper thrown when ``acquireToken`` rejects inside the fetch
|
|
71
|
-
* interceptor. Lets the outer 401-retry layer distinguish
|
|
72
|
-
* token-acquisition failures (which warrant interactive recovery) from
|
|
73
|
-
* generic ``fetch`` rejections like network drops, aborts, or CORS
|
|
74
|
-
* preflight failures (which do NOT — those would needlessly bounce the
|
|
75
|
-
* user through ``loginRedirect`` on a transient transport error).
|
|
76
|
-
*
|
|
77
|
-
* Exported so hosts that wrap ``serviceFetch`` further can ``instanceof``
|
|
78
|
-
* against the same class without re-declaring it.
|
|
79
|
-
*/
|
|
80
|
-
export class TokenAcquisitionError extends Error {
|
|
81
|
-
override readonly name = "TokenAcquisitionError";
|
|
82
|
-
readonly cause: unknown;
|
|
83
|
-
constructor(cause: unknown) {
|
|
84
|
-
super(cause instanceof Error ? cause.message : String(cause));
|
|
85
|
-
this.cause = cause;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const defaultOriginResolver = (): string =>
|
|
90
|
-
typeof window !== "undefined" ? window.location.origin : "";
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build a service-fetch function. Returns a function with the same shape as
|
|
94
|
-
* `fetch` that rewrites URLs + attaches the Bearer token from `acquireToken`.
|
|
95
|
-
*/
|
|
96
|
-
export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
|
|
97
|
-
const routerBase = (options.baseUrl ?? "").replace(/\/+$/, "");
|
|
98
|
-
const resolveOrigin = options.originResolver ?? defaultOriginResolver;
|
|
99
|
-
|
|
100
|
-
function resolveUrl(url: string): string {
|
|
101
|
-
if (!routerBase) return url; // dev: Vite proxy handles routing
|
|
102
|
-
const origin = resolveOrigin();
|
|
103
|
-
const rel = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
|
104
|
-
return `${routerBase}${rel}`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return async function serviceFetch(input, init): Promise<Response> {
|
|
108
|
-
const url =
|
|
109
|
-
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
110
|
-
const resolvedUrl = resolveUrl(url);
|
|
111
|
-
|
|
112
|
-
// Merge headers from a `Request` input (if provided), then `init`. The
|
|
113
|
-
// Request-input branch must preserve method / body / credentials /
|
|
114
|
-
// signal / etc. — silently dropping them by reading only `.url` would
|
|
115
|
-
// turn POST/PUT into GET and drop required headers.
|
|
116
|
-
const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
|
|
117
|
-
if (init?.headers) {
|
|
118
|
-
new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
|
|
119
|
-
}
|
|
120
|
-
const callerSuppliedAuth = baseHeaders.has("Authorization");
|
|
121
|
-
|
|
122
|
-
function buildRequestInitFrom(req: Request): RequestInit {
|
|
123
|
-
// ReadableStream bodies are single-consume. ``req.clone()`` returns a
|
|
124
|
-
// fresh Request whose body stream is independent — call it once per
|
|
125
|
-
// dispatch attempt so the 401 retry can replay POST/PUT bodies
|
|
126
|
-
// intact.
|
|
127
|
-
const fresh = req.clone();
|
|
128
|
-
const ri: RequestInit = {
|
|
129
|
-
method: fresh.method,
|
|
130
|
-
body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
|
|
131
|
-
credentials: fresh.credentials,
|
|
132
|
-
mode: fresh.mode,
|
|
133
|
-
cache: fresh.cache,
|
|
134
|
-
redirect: fresh.redirect,
|
|
135
|
-
referrer: fresh.referrer,
|
|
136
|
-
integrity: fresh.integrity,
|
|
137
|
-
signal: fresh.signal,
|
|
138
|
-
};
|
|
139
|
-
// Streaming bodies need duplex: "half"; harmless when there's no body.
|
|
140
|
-
if (fresh.body !== null) {
|
|
141
|
-
(ri as RequestInit & { duplex?: string }).duplex = "half";
|
|
142
|
-
}
|
|
143
|
-
return ri;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function dispatch(forceRefreshToken: boolean): Promise<Response> {
|
|
147
|
-
const headers = new Headers(baseHeaders);
|
|
148
|
-
if (!callerSuppliedAuth) {
|
|
149
|
-
try {
|
|
150
|
-
const token = await options.acquireToken(
|
|
151
|
-
forceRefreshToken ? { forceRefresh: true } : undefined,
|
|
152
|
-
);
|
|
153
|
-
if (token) {
|
|
154
|
-
headers.set("Authorization", `Bearer ${token}`);
|
|
155
|
-
}
|
|
156
|
-
} catch (error) {
|
|
157
|
-
// A thrown token-acquisition error usually means the auth layer
|
|
158
|
-
// is starting an interactive recovery. Do not downgrade
|
|
159
|
-
// protected API calls to anonymous requests; that creates
|
|
160
|
-
// noisy 401s and stale UI. Wrap the error so the outer
|
|
161
|
-
// 401-retry layer can distinguish it from generic ``fetch``
|
|
162
|
-
// rejections (network drops, aborts, CORS preflight fails)
|
|
163
|
-
// and only escalate to ``recoverFromHardAuthFailure`` for
|
|
164
|
-
// genuine auth failures.
|
|
165
|
-
throw new TokenAcquisitionError(error);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (input instanceof Request) {
|
|
169
|
-
// Re-clone for THIS attempt — the body stream of the original is
|
|
170
|
-
// either still pristine (first attempt) or already consumed
|
|
171
|
-
// (second attempt); ``clone()`` always returns a fresh, replayable
|
|
172
|
-
// copy.
|
|
173
|
-
const requestInit = buildRequestInitFrom(input);
|
|
174
|
-
return fetch(resolvedUrl, { ...requestInit, ...init, headers });
|
|
175
|
-
}
|
|
176
|
-
return fetch(resolvedUrl, { ...init, headers });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const response = await dispatch(false);
|
|
180
|
-
|
|
181
|
-
// Long-lived tab recovery: when the resource server returns 401 the
|
|
182
|
-
// local MSAL cache may still hold a token MSAL itself thinks is valid
|
|
183
|
-
// (claims challenge, conditional-access re-eval, audience drift, or
|
|
184
|
-
// the user simply left the tab open past the cached access token's
|
|
185
|
-
// server-side validity). Force-refresh the token via the refresh
|
|
186
|
-
// token grant and replay the request once. If the refresh-token is
|
|
187
|
-
// also gone (24h SPA cap), ``acquireToken`` will throw an
|
|
188
|
-
// interaction-required error and the auth layer kicks off
|
|
189
|
-
// ``loginRedirect`` — that's the only correct UX for a hard expiry.
|
|
190
|
-
//
|
|
191
|
-
// Conditions for retry: 401, no caller-supplied auth header (we own
|
|
192
|
-
// the token), and the server didn't already see a fresh token (we
|
|
193
|
-
// only retry once).
|
|
194
|
-
if (response.status !== 401 || callerSuppliedAuth) {
|
|
195
|
-
return response;
|
|
196
|
-
}
|
|
197
|
-
// Drain the failed response body — letting it sit unread keeps the
|
|
198
|
-
// underlying connection occupied on some runtimes.
|
|
199
|
-
response.body?.cancel().catch(() => undefined);
|
|
200
|
-
|
|
201
|
-
// Two failure shapes for the retry:
|
|
202
|
-
// (a) The force-refreshed token grant FAILS — refresh token gone
|
|
203
|
-
// (24h SPA cap), interaction-required claims challenge, etc.
|
|
204
|
-
// ``acquireToken`` throws inside ``dispatch`` BEFORE the fetch
|
|
205
|
-
// leaves the browser. ``dispatch`` re-throws wrapped in
|
|
206
|
-
// ``TokenAcquisitionError`` so we can distinguish this case
|
|
207
|
-
// from network errors below.
|
|
208
|
-
// (b) The grant succeeds but the resource server STILL rejects
|
|
209
|
-
// the token (audience drift, server-side policy change). The
|
|
210
|
-
// retried response status is 401.
|
|
211
|
-
// Both must funnel into ``recoverFromHardAuthFailure`` so the auth
|
|
212
|
-
// layer can kick off ``loginRedirect`` — the only correct UX for a
|
|
213
|
-
// hard expiry. We treat the two auth paths symmetrically.
|
|
214
|
-
//
|
|
215
|
-
// We deliberately do NOT trigger recovery on generic ``dispatch``
|
|
216
|
-
// throws (TypeError from network drops, AbortError from caller
|
|
217
|
-
// cancellation, CORS-preflight failures, etc.) — those are
|
|
218
|
-
// transient transport problems, not auth state corruption, and
|
|
219
|
-
// bouncing the user through ``loginRedirect`` for a flaky network
|
|
220
|
-
// would be a much worse UX than letting the error propagate.
|
|
221
|
-
let retried: Response;
|
|
222
|
-
try {
|
|
223
|
-
retried = await dispatch(true);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
if (err instanceof TokenAcquisitionError && options.recoverFromHardAuthFailure) {
|
|
226
|
-
// ``recoverFromHardAuthFailure`` is expected to throw once the
|
|
227
|
-
// redirect is in flight; if it returns (e.g. test stub) we
|
|
228
|
-
// re-throw the wrapped original so the caller still sees the
|
|
229
|
-
// auth failure rather than a phantom recovery. We pass the
|
|
230
|
-
// unwrapped ``cause`` to recovery so it sees the real MSAL
|
|
231
|
-
// error (InteractionRequiredAuthError etc.) for telemetry.
|
|
232
|
-
await options.recoverFromHardAuthFailure(err.cause);
|
|
233
|
-
}
|
|
234
|
-
throw err;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Hard auth failure path (b): even the force-refreshed token got
|
|
238
|
-
// rejected. A token minted from the cached refresh token carries
|
|
239
|
-
// the same identity claims as the original, so it'll keep getting
|
|
240
|
-
// rejected for the same reason. Only a brand-new ``loginRedirect``
|
|
241
|
-
// produces a token bound to the current server policy. Without
|
|
242
|
-
// this branch the user sees an endless 401 loop until they
|
|
243
|
-
// manually log out.
|
|
244
|
-
if (retried.status === 401 && options.recoverFromHardAuthFailure) {
|
|
245
|
-
// Continuous Access Evaluation / Conditional-Access claims
|
|
246
|
-
// challenge passthrough. When the resource server requires a
|
|
247
|
-
// step-up (MFA, device compliance, revocation invalidation),
|
|
248
|
-
// it returns 401 with ``WWW-Authenticate: Bearer ... claims="…"``.
|
|
249
|
-
// We forward the payload through the recovery callback so the
|
|
250
|
-
// auth layer's redirect carries it to Entra ID and the
|
|
251
|
-
// re-minted token satisfies the exact challenge. Without it,
|
|
252
|
-
// Entra re-issues the same already-rejected claims set and the
|
|
253
|
-
// user loops back into the broken state.
|
|
254
|
-
const claims = parseClaimsChallengeFromWwwAuthenticate(
|
|
255
|
-
retried.headers.get("www-authenticate"),
|
|
256
|
-
);
|
|
257
|
-
retried.body?.cancel().catch(() => undefined);
|
|
258
|
-
const reason = new AuthInteractionRequiredError(
|
|
259
|
-
"API returned 401 after force-refresh retry",
|
|
260
|
-
claims ? { claims } : undefined,
|
|
261
|
-
);
|
|
262
|
-
// ``recoverFromHardAuthFailure`` throws once the redirect is in
|
|
263
|
-
// flight; the throw stops the calling pipeline so we don't
|
|
264
|
-
// return a stale 401 the caller might handle as a real failure.
|
|
265
|
-
await options.recoverFromHardAuthFailure(reason);
|
|
266
|
-
}
|
|
267
|
-
return retried;
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Error class the fetch interceptor raises (and forwards through
|
|
273
|
-
* ``recoverFromHardAuthFailure``) when a 401 needs interactive
|
|
274
|
-
* recovery. Exposed so the auth-layer recovery can pick up a
|
|
275
|
-
* ``claims`` field if one was extracted from the
|
|
276
|
-
* ``WWW-Authenticate`` header.
|
|
277
|
-
*/
|
|
278
|
-
export class AuthInteractionRequiredError extends Error {
|
|
279
|
-
override readonly name = "AuthInteractionRequiredError";
|
|
280
|
-
readonly claims?: string;
|
|
281
|
-
constructor(message: string, options?: { claims?: string }) {
|
|
282
|
-
super(message);
|
|
283
|
-
this.claims = options?.claims;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Extract the claims challenge string from a resource server's
|
|
289
|
-
* ``WWW-Authenticate: Bearer ... claims="…"`` header. Returns
|
|
290
|
-
* ``undefined`` when the header is missing, malformed, or carries
|
|
291
|
-
* no claims directive.
|
|
292
|
-
*
|
|
293
|
-
* The value is forwarded VERBATIM to MSAL's
|
|
294
|
-
* ``acquireTokenRedirect({ claims })``; MSAL handles the
|
|
295
|
-
* base64-url decode + JSON parse itself. We don't try to validate
|
|
296
|
-
* the inner shape — letting Entra speak for itself avoids drift if
|
|
297
|
-
* the schema evolves.
|
|
298
|
-
*
|
|
299
|
-
* Spec: RFC 6750 ``WWW-Authenticate`` + CAE claims-challenge
|
|
300
|
-
* supplement (Microsoft Identity Platform docs).
|
|
301
|
-
*/
|
|
302
|
-
export function parseClaimsChallengeFromWwwAuthenticate(
|
|
303
|
-
header: string | null | undefined,
|
|
304
|
-
): string | undefined {
|
|
305
|
-
if (!header) return undefined;
|
|
306
|
-
// ``WWW-Authenticate`` can carry multiple challenges separated by
|
|
307
|
-
// commas (e.g. ``Basic realm="x", Bearer ...``). We only care
|
|
308
|
-
// about the Bearer challenge.
|
|
309
|
-
const bearer = header.match(/Bearer\s+([^,]+(?:,(?!\s*[A-Za-z]+\s)[^,]*)*)/i);
|
|
310
|
-
if (!bearer) return undefined;
|
|
311
|
-
// Inside the Bearer params, find ``claims="..."`` (quoted) or
|
|
312
|
-
// ``claims=token68`` (unquoted, base64url).
|
|
313
|
-
const quoted = bearer[1].match(/claims\s*=\s*"([^"]*)"/i);
|
|
314
|
-
if (quoted) return quoted[1].length > 0 ? quoted[1] : undefined;
|
|
315
|
-
const unquoted = bearer[1].match(/claims\s*=\s*([A-Za-z0-9_\-+/=]+)/i);
|
|
316
|
-
if (unquoted) return unquoted[1].length > 0 ? unquoted[1] : undefined;
|
|
317
|
-
return undefined;
|
|
318
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// --- AG-UI runner ---
|
|
2
|
-
export { AGUIRunner, type AGUIRunnerOptions, type AGUIRunInput } from "./client/agui-runner.ts";
|
|
3
|
-
export type { RunnerEvent } from "./client/runner-events.ts";
|
|
4
|
-
|
|
5
|
-
// --- Service fetch factory ---
|
|
6
|
-
export {
|
|
7
|
-
createServiceFetch,
|
|
8
|
-
type ServiceFetch,
|
|
9
|
-
type ServiceFetchOptions,
|
|
10
|
-
} from "./client/service-fetch.ts";
|
|
11
|
-
|
|
12
|
-
// --- Tool registry ---
|
|
13
|
-
export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.ts";
|
|
14
|
-
|
|
15
|
-
// --- Stores (vanilla) ---
|
|
16
|
-
export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.ts";
|
|
17
|
-
export {
|
|
18
|
-
citationStore,
|
|
19
|
-
type CitationResult,
|
|
20
|
-
type CitationHandler,
|
|
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
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
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
|
-
|
|
11
|
-
export interface MsalAuthConfig {
|
|
12
|
-
clientId: string;
|
|
13
|
-
authority: string; // https://login.microsoftonline.com/{tenantId}
|
|
14
|
-
redirectUri: string; // window.location.origin
|
|
15
|
-
apiScope: string; // api://{apiClientId}/access_as_user
|
|
16
|
-
postLogoutRedirectUri?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface MsalAccountInfo {
|
|
20
|
-
username: string;
|
|
21
|
-
localAccountId: string;
|
|
22
|
-
name?: string | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface MsalClientApplication {
|
|
26
|
-
initialize(): Promise<void>;
|
|
27
|
-
handleRedirectPromise(): Promise<unknown>;
|
|
28
|
-
clearCache(request?: { account?: MsalAccountInfo }): Promise<void>;
|
|
29
|
-
getAllAccounts(): MsalAccountInfo[];
|
|
30
|
-
loginRedirect(request: {
|
|
31
|
-
scopes: string[];
|
|
32
|
-
prompt?: string;
|
|
33
|
-
/** Same claims-challenge passthrough as ``acquireTokenRedirect``. */
|
|
34
|
-
claims?: string;
|
|
35
|
-
}): Promise<void>;
|
|
36
|
-
logoutRedirect(): Promise<void>;
|
|
37
|
-
setActiveAccount(account: MsalAccountInfo | null): void;
|
|
38
|
-
acquireTokenSilent(request: {
|
|
39
|
-
scopes: string[];
|
|
40
|
-
account: MsalAccountInfo;
|
|
41
|
-
/**
|
|
42
|
-
* Skip MSAL's local cache and force a round-trip to the token
|
|
43
|
-
* endpoint using the cached refresh token. The fetch interceptor
|
|
44
|
-
* uses this on a 401 retry — the first attempt may have served a
|
|
45
|
-
* cached access token MSAL still thought valid (within
|
|
46
|
-
* ``tokenRenewalOffsetSeconds``) that the resource server has
|
|
47
|
-
* since rejected (claims challenge, conditional access re-eval,
|
|
48
|
-
* audience drift).
|
|
49
|
-
*/
|
|
50
|
-
forceRefresh?: boolean;
|
|
51
|
-
}): Promise<{ accessToken: string }>;
|
|
52
|
-
acquireTokenRedirect(request: {
|
|
53
|
-
scopes: string[];
|
|
54
|
-
account: MsalAccountInfo;
|
|
55
|
-
/**
|
|
56
|
-
* Force a fresh interactive login regardless of Entra SSO state.
|
|
57
|
-
* Without ``prompt: "login"``, when the user still has a live
|
|
58
|
-
* Entra session, the redirect silently round-trips and returns
|
|
59
|
-
* THE SAME stale token / claims — leaving the SPA back in the
|
|
60
|
-
* exact broken state we tried to recover from. See
|
|
61
|
-
* https://learn.microsoft.com/entra/identity-platform/msal-error-handling-js
|
|
62
|
-
* "Hard expiry / silent redirect loop" pattern.
|
|
63
|
-
*/
|
|
64
|
-
prompt?: string;
|
|
65
|
-
/**
|
|
66
|
-
* Optional Continuous Access Evaluation / Conditional Access
|
|
67
|
-
* claims challenge payload, parsed from a resource-server 401
|
|
68
|
-
* response's ``WWW-Authenticate: Bearer ... claims="..."``
|
|
69
|
-
* header. When present, MSAL passes it through to Entra so the
|
|
70
|
-
* issued token explicitly satisfies the challenge (MFA step-up,
|
|
71
|
-
* device-compliance refresh, revocation invalidation, etc.).
|
|
72
|
-
*/
|
|
73
|
-
claims?: string;
|
|
74
|
-
}): Promise<void>;
|
|
75
|
-
acquireTokenPopup(request: {
|
|
76
|
-
scopes: string[];
|
|
77
|
-
account: MsalAccountInfo;
|
|
78
|
-
}): Promise<{ accessToken: string }>;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let msalInstance: MsalClientApplication | null = null;
|
|
82
|
-
let msalConfig: MsalAuthConfig | null = null;
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Initialize the MSAL singleton. Must be called once before rendering.
|
|
86
|
-
* Dynamically imports `@azure/msal-browser` so the dependency stays optional.
|
|
87
|
-
*/
|
|
88
|
-
export async function initializeMsal(
|
|
89
|
-
config: MsalAuthConfig,
|
|
90
|
-
): Promise<MsalClientApplication> {
|
|
91
|
-
if (msalInstance) return msalInstance;
|
|
92
|
-
|
|
93
|
-
const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
|
|
94
|
-
|
|
95
|
-
// Token-renewal config — see Microsoft Learn:
|
|
96
|
-
// https://learn.microsoft.com/entra/msal/javascript/browser/errors
|
|
97
|
-
//
|
|
98
|
-
// SPA refresh tokens are 24 h, non-sliding, non-renewable. After that
|
|
99
|
-
// window the user MUST re-auth; nothing the SPA can do silently saves
|
|
100
|
-
// it. Goal of these knobs is to make the unavoidable interactive
|
|
101
|
-
// bounce predictable and to keep the silent path healthy in between.
|
|
102
|
-
const msalConfiguration = {
|
|
103
|
-
auth: {
|
|
104
|
-
clientId: config.clientId,
|
|
105
|
-
authority: config.authority,
|
|
106
|
-
redirectUri: config.redirectUri,
|
|
107
|
-
postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,
|
|
108
|
-
},
|
|
109
|
-
cache: {
|
|
110
|
-
// localStorage is required for Playwright E2E tests — sessionStorage
|
|
111
|
-
// is not preserved across page navigations in the Playwright context.
|
|
112
|
-
cacheLocation: "localStorage",
|
|
113
|
-
// Cache key includes a hash of any `claims` parameter. Without
|
|
114
|
-
// this, MSAL serves the same cached access token even after a
|
|
115
|
-
// claims-challenge / token revocation / role change. The MSAL
|
|
116
|
-
// team has signalled this will become the default; opt in early.
|
|
117
|
-
claimsBasedCachingEnabled: true,
|
|
118
|
-
},
|
|
119
|
-
system: {
|
|
120
|
-
// Treat access tokens as "expired" 10 min before the actual exp
|
|
121
|
-
// claim instead of MSAL's default 5 min. Eliminates the race
|
|
122
|
-
// where the SPA's clock thinks the token is still valid but the
|
|
123
|
-
// resource server rejects it as expired (clock skew, slow request
|
|
124
|
-
// queueing, etc.).
|
|
125
|
-
tokenRenewalOffsetSeconds: 600,
|
|
126
|
-
// Default 6 s is too tight on modern browsers — third-party
|
|
127
|
-
// storage partitioning + slower CPUs in the silent iframe can
|
|
128
|
-
// push the round-trip past it. 10 s is the value MSAL Angular
|
|
129
|
-
// and React samples ship with.
|
|
130
|
-
iframeHashTimeout: 10000,
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
msalInstance = new (PCA as new (config: typeof msalConfiguration) => MsalClientApplication)(
|
|
135
|
-
msalConfiguration,
|
|
136
|
-
);
|
|
137
|
-
await msalInstance.initialize();
|
|
138
|
-
msalConfig = config;
|
|
139
|
-
return msalInstance;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Get the MSAL instance (null when not configured). */
|
|
143
|
-
export function getMsalInstance(): MsalClientApplication | null {
|
|
144
|
-
return msalInstance;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Get the MSAL auth config (null when not configured). */
|
|
148
|
-
export function getMsalConfig(): MsalAuthConfig | null {
|
|
149
|
-
return msalConfig;
|
|
150
|
-
}
|