@microsoft/agents-hosting 1.7.2 → 1.8.0-beta.36.gb033f0abc1

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 (102) hide show
  1. package/README.md +64 -2
  2. package/dist/package.json +3 -3
  3. package/dist/src/agent-client/agentClient.d.ts +18 -6
  4. package/dist/src/agent-client/agentClient.js +34 -18
  5. package/dist/src/agent-client/agentClient.js.map +1 -1
  6. package/dist/src/agent-client/agentResponseHandler.d.ts +14 -9
  7. package/dist/src/agent-client/agentResponseHandler.js +10 -5
  8. package/dist/src/agent-client/agentResponseHandler.js.map +1 -1
  9. package/dist/src/agent-client/createAgentResponseHandler.d.ts +7 -2
  10. package/dist/src/agent-client/createAgentResponseHandler.js +55 -8
  11. package/dist/src/agent-client/createAgentResponseHandler.js.map +1 -1
  12. package/dist/src/app/adaptiveCards/activityValueParsers.d.ts +1 -4
  13. package/dist/src/app/adaptiveCards/activityValueParsers.js +2 -5
  14. package/dist/src/app/adaptiveCards/activityValueParsers.js.map +1 -1
  15. package/dist/src/app/adaptiveCards/adaptiveCardsActions.d.ts +18 -0
  16. package/dist/src/app/adaptiveCards/adaptiveCardsActions.js +53 -23
  17. package/dist/src/app/adaptiveCards/adaptiveCardsActions.js.map +1 -1
  18. package/dist/src/app/agentApplication.d.ts +3 -0
  19. package/dist/src/app/agentApplication.js +140 -81
  20. package/dist/src/app/agentApplication.js.map +1 -1
  21. package/dist/src/app/attachmentDownloader.d.ts +4 -1
  22. package/dist/src/app/attachmentDownloader.js +27 -5
  23. package/dist/src/app/attachmentDownloader.js.map +1 -1
  24. package/dist/src/app/auth/handlers/agenticAuthorization.d.ts +1 -1
  25. package/dist/src/app/auth/handlers/azureBotAuthorization.d.ts +1 -1
  26. package/dist/src/app/auth/types.d.ts +3 -4
  27. package/dist/src/app/index.d.ts +2 -0
  28. package/dist/src/app/index.js.map +1 -1
  29. package/dist/src/app/streaming/streamingResponse.d.ts +56 -0
  30. package/dist/src/app/streaming/streamingResponse.js +250 -26
  31. package/dist/src/app/streaming/streamingResponse.js.map +1 -1
  32. package/dist/src/app/teamsAttachmentDownloader.d.ts +3 -1
  33. package/dist/src/app/teamsAttachmentDownloader.js +12 -1
  34. package/dist/src/app/teamsAttachmentDownloader.js.map +1 -1
  35. package/dist/src/auth/authConfiguration.js +12 -0
  36. package/dist/src/auth/authConfiguration.js.map +1 -1
  37. package/dist/src/auth/index.d.ts +2 -1
  38. package/dist/src/auth/index.js +2 -1
  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 +263 -13
  42. package/dist/src/auth/jwt-middleware.js.map +1 -1
  43. package/dist/src/auth/msal/msalConnectionSettings.d.ts +8 -0
  44. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.d.ts +34 -0
  45. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.js +126 -0
  46. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.js.map +1 -0
  47. package/dist/src/auth/msal/msalTokenProvider.d.ts +1 -0
  48. package/dist/src/auth/msal/msalTokenProvider.js +23 -14
  49. package/dist/src/auth/msal/msalTokenProvider.js.map +1 -1
  50. package/dist/src/auth/settings.d.ts +18 -0
  51. package/dist/src/auth/settings.js +8 -2
  52. package/dist/src/auth/settings.js.map +1 -1
  53. package/dist/src/cloudAdapter.d.ts +29 -2
  54. package/dist/src/cloudAdapter.js +44 -2
  55. package/dist/src/cloudAdapter.js.map +1 -1
  56. package/dist/src/errorHelper.js +137 -101
  57. package/dist/src/errorHelper.js.map +1 -1
  58. package/dist/src/index.d.ts +1 -0
  59. package/dist/src/index.js +1 -0
  60. package/dist/src/index.js.map +1 -1
  61. package/dist/src/invoke/adaptiveCardInvokeValue.d.ts +5 -0
  62. package/dist/src/oauth/userTokenClient.d.ts +0 -1
  63. package/dist/src/oauth/userTokenClient.js +0 -1
  64. package/dist/src/oauth/userTokenClient.js.map +1 -1
  65. package/dist/src/observability/traces.d.ts +6 -0
  66. package/dist/src/observability/traces.js +20 -2
  67. package/dist/src/observability/traces.js.map +1 -1
  68. package/dist/src/outboundHostValidator.d.ts +47 -0
  69. package/dist/src/outboundHostValidator.js +149 -0
  70. package/dist/src/outboundHostValidator.js.map +1 -0
  71. package/dist/src/state/agentState.d.ts +5 -0
  72. package/dist/src/state/agentState.js +16 -3
  73. package/dist/src/state/agentState.js.map +1 -1
  74. package/package.json +3 -3
  75. package/src/agent-client/agentClient.ts +46 -23
  76. package/src/agent-client/agentResponseHandler.ts +14 -9
  77. package/src/agent-client/createAgentResponseHandler.ts +70 -9
  78. package/src/app/adaptiveCards/activityValueParsers.ts +3 -9
  79. package/src/app/adaptiveCards/adaptiveCardsActions.ts +52 -24
  80. package/src/app/agentApplication.ts +149 -77
  81. package/src/app/attachmentDownloader.ts +25 -5
  82. package/src/app/auth/handlers/agenticAuthorization.ts +1 -1
  83. package/src/app/auth/handlers/azureBotAuthorization.ts +1 -1
  84. package/src/app/auth/types.ts +3 -3
  85. package/src/app/index.ts +8 -0
  86. package/src/app/streaming/streamingResponse.ts +276 -22
  87. package/src/app/teamsAttachmentDownloader.ts +11 -1
  88. package/src/auth/authConfiguration.ts +12 -0
  89. package/src/auth/index.ts +2 -1
  90. package/src/auth/jwt-middleware.ts +298 -14
  91. package/src/auth/msal/msalConnectionSettings.ts +10 -0
  92. package/src/auth/msal/msalHttpRetryHandlerHelper.ts +161 -0
  93. package/src/auth/msal/msalTokenProvider.ts +32 -14
  94. package/src/auth/settings.ts +18 -3
  95. package/src/cloudAdapter.ts +56 -3
  96. package/src/errorHelper.ts +155 -114
  97. package/src/index.ts +1 -0
  98. package/src/invoke/adaptiveCardInvokeValue.ts +5 -0
  99. package/src/oauth/userTokenClient.ts +0 -1
  100. package/src/observability/traces.ts +19 -1
  101. package/src/outboundHostValidator.ts +172 -0
  102. package/src/state/agentState.ts +24 -6
