@microsoft/agents-hosting 1.6.1 → 1.7.0-beta.1.g8bcd4f11a7
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/dist/package.json +3 -3
- package/dist/src/app/streaming/streamingResponse.d.ts +11 -0
- package/dist/src/app/streaming/streamingResponse.js +21 -0
- package/dist/src/app/streaming/streamingResponse.js.map +1 -1
- package/dist/src/auth/MemoryCache.d.ts +2 -1
- package/dist/src/auth/MemoryCache.js +7 -1
- package/dist/src/auth/MemoryCache.js.map +1 -1
- package/dist/src/auth/authConfiguration.d.ts +1 -1
- package/dist/src/auth/authConfiguration.js +36 -1
- package/dist/src/auth/authConfiguration.js.map +1 -1
- package/dist/src/auth/authConstants.d.ts +7 -7
- package/dist/src/auth/authConstants.js.map +1 -1
- package/dist/src/auth/connectionManager.d.ts +92 -0
- package/dist/src/auth/{msalConnectionManager.js → connectionManager.js} +50 -48
- package/dist/src/auth/connectionManager.js.map +1 -0
- package/dist/src/auth/index.d.ts +5 -3
- package/dist/src/auth/index.js +5 -3
- package/dist/src/auth/index.js.map +1 -1
- package/dist/src/auth/jwt-middleware.d.ts +6 -0
- package/dist/src/auth/jwt-middleware.js +32 -1
- package/dist/src/auth/jwt-middleware.js.map +1 -1
- package/dist/src/auth/msal/msalConnectionManager.d.ts +17 -0
- package/dist/src/auth/msal/msalConnectionManager.js +50 -0
- package/dist/src/auth/msal/msalConnectionManager.js.map +1 -0
- package/dist/src/auth/msal/msalConnectionSettings.d.ts +68 -0
- package/dist/src/auth/msal/msalConnectionSettings.js +7 -0
- package/dist/src/auth/msal/msalConnectionSettings.js.map +1 -0
- package/dist/src/auth/{msalTokenCredential.d.ts → msal/msalTokenCredential.d.ts} +1 -1
- package/dist/src/auth/{msalTokenCredential.js → msal/msalTokenCredential.js} +2 -2
- package/dist/src/auth/msal/msalTokenCredential.js.map +1 -0
- package/dist/src/auth/{msalTokenProvider.d.ts → msal/msalTokenProvider.d.ts} +23 -3
- package/dist/src/auth/{msalTokenProvider.js → msal/msalTokenProvider.js} +264 -160
- package/dist/src/auth/msal/msalTokenProvider.js.map +1 -0
- package/dist/src/auth/settings.d.ts +142 -133
- package/dist/src/auth/settings.js +49 -5
- package/dist/src/auth/settings.js.map +1 -1
- package/dist/src/auth/sidecar/sidecarAuthProvider.d.ts +74 -0
- package/dist/src/auth/sidecar/sidecarAuthProvider.js +181 -0
- package/dist/src/auth/sidecar/sidecarAuthProvider.js.map +1 -0
- package/dist/src/auth/sidecar/sidecarConnectionSettings.d.ts +65 -0
- package/dist/src/auth/sidecar/sidecarConnectionSettings.js +7 -0
- package/dist/src/auth/sidecar/sidecarConnectionSettings.js.map +1 -0
- package/dist/src/auth/sidecar/sidecarHttpClient.d.ts +95 -0
- package/dist/src/auth/sidecar/sidecarHttpClient.js +406 -0
- package/dist/src/auth/sidecar/sidecarHttpClient.js.map +1 -0
- package/dist/src/auth/sidecar/sidecarModels.d.ts +110 -0
- package/dist/src/auth/sidecar/sidecarModels.js +48 -0
- package/dist/src/auth/sidecar/sidecarModels.js.map +1 -0
- package/dist/src/auth/sidecar/sidecarTokenExpiry.d.ts +13 -0
- package/dist/src/auth/sidecar/sidecarTokenExpiry.js +39 -0
- package/dist/src/auth/sidecar/sidecarTokenExpiry.js.map +1 -0
- package/dist/src/cloudAdapter.js +1 -1
- package/dist/src/cloudAdapter.js.map +1 -1
- package/dist/src/errorHelper.js +96 -0
- package/dist/src/errorHelper.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/oauth/userTokenClient.d.ts +12 -0
- package/dist/src/oauth/userTokenClient.js +28 -8
- package/dist/src/oauth/userTokenClient.js.map +1 -1
- package/package.json +3 -3
- package/src/app/streaming/streamingResponse.ts +24 -1
- package/src/auth/MemoryCache.ts +7 -1
- package/src/auth/authConfiguration.ts +36 -3
- package/src/auth/authConstants.ts +7 -7
- package/src/auth/{msalConnectionManager.ts → connectionManager.ts} +65 -61
- package/src/auth/index.ts +5 -3
- package/src/auth/jwt-middleware.ts +31 -1
- package/src/auth/msal/msalConnectionManager.ts +55 -0
- package/src/auth/msal/msalConnectionSettings.ts +79 -0
- package/src/auth/{msalTokenCredential.ts → msal/msalTokenCredential.ts} +2 -1
- package/src/auth/{msalTokenProvider.ts → msal/msalTokenProvider.ts} +337 -156
- package/src/auth/settings.ts +131 -76
- package/src/auth/sidecar/sidecarAuthProvider.ts +234 -0
- package/src/auth/sidecar/sidecarConnectionSettings.ts +72 -0
- package/src/auth/sidecar/sidecarHttpClient.ts +443 -0
- package/src/auth/sidecar/sidecarModels.ts +153 -0
- package/src/auth/sidecar/sidecarTokenExpiry.ts +35 -0
- package/src/cloudAdapter.ts +1 -1
- package/src/errorHelper.ts +110 -0
- package/src/index.ts +1 -1
- package/src/oauth/userTokenClient.ts +43 -9
- package/dist/src/auth/msalConnectionManager.d.ts +0 -64
- package/dist/src/auth/msalConnectionManager.js.map +0 -1
- package/dist/src/auth/msalTokenCredential.js.map +0 -1
- package/dist/src/auth/msalTokenProvider.js.map +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isIP } from 'node:net'
|
|
7
|
+
import { debug } from '@microsoft/agents-telemetry'
|
|
8
|
+
import { ExceptionHelper } from '@microsoft/agents-activity'
|
|
9
|
+
import { Errors } from '../../errorHelper'
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
12
|
+
DEFAULT_RETRY_COUNT,
|
|
13
|
+
DEFAULT_SIDECAR_BASE_URL,
|
|
14
|
+
SidecarProblemDetails,
|
|
15
|
+
SidecarRequestOptions,
|
|
16
|
+
SidecarTokenResult
|
|
17
|
+
} from './sidecarModels'
|
|
18
|
+
|
|
19
|
+
const logger = debug('agents:sidecar')
|
|
20
|
+
|
|
21
|
+
const DEFAULT_RETRY_BACKOFF_BASE_MS = 2000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reusable HTTP client for communicating with the Microsoft Entra Agent ID sidecar (agent container).
|
|
25
|
+
* Handles URL construction, query parameter building, response parsing, retry, and SSRF-safe base
|
|
26
|
+
* URL validation. Uses the native `fetch` API (Node.js 20+).
|
|
27
|
+
*/
|
|
28
|
+
export class SidecarHttpClient {
|
|
29
|
+
/** Default per-attempt request timeout (milliseconds). */
|
|
30
|
+
static readonly DEFAULT_TIMEOUT_MS = DEFAULT_REQUEST_TIMEOUT_MS
|
|
31
|
+
|
|
32
|
+
/** Default number of retry attempts for transient failures. */
|
|
33
|
+
static readonly DEFAULT_RETRY_COUNT = DEFAULT_RETRY_COUNT
|
|
34
|
+
|
|
35
|
+
private readonly _baseUrl: string
|
|
36
|
+
private readonly _timeoutMs: number
|
|
37
|
+
private readonly _retryCount: number
|
|
38
|
+
private readonly _retryBackoffBaseMs: number
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a new {@link SidecarHttpClient}.
|
|
42
|
+
* @param baseUrl The resolved (and validated) sidecar base URL.
|
|
43
|
+
* @param timeoutMs Per-attempt request timeout in milliseconds.
|
|
44
|
+
* @param retryCount Number of retry attempts for transient failures (0 disables retries).
|
|
45
|
+
* @param retryBackoffBaseMs Base delay for exponential backoff; primarily a test seam.
|
|
46
|
+
*/
|
|
47
|
+
constructor (
|
|
48
|
+
baseUrl: string,
|
|
49
|
+
timeoutMs: number = SidecarHttpClient.DEFAULT_TIMEOUT_MS,
|
|
50
|
+
retryCount: number = SidecarHttpClient.DEFAULT_RETRY_COUNT,
|
|
51
|
+
retryBackoffBaseMs: number = DEFAULT_RETRY_BACKOFF_BASE_MS
|
|
52
|
+
) {
|
|
53
|
+
this._baseUrl = SidecarHttpClient.stripTrailingSlashes(baseUrl ?? SidecarHttpClient.resolveBaseUrl())
|
|
54
|
+
this._timeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SidecarHttpClient.DEFAULT_TIMEOUT_MS
|
|
55
|
+
this._retryCount = Number.isFinite(retryCount) ? Math.max(0, Math.floor(retryCount)) : SidecarHttpClient.DEFAULT_RETRY_COUNT
|
|
56
|
+
this._retryBackoffBaseMs = Number.isFinite(retryBackoffBaseMs) && retryBackoffBaseMs >= 0 ? retryBackoffBaseMs : DEFAULT_RETRY_BACKOFF_BASE_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Removes any trailing `/` characters from `value` using a linear scan. Avoids a regular
|
|
61
|
+
* expression so untrusted input with many `/` characters cannot trigger super-linear
|
|
62
|
+
* backtracking (ReDoS).
|
|
63
|
+
* @param value The string to trim.
|
|
64
|
+
* @returns `value` without trailing slashes.
|
|
65
|
+
*/
|
|
66
|
+
private static stripTrailingSlashes (value: string): string {
|
|
67
|
+
let end = value.length
|
|
68
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */) {
|
|
69
|
+
end--
|
|
70
|
+
}
|
|
71
|
+
return end === value.length ? value : value.slice(0, end)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the sidecar base URL. Resolution order: `SIDECAR_URL` environment variable >
|
|
76
|
+
* `configuredUrl` > {@link DEFAULT_SIDECAR_BASE_URL}. Empty/whitespace values are treated as unset.
|
|
77
|
+
* @param configuredUrl The optional base URL from configuration.
|
|
78
|
+
* @returns The resolved base URL.
|
|
79
|
+
*/
|
|
80
|
+
static resolveBaseUrl (configuredUrl?: string): string {
|
|
81
|
+
const envUrl = process.env.SIDECAR_URL
|
|
82
|
+
if (envUrl && envUrl.trim()) {
|
|
83
|
+
return envUrl.trim()
|
|
84
|
+
}
|
|
85
|
+
return configuredUrl && configuredUrl.trim() ? configuredUrl.trim() : DEFAULT_SIDECAR_BASE_URL
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns a copy of `raw` with any userinfo (credentials) removed, safe to include in logs and
|
|
90
|
+
* error messages. Falls back to a regex strip when `raw` is not a parseable URL.
|
|
91
|
+
*/
|
|
92
|
+
private static redactUrl (raw: string): string {
|
|
93
|
+
try {
|
|
94
|
+
const u = new URL(raw)
|
|
95
|
+
if (u.username || u.password) {
|
|
96
|
+
u.username = ''
|
|
97
|
+
u.password = ''
|
|
98
|
+
}
|
|
99
|
+
return u.toString()
|
|
100
|
+
} catch {
|
|
101
|
+
return raw.replace(/\/\/[^/@]*@/, '//')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validates that the resolved sidecar base URL is safe to call. The URL must be a well-formed
|
|
107
|
+
* absolute http/https URL without userinfo. Unless `bypassLocalNetworkRestriction` is set, the host
|
|
108
|
+
* must also be a loopback address or a private network address (RFC 1918 / RFC 4193 / link-local) to
|
|
109
|
+
* avoid sending agent credentials off-box (SSRF safety).
|
|
110
|
+
* @param resolvedUrl The resolved sidecar base URL.
|
|
111
|
+
* @param bypassLocalNetworkRestriction Skip the loopback/private-address check (UNSAFE).
|
|
112
|
+
* @throws When the URL is malformed or points to a disallowed address.
|
|
113
|
+
*/
|
|
114
|
+
static validateBaseUrl (resolvedUrl: string, bypassLocalNetworkRestriction: boolean): void {
|
|
115
|
+
let uri: URL
|
|
116
|
+
try {
|
|
117
|
+
uri = new URL(resolvedUrl)
|
|
118
|
+
} catch {
|
|
119
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarBaseUrlInvalid, undefined, { url: SidecarHttpClient.redactUrl(resolvedUrl) })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (uri.protocol !== 'http:' && uri.protocol !== 'https:') {
|
|
123
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarBaseUrlInsecureScheme, undefined, { url: SidecarHttpClient.redactUrl(resolvedUrl) })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (uri.username || uri.password) {
|
|
127
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarBaseUrlUserInfo, undefined, { url: SidecarHttpClient.redactUrl(resolvedUrl) })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (bypassLocalNetworkRestriction) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (SidecarHttpClient.isLoopbackOrPrivateHost(uri.hostname)) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarBaseUrlNotLocal, undefined, { url: SidecarHttpClient.redactUrl(resolvedUrl) })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private static isLoopbackOrPrivateHost (host: string): boolean {
|
|
142
|
+
// URL.hostname wraps IPv6 literals in brackets (e.g. "[::1]"); strip them before classification.
|
|
143
|
+
const normalized = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
|
|
144
|
+
const ipVersion = isIP(normalized)
|
|
145
|
+
if (ipVersion === 0) {
|
|
146
|
+
// DNS host: only "localhost" (and subdomains) is treated as safe.
|
|
147
|
+
const lower = normalized.toLowerCase()
|
|
148
|
+
return lower === 'localhost' || lower.endsWith('.localhost')
|
|
149
|
+
}
|
|
150
|
+
if (ipVersion === 4) {
|
|
151
|
+
return SidecarHttpClient.isPrivateOrLoopbackIPv4(normalized)
|
|
152
|
+
}
|
|
153
|
+
return SidecarHttpClient.isPrivateOrLoopbackIPv6(normalized)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static isPrivateOrLoopbackIPv4 (host: string): boolean {
|
|
157
|
+
const b = host.split('.').map((p) => parseInt(p, 10))
|
|
158
|
+
if (b.length !== 4 || b.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
if (b[0] === 127) return true // 127.0.0.0/8 loopback
|
|
162
|
+
if (b[0] === 10) return true // 10.0.0.0/8
|
|
163
|
+
if (b[0] === 172 && b[1] >= 16 && b[1] <= 31) return true // 172.16.0.0/12
|
|
164
|
+
if (b[0] === 192 && b[1] === 168) return true // 192.168.0.0/16
|
|
165
|
+
if (b[0] === 169 && b[1] === 254) return true // 169.254.0.0/16 link-local
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static isPrivateOrLoopbackIPv6 (host: string): boolean {
|
|
170
|
+
const lower = host.toLowerCase()
|
|
171
|
+
if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return true // loopback (`::` is the unspecified address, not loopback)
|
|
172
|
+
// IPv4-mapped/-translated addresses in either dotted (::ffff:127.0.0.1)
|
|
173
|
+
// or hex-compressed (::ffff:7f00:1) form.
|
|
174
|
+
const mappedIPv4 = SidecarHttpClient.extractMappedIPv4(lower)
|
|
175
|
+
if (mappedIPv4) {
|
|
176
|
+
return SidecarHttpClient.isPrivateOrLoopbackIPv4(mappedIPv4)
|
|
177
|
+
}
|
|
178
|
+
const firstHextet = parseInt(lower.split(':')[0], 16)
|
|
179
|
+
if (!Number.isNaN(firstHextet)) {
|
|
180
|
+
if ((firstHextet & 0xfe00) === 0xfc00) return true // fc00::/7 unique local
|
|
181
|
+
if ((firstHextet & 0xffc0) === 0xfe80) return true // fe80::/10 link-local
|
|
182
|
+
if ((firstHextet & 0xffc0) === 0xfec0) return true // fec0::/10 site-local (deprecated)
|
|
183
|
+
}
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Extracts the embedded IPv4 address from an IPv4-mapped/-translated IPv6 literal, returning it
|
|
189
|
+
* in dotted-quad form, or `undefined` if `host` is not such an address. Handles both the dotted
|
|
190
|
+
* form (`::ffff:127.0.0.1`) and the hex-compressed form Node emits (`::ffff:7f00:1`).
|
|
191
|
+
*/
|
|
192
|
+
private static extractMappedIPv4 (host: string): string | undefined {
|
|
193
|
+
const dotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
194
|
+
if (dotted) {
|
|
195
|
+
return dotted[1]
|
|
196
|
+
}
|
|
197
|
+
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
198
|
+
if (hex) {
|
|
199
|
+
const high = parseInt(hex[1], 16)
|
|
200
|
+
const low = parseInt(hex[2], 16)
|
|
201
|
+
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
|
|
202
|
+
}
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Calls `GET /AuthorizationHeaderUnauthenticated/{serviceName}` with the specified options.
|
|
208
|
+
* @param serviceName The sidecar downstream API name.
|
|
209
|
+
* @param options The request options used to build the query string.
|
|
210
|
+
* @param signal Optional abort signal supplied by the caller.
|
|
211
|
+
* @returns The parsed token result.
|
|
212
|
+
*/
|
|
213
|
+
async getAuthorizationHeaderUnauthenticated (
|
|
214
|
+
serviceName: string,
|
|
215
|
+
options?: SidecarRequestOptions,
|
|
216
|
+
signal?: AbortSignal
|
|
217
|
+
): Promise<SidecarTokenResult> {
|
|
218
|
+
const url = this.buildUrl(`/AuthorizationHeaderUnauthenticated/${encodeURIComponent(serviceName)}`, options)
|
|
219
|
+
return this.sendAndParse(url, signal)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Checks sidecar health via `GET /healthz`.
|
|
224
|
+
* @returns `true` when the sidecar responds with a success status, `false` otherwise.
|
|
225
|
+
*/
|
|
226
|
+
async isHealthy (): Promise<boolean> {
|
|
227
|
+
try {
|
|
228
|
+
const response = await this.fetchWithTimeout(`${this._baseUrl}/healthz`)
|
|
229
|
+
return response.ok
|
|
230
|
+
} catch {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private buildUrl (path: string, options?: SidecarRequestOptions): string {
|
|
236
|
+
const url = `${this._baseUrl}${path}`
|
|
237
|
+
if (!options) {
|
|
238
|
+
return url
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// AgentUsername (UPN) and AgentUserId (object id) are mutually exclusive per the sidecar contract.
|
|
242
|
+
if (options.agentUsername && options.agentUserId) {
|
|
243
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarUserIdentityMutuallyExclusive)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const params = new URLSearchParams()
|
|
247
|
+
if (options.agentIdentity) {
|
|
248
|
+
params.append('AgentIdentity', options.agentIdentity)
|
|
249
|
+
}
|
|
250
|
+
if (options.agentUsername) {
|
|
251
|
+
params.append('AgentUsername', options.agentUsername)
|
|
252
|
+
}
|
|
253
|
+
if (options.agentUserId) {
|
|
254
|
+
params.append('AgentUserId', options.agentUserId)
|
|
255
|
+
}
|
|
256
|
+
if (options.scopes) {
|
|
257
|
+
for (const scope of options.scopes) {
|
|
258
|
+
if (scope && scope.trim()) {
|
|
259
|
+
params.append('optionsOverride.Scopes', scope)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (options.requestAppToken === true) {
|
|
264
|
+
params.append('optionsOverride.RequestAppToken', 'true')
|
|
265
|
+
}
|
|
266
|
+
if (options.tenant) {
|
|
267
|
+
params.append('optionsOverride.AcquireTokenOptions.Tenant', options.tenant)
|
|
268
|
+
}
|
|
269
|
+
if (options.forceRefresh === true) {
|
|
270
|
+
params.append('optionsOverride.AcquireTokenOptions.ForceRefresh', 'true')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const query = params.toString()
|
|
274
|
+
return query ? `${url}?${query}` : url
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async withTimeout<T> (signal: AbortSignal | undefined, op: (innerSignal: AbortSignal) => Promise<T>): Promise<T> {
|
|
278
|
+
const controller = new AbortController()
|
|
279
|
+
const timer = setTimeout(() => controller.abort(), this._timeoutMs)
|
|
280
|
+
const onAbort = () => controller.abort()
|
|
281
|
+
if (signal) {
|
|
282
|
+
if (signal.aborted) {
|
|
283
|
+
controller.abort()
|
|
284
|
+
} else {
|
|
285
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
return await op(controller.signal)
|
|
290
|
+
} finally {
|
|
291
|
+
clearTimeout(timer)
|
|
292
|
+
signal?.removeEventListener('abort', onAbort)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async fetchWithTimeout (url: string, signal?: AbortSignal): Promise<Response> {
|
|
297
|
+
return this.withTimeout(signal, (innerSignal) => fetch(url, { method: 'GET', signal: innerSignal }))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Performs the request and reads the full response body within a single timeout window, so a
|
|
302
|
+
* sidecar that streams response headers and then stalls the body cannot hang token acquisition
|
|
303
|
+
* beyond `requestTimeout`.
|
|
304
|
+
*/
|
|
305
|
+
private async fetchAndReadText (url: string, signal?: AbortSignal): Promise<{ response: Response, body: string }> {
|
|
306
|
+
return this.withTimeout(signal, async (innerSignal) => {
|
|
307
|
+
const response = await fetch(url, { method: 'GET', signal: innerSignal })
|
|
308
|
+
const body = await response.text().catch(() => '')
|
|
309
|
+
return { response, body }
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async sendAndParse (url: string, signal?: AbortSignal): Promise<SidecarTokenResult> {
|
|
314
|
+
const maxAttempts = this._retryCount + 1
|
|
315
|
+
// Log only the request path, never the query string, which can carry PII (UPN, object id, tenant).
|
|
316
|
+
const requestPath = url.split('?')[0]
|
|
317
|
+
|
|
318
|
+
for (let attempt = 1; ; attempt++) {
|
|
319
|
+
logger.debug('Sidecar request (attempt %d/%d): GET %s', attempt, maxAttempts, requestPath)
|
|
320
|
+
|
|
321
|
+
let response: Response
|
|
322
|
+
let body: string
|
|
323
|
+
try {
|
|
324
|
+
({ response, body } = await this.fetchAndReadText(url, signal))
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// A caller-requested cancellation is not transient — propagate it.
|
|
327
|
+
if (signal?.aborted) {
|
|
328
|
+
throw err
|
|
329
|
+
}
|
|
330
|
+
if (attempt >= maxAttempts) {
|
|
331
|
+
logger.error('Sidecar request to %s failed after %d attempt(s).', requestPath, attempt)
|
|
332
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarRequestFailed, err as Error, {
|
|
333
|
+
attempts: String(attempt),
|
|
334
|
+
message: (err as Error)?.message ?? 'network error'
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
logger.warn('Sidecar request to %s failed (attempt %d/%d); retrying.', requestPath, attempt, maxAttempts)
|
|
338
|
+
await this.delayBeforeRetry(attempt, signal)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
if (SidecarHttpClient.isTransientStatus(response.status) && attempt < maxAttempts) {
|
|
344
|
+
logger.warn('Sidecar returned transient status %d from %s (attempt %d/%d); retrying.', response.status, requestPath, attempt, maxAttempts)
|
|
345
|
+
await this.delayBeforeRetry(attempt, signal)
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const problem = SidecarHttpClient.tryParseProblemDetails(body)
|
|
350
|
+
// Never surface problem.detail: the sidecar can echo request parameters (UPN, object id,
|
|
351
|
+
// tenant) into it. Use only the non-sensitive title/status in logs and the thrown error.
|
|
352
|
+
const message = problem?.title ?? `Sidecar returned ${response.status}`
|
|
353
|
+
logger.error('Sidecar returned error. Status: %d, URL: %s, Title: %s', response.status, requestPath, problem?.title ?? '(none)')
|
|
354
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarErrorResponse, undefined, {
|
|
355
|
+
status: String(response.status),
|
|
356
|
+
message
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const result = SidecarHttpClient.parseTokenResponse(body)
|
|
361
|
+
logger.debug('Sidecar token acquired from %s. Scheme: %s, TokenLength: %d', requestPath, result.scheme, result.token.length)
|
|
362
|
+
return result
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async delayBeforeRetry (attempt: number, signal?: AbortSignal): Promise<void> {
|
|
367
|
+
const multiplier = Math.pow(2, attempt - 1)
|
|
368
|
+
const delay = this._retryBackoffBaseMs * multiplier
|
|
369
|
+
if (delay <= 0) {
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
// Resolve after the backoff delay, but reject immediately if the caller aborts so a pending
|
|
373
|
+
// retry wait does not keep the caller blocked for the full (potentially several second) delay.
|
|
374
|
+
await new Promise<void>((resolve, reject) => {
|
|
375
|
+
let onAbort: (() => void) | undefined
|
|
376
|
+
const timer = setTimeout(() => {
|
|
377
|
+
if (signal && onAbort) {
|
|
378
|
+
signal.removeEventListener('abort', onAbort)
|
|
379
|
+
}
|
|
380
|
+
resolve()
|
|
381
|
+
}, delay)
|
|
382
|
+
if (signal) {
|
|
383
|
+
onAbort = () => {
|
|
384
|
+
clearTimeout(timer)
|
|
385
|
+
reject(signal.reason ?? new Error('Aborted'))
|
|
386
|
+
}
|
|
387
|
+
if (signal.aborted) {
|
|
388
|
+
onAbort()
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private static isTransientStatus (status: number): boolean {
|
|
397
|
+
return status === 408 || status === 429 || status >= 500
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private static parseTokenResponse (responseContent: string): SidecarTokenResult {
|
|
401
|
+
let body: { authorizationHeader?: unknown }
|
|
402
|
+
try {
|
|
403
|
+
body = JSON.parse(responseContent)
|
|
404
|
+
} catch (err) {
|
|
405
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarResponseUnparsable, err as Error)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const headerValue = typeof body.authorizationHeader === 'string' ? body.authorizationHeader.trim() : ''
|
|
409
|
+
if (headerValue) {
|
|
410
|
+
const spaceIndex = headerValue.indexOf(' ')
|
|
411
|
+
if (spaceIndex > 0) {
|
|
412
|
+
// "{scheme} {token}" form: the token after the separator must be non-empty.
|
|
413
|
+
const token = headerValue.substring(spaceIndex + 1).trim()
|
|
414
|
+
if (token.length > 0) {
|
|
415
|
+
return { scheme: headerValue.substring(0, spaceIndex), token }
|
|
416
|
+
}
|
|
417
|
+
} else if (!SidecarHttpClient.isKnownAuthScheme(headerValue)) {
|
|
418
|
+
// No separator: treat the whole value as a raw token and default the scheme to
|
|
419
|
+
// Bearer. A lone scheme name (e.g. "Bearer") is not a valid token and falls
|
|
420
|
+
// through to the error below.
|
|
421
|
+
return { scheme: 'Bearer', token: headerValue }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
throw ExceptionHelper.generateException(Error, Errors.SidecarResponseMissingAuthorizationHeader)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private static isKnownAuthScheme (value: string): boolean {
|
|
429
|
+
const lower = value.toLowerCase()
|
|
430
|
+
return lower === 'bearer' || lower === 'pop'
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private static tryParseProblemDetails (content: string): SidecarProblemDetails | undefined {
|
|
434
|
+
if (!content) {
|
|
435
|
+
return undefined
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(content) as SidecarProblemDetails
|
|
439
|
+
} catch {
|
|
440
|
+
return undefined
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AuthConfiguration } from '../authConfiguration'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default sidecar downstream API name used for app-only and autonomous agent token acquisition.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_SERVICE_NAME = 'default'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default sidecar downstream API name used to acquire the Blueprint (agent application) token.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_BLUEPRINT_SERVICE_NAME = 'agenticblueprint'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default base URL of the Entra Agent ID sidecar (agent container).
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_SIDECAR_BASE_URL = 'http://localhost:5178'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default per-attempt HTTP request timeout (in milliseconds) for sidecar calls.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default number of retry attempts for transient sidecar failures.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_RETRY_COUNT = 3
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolved (normalized) connection settings for the sidecar-based token provider, derived from a
|
|
35
|
+
* connection's {@link AuthConfiguration} with all sidecar defaults applied. Distinct from the
|
|
36
|
+
* configuration-level {@link SidecarConnectionSettings} (which has optional properties): this is the
|
|
37
|
+
* runtime shape consumed by the provider after normalization via {@link toSidecarConnectionSettings}.
|
|
38
|
+
*/
|
|
39
|
+
export interface ResolvedSidecarConnectionSettings {
|
|
40
|
+
/**
|
|
41
|
+
* The configured downstream API service name in the sidecar's DownstreamApis configuration.
|
|
42
|
+
* Defaults to `'default'`.
|
|
43
|
+
*/
|
|
44
|
+
serviceName: string
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The sidecar downstream API name used to acquire the Blueprint (agent application) token for the
|
|
48
|
+
* agentic FIC chain. Defaults to `'agenticblueprint'`.
|
|
49
|
+
*/
|
|
50
|
+
blueprintServiceName: string
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optional base URL of the Entra Agent ID sidecar (agent container). Resolution order:
|
|
54
|
+
* `SIDECAR_URL` environment variable > this setting > {@link DEFAULT_SIDECAR_BASE_URL}.
|
|
55
|
+
*/
|
|
56
|
+
sidecarBaseUrl?: string
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* When `true`, disables the loopback/private-address safety check on the resolved sidecar base
|
|
60
|
+
* URL. UNSAFE; leave `false` in all normal deployments.
|
|
61
|
+
*/
|
|
62
|
+
bypassLocalNetworkRestriction: boolean
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Per-attempt HTTP request timeout (in milliseconds) for sidecar calls. Defaults to 30000.
|
|
66
|
+
*/
|
|
67
|
+
requestTimeout: number
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Number of retry attempts for transient sidecar failures. Defaults to 3.
|
|
71
|
+
*/
|
|
72
|
+
retryCount: number
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The OAuth scopes to request, used to override the sidecar's configured downstream API scopes.
|
|
76
|
+
*/
|
|
77
|
+
scopes?: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds a {@link ResolvedSidecarConnectionSettings} from a connection's {@link AuthConfiguration},
|
|
82
|
+
* applying the documented defaults.
|
|
83
|
+
* @param config The connection authentication configuration.
|
|
84
|
+
* @returns The resolved sidecar connection settings.
|
|
85
|
+
*/
|
|
86
|
+
export function toSidecarConnectionSettings (config?: AuthConfiguration): ResolvedSidecarConnectionSettings {
|
|
87
|
+
const scopes = config?.scopes ?? (config?.scope ? [config.scope] : undefined)
|
|
88
|
+
return {
|
|
89
|
+
serviceName: config?.serviceName?.trim() ? config.serviceName : DEFAULT_SERVICE_NAME,
|
|
90
|
+
blueprintServiceName: config?.blueprintServiceName?.trim() ? config.blueprintServiceName : DEFAULT_BLUEPRINT_SERVICE_NAME,
|
|
91
|
+
sidecarBaseUrl: config?.sidecarBaseUrl,
|
|
92
|
+
bypassLocalNetworkRestriction: config?.bypassLocalNetworkRestriction ?? false,
|
|
93
|
+
requestTimeout: config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
94
|
+
retryCount: config?.retryCount ?? DEFAULT_RETRY_COUNT,
|
|
95
|
+
scopes
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Options used to build the query string for a sidecar token request. Mirrors the C#
|
|
101
|
+
* `SidecarRequestOptions` shape.
|
|
102
|
+
*/
|
|
103
|
+
export interface SidecarRequestOptions {
|
|
104
|
+
/** Agent app (client) ID for agent identity flows. Maps to `AgentIdentity`. */
|
|
105
|
+
agentIdentity?: string
|
|
106
|
+
|
|
107
|
+
/** Agentic user principal name for delegated agent flows. Maps to `AgentUsername`. */
|
|
108
|
+
agentUsername?: string
|
|
109
|
+
|
|
110
|
+
/** Agentic user object ID for delegated agent flows. Maps to `AgentUserId`. */
|
|
111
|
+
agentUserId?: string
|
|
112
|
+
|
|
113
|
+
/** Override the configured downstream API scopes. Maps to `optionsOverride.Scopes`. */
|
|
114
|
+
scopes?: string[]
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Request an app-only token instead of a user (delegated) token. Maps to
|
|
118
|
+
* `optionsOverride.RequestAppToken=true`.
|
|
119
|
+
*/
|
|
120
|
+
requestAppToken?: boolean
|
|
121
|
+
|
|
122
|
+
/** Override the tenant ID. Maps to `optionsOverride.AcquireTokenOptions.Tenant`. */
|
|
123
|
+
tenant?: string
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Force a fresh token acquisition, bypassing the sidecar cache. Maps to
|
|
127
|
+
* `optionsOverride.AcquireTokenOptions.ForceRefresh=true`.
|
|
128
|
+
*/
|
|
129
|
+
forceRefresh?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Result of a successful sidecar token acquisition. Mirrors the C# `SidecarTokenResult` shape.
|
|
134
|
+
*/
|
|
135
|
+
export interface SidecarTokenResult {
|
|
136
|
+
/** The authorization scheme (e.g., `'Bearer'` or `'PoP'`). */
|
|
137
|
+
scheme: string
|
|
138
|
+
|
|
139
|
+
/** The raw access token. */
|
|
140
|
+
token: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* RFC 7807 problem details returned by the sidecar on error. Mirrors the C# `SidecarProblemDetails`
|
|
145
|
+
* shape.
|
|
146
|
+
*/
|
|
147
|
+
export interface SidecarProblemDetails {
|
|
148
|
+
type?: string
|
|
149
|
+
title?: string
|
|
150
|
+
status?: number
|
|
151
|
+
detail?: string
|
|
152
|
+
instance?: string
|
|
153
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import jwt from 'jsonwebtoken'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Conservative lifetime (in milliseconds) applied when the token is opaque or carries no readable
|
|
10
|
+
* JWT `exp` claim.
|
|
11
|
+
*/
|
|
12
|
+
const FALLBACK_LIFETIME_MS = 5 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the absolute expiry (epoch milliseconds) of a sidecar-issued token. Prefers the token's
|
|
16
|
+
* own JWT `exp` claim; falls back to {@link FALLBACK_LIFETIME_MS} from now when the token is opaque
|
|
17
|
+
* or carries no readable expiry.
|
|
18
|
+
* @param token The raw access token returned by the sidecar.
|
|
19
|
+
* @param now The current time in epoch milliseconds (defaults to `Date.now()`); primarily a test seam.
|
|
20
|
+
* @returns The absolute UTC expiry of the token, in epoch milliseconds.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveTokenExpiry (token: string, now: number = Date.now()): number {
|
|
23
|
+
if (token) {
|
|
24
|
+
try {
|
|
25
|
+
const decoded = jwt.decode(token) as jwt.JwtPayload | null
|
|
26
|
+
if (decoded && typeof decoded.exp === 'number' && decoded.exp > 0) {
|
|
27
|
+
return decoded.exp * 1000
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Opaque / non-JWT token — fall back below.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return now + FALLBACK_LIFETIME_MS
|
|
35
|
+
}
|
package/src/cloudAdapter.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ConnectorClient } from './connector-client/connectorClient'
|
|
|
12
12
|
import { AuthConfiguration, getAuthConfigWithDefaults } from './auth/authConfiguration'
|
|
13
13
|
import { AuthProvider } from './auth/authProvider'
|
|
14
14
|
import { ApxProductionScope } from './auth/authConstants'
|
|
15
|
-
import { MsalConnectionManager } from './auth/msalConnectionManager'
|
|
15
|
+
import { MsalConnectionManager } from './auth/msal/msalConnectionManager'
|
|
16
16
|
import { Activity, ActivityEventNames, ActivityTypes, Channels, ConversationReference, DeliveryModes, ConversationParameters, RoleTypes, ExceptionHelper } from '@microsoft/agents-activity'
|
|
17
17
|
import { Errors } from './errorHelper'
|
|
18
18
|
import { ResourceResponse } from './connector-client/resourceResponse'
|