@microsoft/agents-hosting 1.6.0-beta.9.gce9d8facd2 → 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.
Files changed (149) hide show
  1. package/dist/package.json +4 -9
  2. package/dist/src/agent-client/agentClient.js +2 -2
  3. package/dist/src/agent-client/agentClient.js.map +1 -1
  4. package/dist/src/agent-client/agentResponseHandler.js +2 -2
  5. package/dist/src/agent-client/agentResponseHandler.js.map +1 -1
  6. package/dist/src/app/agentApplication.js +28 -2
  7. package/dist/src/app/agentApplication.js.map +1 -1
  8. package/dist/src/app/agentApplicationOptions.d.ts +10 -0
  9. package/dist/src/app/attachmentDownloader.js +5 -10
  10. package/dist/src/app/attachmentDownloader.js.map +1 -1
  11. package/dist/src/app/auth/authorizationManager.d.ts +4 -0
  12. package/dist/src/app/auth/authorizationManager.js +94 -67
  13. package/dist/src/app/auth/authorizationManager.js.map +1 -1
  14. package/dist/src/app/auth/handlers/azureBotAuthorization.js +2 -2
  15. package/dist/src/app/auth/handlers/azureBotAuthorization.js.map +1 -1
  16. package/dist/src/app/proactive/createConversationOptionsBuilder.js +10 -6
  17. package/dist/src/app/proactive/createConversationOptionsBuilder.js.map +1 -1
  18. package/dist/src/app/proactive/proactive.d.ts +1 -0
  19. package/dist/src/app/proactive/proactive.js +30 -15
  20. package/dist/src/app/proactive/proactive.js.map +1 -1
  21. package/dist/src/app/streaming/streamingResponse.d.ts +11 -0
  22. package/dist/src/app/streaming/streamingResponse.js +21 -0
  23. package/dist/src/app/streaming/streamingResponse.js.map +1 -1
  24. package/dist/src/app/teamsAttachmentDownloader.js +6 -14
  25. package/dist/src/app/teamsAttachmentDownloader.js.map +1 -1
  26. package/dist/src/auth/MemoryCache.d.ts +2 -1
  27. package/dist/src/auth/MemoryCache.js +7 -1
  28. package/dist/src/auth/MemoryCache.js.map +1 -1
  29. package/dist/src/auth/authConfiguration.d.ts +2 -143
  30. package/dist/src/auth/authConfiguration.js +336 -257
  31. package/dist/src/auth/authConfiguration.js.map +1 -1
  32. package/dist/src/auth/authConstants.d.ts +7 -7
  33. package/dist/src/auth/authConstants.js.map +1 -1
  34. package/dist/src/auth/connectionManager.d.ts +92 -0
  35. package/dist/src/auth/{msalConnectionManager.js → connectionManager.js} +48 -33
  36. package/dist/src/auth/connectionManager.js.map +1 -0
  37. package/dist/src/auth/index.d.ts +5 -3
  38. package/dist/src/auth/index.js +5 -3
  39. package/dist/src/auth/index.js.map +1 -1
  40. package/dist/src/auth/jwt-middleware.d.ts +6 -0
  41. package/dist/src/auth/jwt-middleware.js +34 -2
  42. package/dist/src/auth/jwt-middleware.js.map +1 -1
  43. package/dist/src/auth/msal/msalConnectionManager.d.ts +17 -0
  44. package/dist/src/auth/msal/msalConnectionManager.js +50 -0
  45. package/dist/src/auth/msal/msalConnectionManager.js.map +1 -0
  46. package/dist/src/auth/msal/msalConnectionSettings.d.ts +68 -0
  47. package/dist/src/auth/msal/msalConnectionSettings.js +7 -0
  48. package/dist/src/auth/msal/msalConnectionSettings.js.map +1 -0
  49. package/dist/src/auth/{msalTokenCredential.d.ts → msal/msalTokenCredential.d.ts} +1 -1
  50. package/dist/src/auth/{msalTokenCredential.js → msal/msalTokenCredential.js} +2 -2
  51. package/dist/src/auth/msal/msalTokenCredential.js.map +1 -0
  52. package/dist/src/auth/{msalTokenProvider.d.ts → msal/msalTokenProvider.d.ts} +30 -3
  53. package/dist/src/auth/msal/msalTokenProvider.js +733 -0
  54. package/dist/src/auth/msal/msalTokenProvider.js.map +1 -0
  55. package/dist/src/auth/settings.d.ts +336 -0
  56. package/dist/src/auth/settings.js +202 -0
  57. package/dist/src/auth/settings.js.map +1 -0
  58. package/dist/src/auth/sidecar/sidecarAuthProvider.d.ts +74 -0
  59. package/dist/src/auth/sidecar/sidecarAuthProvider.js +181 -0
  60. package/dist/src/auth/sidecar/sidecarAuthProvider.js.map +1 -0
  61. package/dist/src/auth/sidecar/sidecarConnectionSettings.d.ts +65 -0
  62. package/dist/src/auth/sidecar/sidecarConnectionSettings.js +7 -0
  63. package/dist/src/auth/sidecar/sidecarConnectionSettings.js.map +1 -0
  64. package/dist/src/auth/sidecar/sidecarHttpClient.d.ts +95 -0
  65. package/dist/src/auth/sidecar/sidecarHttpClient.js +406 -0
  66. package/dist/src/auth/sidecar/sidecarHttpClient.js.map +1 -0
  67. package/dist/src/auth/sidecar/sidecarModels.d.ts +110 -0
  68. package/dist/src/auth/sidecar/sidecarModels.js +48 -0
  69. package/dist/src/auth/sidecar/sidecarModels.js.map +1 -0
  70. package/dist/src/auth/sidecar/sidecarTokenExpiry.d.ts +13 -0
  71. package/dist/src/auth/sidecar/sidecarTokenExpiry.js +39 -0
  72. package/dist/src/auth/sidecar/sidecarTokenExpiry.js.map +1 -0
  73. package/dist/src/cloudAdapter.d.ts +58 -1
  74. package/dist/src/cloudAdapter.js +233 -53
  75. package/dist/src/cloudAdapter.js.map +1 -1
  76. package/dist/src/connector-client/connectorClient.d.ts +12 -8
  77. package/dist/src/connector-client/connectorClient.js +99 -82
  78. package/dist/src/connector-client/connectorClient.js.map +1 -1
  79. package/dist/src/errorHelper.js +181 -7
  80. package/dist/src/errorHelper.js.map +1 -1
  81. package/dist/src/getProductInfo.d.ts +23 -0
  82. package/dist/src/getProductInfo.js +71 -1
  83. package/dist/src/getProductInfo.js.map +1 -1
  84. package/dist/src/headerPropagation.d.ts +17 -2
  85. package/dist/src/headerPropagation.js +46 -24
  86. package/dist/src/headerPropagation.js.map +1 -1
  87. package/dist/src/httpClient.d.ts +60 -0
  88. package/dist/src/httpClient.js +173 -0
  89. package/dist/src/httpClient.js.map +1 -0
  90. package/dist/src/index.d.ts +3 -2
  91. package/dist/src/index.js +8 -2
  92. package/dist/src/index.js.map +1 -1
  93. package/dist/src/oauth/userTokenClient.d.ts +17 -4
  94. package/dist/src/oauth/userTokenClient.js +119 -80
  95. package/dist/src/oauth/userTokenClient.js.map +1 -1
  96. package/dist/src/observability/traces.d.ts +11 -4
  97. package/dist/src/observability/traces.js +39 -7
  98. package/dist/src/observability/traces.js.map +1 -1
  99. package/dist/src/utils/env.d.ts +50 -0
  100. package/dist/src/utils/env.js +113 -0
  101. package/dist/src/utils/env.js.map +1 -0
  102. package/dist/src/utils.d.ts +10 -0
  103. package/dist/src/utils.js +18 -0
  104. package/dist/src/utils.js.map +1 -0
  105. package/package.json +4 -9
  106. package/src/agent-client/agentClient.ts +2 -2
  107. package/src/agent-client/agentResponseHandler.ts +2 -2
  108. package/src/app/agentApplication.ts +30 -3
  109. package/src/app/agentApplicationOptions.ts +11 -0
  110. package/src/app/attachmentDownloader.ts +6 -8
  111. package/src/app/auth/authorizationManager.ts +84 -41
  112. package/src/app/auth/handlers/azureBotAuthorization.ts +2 -2
  113. package/src/app/proactive/createConversationOptionsBuilder.ts +8 -4
  114. package/src/app/proactive/proactive.ts +39 -23
  115. package/src/app/streaming/streamingResponse.ts +24 -1
  116. package/src/app/teamsAttachmentDownloader.ts +7 -11
  117. package/src/auth/MemoryCache.ts +7 -1
  118. package/src/auth/authConfiguration.ts +350 -356
  119. package/src/auth/authConstants.ts +7 -7
  120. package/src/auth/{msalConnectionManager.ts → connectionManager.ts} +65 -44
  121. package/src/auth/index.ts +5 -3
  122. package/src/auth/jwt-middleware.ts +32 -2
  123. package/src/auth/msal/msalConnectionManager.ts +55 -0
  124. package/src/auth/msal/msalConnectionSettings.ts +79 -0
  125. package/src/auth/{msalTokenCredential.ts → msal/msalTokenCredential.ts} +2 -1
  126. package/src/auth/msal/msalTokenProvider.ts +881 -0
  127. package/src/auth/settings.ts +378 -0
  128. package/src/auth/sidecar/sidecarAuthProvider.ts +234 -0
  129. package/src/auth/sidecar/sidecarConnectionSettings.ts +72 -0
  130. package/src/auth/sidecar/sidecarHttpClient.ts +443 -0
  131. package/src/auth/sidecar/sidecarModels.ts +153 -0
  132. package/src/auth/sidecar/sidecarTokenExpiry.ts +35 -0
  133. package/src/cloudAdapter.ts +292 -13
  134. package/src/connector-client/connectorClient.ts +116 -94
  135. package/src/errorHelper.ts +206 -7
  136. package/src/getProductInfo.ts +79 -1
  137. package/src/headerPropagation.ts +55 -23
  138. package/src/httpClient.ts +223 -0
  139. package/src/index.ts +6 -2
  140. package/src/oauth/userTokenClient.ts +137 -84
  141. package/src/observability/traces.ts +39 -8
  142. package/src/utils/env.ts +105 -0
  143. package/src/utils.ts +14 -0
  144. package/dist/src/auth/msalConnectionManager.d.ts +0 -64
  145. package/dist/src/auth/msalConnectionManager.js.map +0 -1
  146. package/dist/src/auth/msalTokenCredential.js.map +0 -1
  147. package/dist/src/auth/msalTokenProvider.js +0 -485
  148. package/dist/src/auth/msalTokenProvider.js.map +0 -1
  149. package/src/auth/msalTokenProvider.ts +0 -562
@@ -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
+ }