@@ -3,7 +3,8 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
- import { AuthConfiguration, resolveAuthority } from './authConfiguration'
6
+ import { AuthConfiguration } from './authConfiguration'
7
+ import { getDefaultIssuers, resolveAuthority } from './settings'
7
8
  import { Request } from './request'
8
9
  import { WebResponse, NextFunction } from '../interfaces/webResponse'
9
10
  import { Errors } from '../errorHelper'
@@ -15,6 +16,25 @@ import { debug } from '@microsoft/agents-telemetry'
15
16
  const logger = debug('agents:jwt-middleware')
16
17
  const jwksClients = new Map<string, JwksClient>()
17
18
  const maxJwksClients = 100
19
+ const authorizedAudience = Symbol('authorizedAudience')
20
+
21
+ interface AuthorizedRequest extends Request {
22
+ [authorizedAudience]?: string
23
+ }
24
+
25
+ interface VerifiedToken {
26
+ payload: JwtPayload
27
+ audience: string
28
+ }
29
+
30
+ /**
31
+ * Gets the configured audience selected while authorizing a request.
32
+ * @param req The authorized request.
33
+ * @returns The matched configured client ID, or `undefined` for anonymous requests.
34
+ */
35
+ export function getAuthorizedAudience (req: Request): string | undefined {
36
+ return (req as AuthorizedRequest)[authorizedAudience]
37
+ }
18
38
 
19
39
  /**
20
40
  * Clears process-wide JWKS clients.
@@ -23,6 +43,248 @@ export function clearJwksClients (): void {
23
43
  jwksClients.clear()
24
44
  }
25
45
 
46
+ /**
47
+ * Well-known Microsoft first-party token issuer tenant IDs that are always trusted,
48
+ * mirroring the default `ValidIssuers` set in the .NET SDK
49
+ * (Microsoft.Agents.Hosting.AspNetCore `AspNetExtensions.AddAgentAspNetAuthentication`).
50
+ *
51
+ * These identify Microsoft infrastructure tenants used by Azure Bot Service, Teams and
52
+ * skill and SDK-specific Activity callback flows. They are accepted in addition to a connection's configured
53
+ * issuers so that enabling issuer validation does not reject legitimate first-party traffic.
54
+ */
55
+ const WELL_KNOWN_PUBLIC_TENANT_IDS = [
56
+ 'd6d49420-f39b-4df7-a1dc-d59a935871db',
57
+ 'f8cdef31-a31e-4b4a-93e4-5f571e91255a',
58
+ '69e9b82d-4842-4902-8d1e-abc5b98a55e8'
59
+ ]
60
+ const WELL_KNOWN_GOV_TENANT_ID = 'cab8a31a-1906-4287-a0d8-4eef66b95f6e'
61
+
62
+ /**
63
+ * Determines whether the configured authority targets the Azure US Government cloud.
64
+ * @param authority The configured Entra authority.
65
+ * @returns `true` when the authority is a US Government endpoint.
66
+ */
67
+ function isGovAuthority (authority?: string): boolean {
68
+ return !!authority && /login\.microsoftonline\.us/i.test(authority)
69
+ }
70
+
71
+ function getAuthority (authConfig: AuthConfiguration): string | undefined {
72
+ return authConfig.authorityEndpoint ?? authConfig.authority
73
+ }
74
+
75
+ /**
76
+ * Returns the well-known Microsoft first-party issuers that are always trusted for the
77
+ * cloud implied by `authority` (public by default, US Government when the authority is gov).
78
+ * @param authority The configured Entra authority.
79
+ * @returns The well-known first-party issuer list.
80
+ */
81
+ function getWellKnownFirstPartyIssuers (authority?: string): string[] {
82
+ if (isGovAuthority(authority)) {
83
+ return [
84
+ 'https://api.botframework.us',
85
+ `https://sts.windows.net/${WELL_KNOWN_GOV_TENANT_ID}/`,
86
+ `https://login.microsoftonline.us/${WELL_KNOWN_GOV_TENANT_ID}/v2.0`
87
+ ]
88
+ }
89
+ return [
90
+ 'https://api.botframework.com',
91
+ ...WELL_KNOWN_PUBLIC_TENANT_IDS.flatMap((t) => [
92
+ `https://sts.windows.net/${t}/`,
93
+ `https://login.microsoftonline.com/${t}/v2.0`
94
+ ])
95
+ ]
96
+ }
97
+
98
+ /**
99
+ * Computes the lowercase issuer allow-list used to validate a token's `iss` claim against a
100
+ * matched connection. The list is the connection's configured issuers (or a tenant-scoped
101
+ * default when none are configured) unioned with the always-trusted Microsoft first-party
102
+ * issuers. Comparison is performed case-insensitively to tolerate operator-provided tenant
103
+ * GUID casing while still rejecting tokens from unrelated tenants.
104
+ * @param authConfig The matched connection configuration.
105
+ * @returns A de-duplicated, lowercased list of accepted issuers.
106
+ */
107
+ function getValidIssuers (authConfig: AuthConfiguration): string[] {
108
+ const authority = getAuthority(authConfig)
109
+ const configured = authConfig.issuers && authConfig.issuers.length > 0
110
+ ? authConfig.issuers
111
+ : getDefaultIssuers(authConfig.tenantId ?? '', authority ?? 'https://login.microsoftonline.com')
112
+ const accepted = new Set<string>()
113
+ for (const issuer of [...configured, ...getWellKnownFirstPartyIssuers(authority)]) {
114
+ accepted.add(issuer.toLowerCase())
115
+ }
116
+ return [...accepted]
117
+ }
118
+
119
+ /**
120
+ * Returns the effective tenant identifier for a connection: the tenant embedded in the authority
121
+ * path when present (which takes precedence, matching {@link resolveAuthority}), otherwise the
122
+ * configured `tenantId`.
123
+ * @param authConfig The matched connection configuration.
124
+ * @returns The effective tenant identifier, or `undefined` when none is configured.
125
+ */
126
+ function getEffectiveTenant (authConfig: AuthConfiguration): string | undefined {
127
+ const authority = getAuthority(authConfig)
128
+ if (authority) {
129
+ try {
130
+ const segment = new URL(authority.replace(/\/+$/, '')).pathname.split('/').filter(Boolean).pop()
131
+ if (segment) {
132
+ return segment
133
+ }
134
+ } catch {
135
+ // Non-URL authority — fall through to the configured tenantId.
136
+ }
137
+ }
138
+ return authConfig.tenantId
139
+ }
140
+
141
+ /**
142
+ * Determines whether a connection is configured as a multi-tenant ("blueprint") agent, i.e. its
143
+ * effective tenant is the Entra `common` or `organizations` meta-tenant. For such agents the
144
+ * calling tenant is not known at configuration time, so the strict issuer allow-list cannot
145
+ * enumerate it and issuer validation must instead accept any canonical Entra issuer for the
146
+ * configured cloud (still bound to the token's `tid` by {@link validateTenantBinding} and anchored
147
+ * by the signature and audience checks).
148
+ * @param authConfig The matched connection configuration.
149
+ * @returns `true` when the connection targets the `common`/`organizations` meta-tenant.
150
+ */
151
+ function isMultiTenant (authConfig: AuthConfiguration): boolean {
152
+ const tenant = getEffectiveTenant(authConfig)?.toLowerCase()
153
+ return tenant === 'common' || tenant === 'organizations'
154
+ }
155
+
156
+ /**
157
+ * Determines whether `iss` is a canonical Entra issuer that may be accepted for a multi-tenant
158
+ * connection. The issuer must carry a concrete tenant GUID and, for cloud-specific v2 issuers,
159
+ * belong to the same cloud as the configured authority (public vs US Government). The cloud-
160
+ * agnostic v1 `sts.windows.net` host is accepted in either cloud.
161
+ * @param iss The token issuer claim.
162
+ * @param authConfig The matched connection configuration.
163
+ * @returns `true` when the issuer is an acceptable Entra tenant issuer for the configured cloud.
164
+ */
165
+ function isAcceptableTenantIssuer (iss: string, authConfig: AuthConfiguration): boolean {
166
+ const info = getEntraIssuerInfo(iss)
167
+ if (!info) {
168
+ return false
169
+ }
170
+ return info.gov === undefined || info.gov === isGovAuthority(getAuthority(authConfig))
171
+ }
172
+
173
+ /**
174
+ * Validates that the token's `iss` claim is present and acceptable for the matched connection.
175
+ * Without this check a signature-valid token from any tenant (Entra signing keys are shared across
176
+ * tenants in a cloud) with a matching audience would be accepted, enabling a cross-tenant
177
+ * authentication bypass for multi-tenant app registrations.
178
+ *
179
+ * For single-tenant connections the issuer must be in the connection's allow-list. For multi-tenant
180
+ * ("blueprint") connections, where the calling tenant is unknown at configuration time, any
181
+ * canonical Entra issuer for the configured cloud is also accepted; the token's tenant is then
182
+ * bound to its `tid` claim by {@link validateTenantBinding} and ultimately anchored by the
183
+ * signature and audience (clientId) checks.
184
+ * @param iss The token issuer claim (from the decoded payload).
185
+ * @param authConfig The matched connection configuration.
186
+ * @throws When the issuer is missing, non-string, or not acceptable for the connection.
187
+ */
188
+ function validateIssuer (iss: unknown, authConfig: AuthConfiguration): void {
189
+ if (typeof iss === 'string') {
190
+ if (getValidIssuers(authConfig).includes(iss.toLowerCase())) {
191
+ return
192
+ }
193
+ if (isMultiTenant(authConfig) && isAcceptableTenantIssuer(iss, authConfig)) {
194
+ return
195
+ }
196
+ }
197
+ const err = ExceptionHelper.generateException(Error, Errors.JwtIssuerMismatch)
198
+ logger.error(err.message, iss)
199
+ throw err
200
+ }
201
+
202
+ /**
203
+ * Matches a tenant GUID (the format Entra uses for the `tid` claim and the tenant segment of an
204
+ * Entra issuer). Operator-configured issuers may instead use a tenant domain alias, which cannot
205
+ * be compared to the GUID `tid` claim and is therefore left unbound.
206
+ */
207
+ const ENTRA_TENANT_GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
208
+
209
+ /**
210
+ * Cloud-affinity metadata for a recognised Entra issuer.
211
+ */
212
+ interface EntraIssuerInfo {
213
+ /** The lowercased tenant GUID embedded in the issuer. */
214
+ tenant: string
215
+ /**
216
+ * `true` for US Government (`login.microsoftonline.us`) issuers, `false` for public
217
+ * (`login.microsoftonline.com`) issuers, and `undefined` for the cloud-agnostic v1
218
+ * `sts.windows.net` host (which is shared across the public and US Government clouds).
219
+ */
220
+ gov?: boolean
221
+ }
222
+
223
+ /**
224
+ * Parses a recognised public or US Government Entra issuer into its tenant GUID and cloud affinity,
225
+ * or returns `undefined` when the issuer is not such an Entra issuer or does not embed a tenant
226
+ * GUID.
227
+ *
228
+ * Only GUID tenants are recognised: the token's `tid` claim is always the tenant GUID, so an issuer
229
+ * whose tenant segment is a domain alias (e.g. `contoso.onmicrosoft.com`) cannot be compared to
230
+ * `tid` and is intentionally left unrecognised to preserve compatibility. Non-Entra issuers such as
231
+ * the Azure Bot Service `api.botframework.*` issuers carry no `tid` claim and are not matched here.
232
+ * @param iss The token issuer claim.
233
+ * @returns The issuer's tenant and cloud affinity, or `undefined` when not a recognised Entra issuer.
234
+ */
235
+ function getEntraIssuerInfo (iss: string): EntraIssuerInfo | undefined {
236
+ const v1 = /^https:\/\/sts\.windows\.net\/([^/]+)\/$/i.exec(iss)
237
+ if (v1) {
238
+ return ENTRA_TENANT_GUID.test(v1[1]) ? { tenant: v1[1].toLowerCase() } : undefined
239
+ }
240
+ const v2 = /^([^:]+):\/\/([^/]+)\/([^/]+)\/v2\.0$/.exec(iss)
241
+ const v2Host = v2 ? /^login\.microsoftonline\.(com|us)$/i.exec(v2[2]) : undefined
242
+ if (v2 && v2Host && v2[1].toLowerCase() === 'https' && ENTRA_TENANT_GUID.test(v2[3])) {
243
+ return { tenant: v2[3].toLowerCase(), gov: v2Host[1].toLowerCase() === 'us' }
244
+ }
245
+ return undefined
246
+ }
247
+
248
+ /**
249
+ * Extracts the tenant GUID embedded in a recognised public or US Government Entra issuer, or
250
+ * `undefined` when the issuer is not such an Entra issuer or does not embed a tenant GUID.
251
+ * @param iss The token issuer claim.
252
+ * @returns The lowercased tenant GUID, or `undefined` when no bindable tenant is present.
253
+ */
254
+ function getIssuerTenant (iss: string): string | undefined {
255
+ return getEntraIssuerInfo(iss)?.tenant
256
+ }
257
+
258
+ /**
259
+ * Validates that an Entra token's `tid` (tenant id) claim matches the tenant GUID embedded in its
260
+ * `iss` claim. This binds the accepted issuer to the token's tenant. Because
261
+ * Entra signing keys are shared across tenants within a cloud, requiring the issuer's tenant and
262
+ * the `tid` claim to agree prevents a token whose `iss` was allow-listed (for example one of the
263
+ * always-trusted Microsoft first-party tenants) from being accepted on behalf of a different
264
+ * tenant.
265
+ *
266
+ * The binding only applies when both claims are strings and the issuer is a recognised Entra
267
+ * issuer carrying a GUID tenant. Missing/non-string claims, Azure Bot Service issuers, and
268
+ * alias-based issuers are skipped for backward compatibility.
269
+ * @param iss The token issuer claim.
270
+ * @param tid The token tenant id claim.
271
+ * @throws When both claims are strings and the issuer embeds a tenant GUID different from `tid`.
272
+ */
273
+ function validateTenantBinding (iss: unknown, tid: unknown): void {
274
+ if (typeof iss !== 'string' || typeof tid !== 'string') {
275
+ return
276
+ }
277
+ const issuerTenant = getIssuerTenant(iss)
278
+ if (!issuerTenant) {
279
+ return
280
+ }
281
+ if (tid.toLowerCase() !== issuerTenant) {
282
+ const err = ExceptionHelper.generateException(Error, Errors.JwtTenantMismatch)
283
+ logger.error(err.message, tid)
284
+ throw err
285
+ }
286
+ }
287
+
26
288
  /**
27
289
  * Builds the JWKS URI for the given token issuer and auth configuration.
28
290
  * @param iss The token issuer claim.
@@ -30,9 +292,14 @@ export function clearJwksClients (): void {
30
292
  * @returns The JWKS URI string.
31
293
  */
32
294
  export function buildJwksUri (iss: string, authConfig: AuthConfiguration): string {
33
- return iss === 'https://api.botframework.com'
34
- ? 'https://login.botframework.com/v1/.well-known/keys'
35
- : `${resolveAuthority(authConfig.authorityEndpoint ?? authConfig.authority, authConfig.tenantId)}/discovery/v2.0/keys`
295
+ switch (typeof iss === 'string' ? iss.toLowerCase() : '') {
296
+ case 'https://api.botframework.com':
297
+ return 'https://login.botframework.com/v1/.well-known/keys'
298
+ case 'https://api.botframework.us':
299
+ return 'https://login.botframework.azure.us/v1/.well-known/keys'
300
+ default:
301
+ return `${resolveAuthority(authConfig.authorityEndpoint ?? authConfig.authority, authConfig.tenantId)}/discovery/v2.0/keys`
302
+ }
36
303
  }
37
304
 
38
305
  export function getJwksClient (jwksUri: string): JwksClient {
@@ -59,31 +326,38 @@ export function getJwksClient (jwksUri: string): JwksClient {
59
326
  * Verifies the JWT token.
60
327
  * @param raw The raw JWT token.
61
328
  * @param config The authentication configuration.
62
- * @returns A promise that resolves to the JWT payload.
329
+ * @returns A promise that resolves to the verified payload and matched configured audience.
63
330
  */
64
- const verifyToken = async (raw: string, config: AuthConfiguration): Promise<JwtPayload> => {
331
+ const verifyToken = async (raw: string, config: AuthConfiguration): Promise<VerifiedToken> => {
65
332
  const payload = jwt.decode(raw) as JwtPayload
66
333
  logger.debug('jwt.decode ', JSON.stringify(payload))
67
334
 
68
335
  if (!payload) {
69
336
  throw ExceptionHelper.generateException(Error, Errors.InvalidJwtToken)
70
337
  }
71
- const audience = payload.aud
338
+ const audiences = Array.isArray(payload.aud)
339
+ ? payload.aud
340
+ : typeof payload.aud === 'string'
341
+ ? [payload.aud]
342
+ : []
72
343
 
73
344
  const matchingEntry = config.connections && config.connections.size > 0
74
- ? [...config.connections.entries()].find(([_, configuration]) => configuration.clientId === audience)
345
+ ? [...config.connections.entries()].find(([_, configuration]) =>
346
+ typeof configuration.clientId === 'string' && audiences.includes(configuration.clientId)
347
+ )
75
348
  : undefined
76
349
 
77
350
  if (!matchingEntry) {
78
351
  const err = ExceptionHelper.generateException(Error, Errors.JwtAudienceMismatch)
79
- logger.error(err.message, audience)
352
+ logger.error(err.message, audiences)
80
353
  throw err
81
354
  }
82
355
 
83
356
  const [key, authConfig] = matchingEntry
84
357
  logger.debug(`Audience found at key: ${key}`)
85
358
 
86
- const jwksUri = buildJwksUri(payload.iss as string, authConfig)
359
+ const issuer = typeof payload.iss === 'string' ? payload.iss : ''
360
+ const jwksUri = buildJwksUri(issuer, authConfig)
87
361
 
88
362
  logger.debug(`fetching keys from ${jwksUri}`)
89
363
  const jwksClient = getJwksClient(jwksUri)
@@ -109,7 +383,7 @@ const verifyToken = async (raw: string, config: AuthConfiguration): Promise<JwtP
109
383
  clockTolerance: 300
110
384
  }
111
385
 
112
- return await new Promise((resolve, reject) => {
386
+ const verifiedPayload = await new Promise<JwtPayload>((resolve, reject) => {
113
387
  jwt.verify(raw, getKey, verifyOptions, (err, user) => {
114
388
  if (err) {
115
389
  logger.error('jwt.verify ', JSON.stringify(err))
@@ -119,6 +393,14 @@ const verifyToken = async (raw: string, config: AuthConfiguration): Promise<JwtP
119
393
  resolve(user as JwtPayload)
120
394
  })
121
395
  })
396
+ if (authConfig.validateIssuer) {
397
+ validateIssuer(verifiedPayload.iss, authConfig)
398
+ }
399
+ validateTenantBinding(verifiedPayload.iss, verifiedPayload.tid)
400
+ return {
401
+ payload: verifiedPayload,
402
+ audience: authConfig.clientId!
403
+ }
122
404
  }
123
405
 
124
406
  /**
@@ -203,9 +485,11 @@ export const authorizeJWT = (authConfig: AuthConfiguration) => {
203
485
  const token = extractBearerToken(req.headers.authorization)
204
486
  if (token) {
205
487
  try {
206
- const user = await verifyToken(token, authConfig)
207
- logger.debug('token verified for ', user)
208
- req.user = user
488
+ const verifiedToken = await verifyToken(token, authConfig)
489
+ logger.debug('token verified for ', verifiedToken.payload)
490
+ req.user = verifiedToken.payload
491
+ const authorizedRequest = req as AuthorizedRequest
492
+ authorizedRequest[authorizedAudience] = verifiedToken.audience
209
493
  } catch (err: Error | any) {
210
494
  failed = true
211
495
  logger.error(err)
@@ -76,4 +76,14 @@ export interface MsalConnectionSettings extends ConnectionSettingsBase {
76
76
  * The federated client ID for the authentication configuration, used for workload identity federation scenarios.
77
77
  */
78
78
  federatedClientId?: string
79
+
80
+ /**
81
+ * Maximum number of retries for an MSAL HTTP request that returns status 408.
82
+ *
83
+ * @remarks
84
+ * The initial request is not included in this count. Set to `0` to disable retries.
85
+ * Defaults to `2`.
86
+ */
87
+ msalRetryCount?: number
88
+
79
89
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ import { INetworkModule, NetworkRequestOptions, NetworkResponse } from '@azure/msal-node'
7
+ import { ExceptionHelper } from '@microsoft/agents-activity'
8
+ import { debug } from '@microsoft/agents-telemetry'
9
+ import { Errors } from '../../errorHelper'
10
+
11
+ const logger = debug('agents:msal')
12
+ const requestTimeoutStatus = 408
13
+
14
+ /**
15
+ * The default number of retries made for an MSAL HTTP request.
16
+ */
17
+ export const DEFAULT_MSAL_RETRY_COUNT = 2
18
+
19
+ /**
20
+ * Converts a configured MSAL retry count to its effective runtime value.
21
+ */
22
+ export function normalizeMsalRetryCount (retryCount: number | undefined): number {
23
+ if (!Number.isFinite(retryCount)) {
24
+ return DEFAULT_MSAL_RETRY_COUNT
25
+ }
26
+
27
+ return Math.max(0, Math.floor(retryCount as number))
28
+ }
29
+
30
+ /**
31
+ * MSAL network client that retries responses with HTTP status 408 (Request Timeout).
32
+ *
33
+ * @remarks
34
+ * `maxRetryCount` is the number of retries after the initial request. Responses with
35
+ * any other status are returned immediately and retries have no additional delay.
36
+ */
37
+ export class MsalHttpRetryHandlerHelper implements INetworkModule {
38
+ private readonly maxRetryCount: number
39
+
40
+ public constructor (
41
+ private readonly networkClient: INetworkModule = new MsalHttpClient(),
42
+ maxRetryCount: number = DEFAULT_MSAL_RETRY_COUNT
43
+ ) {
44
+ this.maxRetryCount = normalizeMsalRetryCount(maxRetryCount)
45
+ }
46
+
47
+ /**
48
+ * Sends an HTTP GET request, retrying only Request Timeout responses.
49
+ */
50
+ public async sendGetRequestAsync<T> (
51
+ url: string,
52
+ options?: NetworkRequestOptions,
53
+ timeout?: number
54
+ ): Promise<NetworkResponse<T>> {
55
+ return this.sendWithRetries(() => this.networkClient.sendGetRequestAsync<T>(url, options, timeout))
56
+ }
57
+
58
+ /**
59
+ * Sends an HTTP POST request, retrying only Request Timeout responses.
60
+ */
61
+ public async sendPostRequestAsync<T> (
62
+ url: string,
63
+ options?: NetworkRequestOptions
64
+ ): Promise<NetworkResponse<T>> {
65
+ return this.sendWithRetries(() => this.networkClient.sendPostRequestAsync<T>(url, options))
66
+ }
67
+
68
+ private async sendWithRetries<T> (send: () => Promise<NetworkResponse<T>>): Promise<NetworkResponse<T>> {
69
+ let response = await send()
70
+ let attempt
71
+ for (attempt = 0; response.status === requestTimeoutStatus && attempt < this.maxRetryCount; attempt++) {
72
+ logger.debug('MSAL retry on request timeout (retry %d/%d)', attempt + 1, this.maxRetryCount)
73
+ response = await send()
74
+ }
75
+ logger.debug('MSAL request completed with status %d after %d attempt(s)', response.status, attempt + 1)
76
+ return response
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Default MSAL-compatible network implementation. MSAL Node does not export its
82
+ * built-in HTTP client, so the retry handler uses the same native fetch contract.
83
+ */
84
+ class MsalHttpClient implements INetworkModule {
85
+ public async sendGetRequestAsync<T> (
86
+ url: string,
87
+ options?: NetworkRequestOptions,
88
+ timeout?: number
89
+ ): Promise<NetworkResponse<T>> {
90
+ return this.sendRequest<T>(url, 'GET', options, timeout)
91
+ }
92
+
93
+ public async sendPostRequestAsync<T> (
94
+ url: string,
95
+ options?: NetworkRequestOptions
96
+ ): Promise<NetworkResponse<T>> {
97
+ return this.sendRequest<T>(url, 'POST', options)
98
+ }
99
+
100
+ private async sendRequest<T> (
101
+ url: string,
102
+ method: 'GET' | 'POST',
103
+ options?: NetworkRequestOptions,
104
+ timeout?: number
105
+ ): Promise<NetworkResponse<T>> {
106
+ const controller = new AbortController()
107
+ const timeoutId = timeout
108
+ ? setTimeout(() => controller.abort(), timeout)
109
+ : undefined
110
+
111
+ let response: Response
112
+ try {
113
+ response = await fetch(url, {
114
+ method,
115
+ headers: options?.headers,
116
+ body: method === 'POST' ? options?.body : undefined,
117
+ signal: controller.signal
118
+ })
119
+ } catch (error) {
120
+ const innerException = error instanceof Error ? error : undefined
121
+ if (innerException?.name === 'AbortError') {
122
+ throw ExceptionHelper.generateException(Error, Errors.TokenRequestTimeout, innerException, { timeoutMs: String(timeout) })
123
+ }
124
+ throw ExceptionHelper.generateException(Error, Errors.MsalHttpRequestFailed, innerException, {
125
+ message: innerException?.message ?? 'unknown'
126
+ })
127
+ } finally {
128
+ if (timeoutId) {
129
+ clearTimeout(timeoutId)
130
+ }
131
+ }
132
+
133
+ const headers: Record<string, string> = {}
134
+ response.headers.forEach((value, key) => {
135
+ headers[key] = value
136
+ })
137
+
138
+ return {
139
+ headers,
140
+ body: await this.parseBody<T>(response),
141
+ status: response.status
142
+ }
143
+ }
144
+
145
+ private async parseBody<T> (response: Response): Promise<T> {
146
+ const text = await response.text()
147
+
148
+ try {
149
+ return JSON.parse(text) as T
150
+ } catch (error) {
151
+ if (response.status === requestTimeoutStatus) {
152
+ return {} as T
153
+ }
154
+ throw ExceptionHelper.generateException(
155
+ Error,
156
+ Errors.MsalResponseUnparsable,
157
+ error instanceof Error ? error : undefined
158
+ )
159
+ }
160
+ }
161
+ }