@microsoft/agents-hosting 1.7.2 → 1.8.0-beta.34.g5d543f96c0

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 (100) 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 +126 -78
  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.js +2 -9
  30. package/dist/src/app/streaming/streamingResponse.js.map +1 -1
  31. package/dist/src/app/teamsAttachmentDownloader.d.ts +3 -1
  32. package/dist/src/app/teamsAttachmentDownloader.js +12 -1
  33. package/dist/src/app/teamsAttachmentDownloader.js.map +1 -1
  34. package/dist/src/auth/authConfiguration.js +12 -0
  35. package/dist/src/auth/authConfiguration.js.map +1 -1
  36. package/dist/src/auth/index.d.ts +2 -1
  37. package/dist/src/auth/index.js +2 -1
  38. package/dist/src/auth/index.js.map +1 -1
  39. package/dist/src/auth/jwt-middleware.d.ts +6 -0
  40. package/dist/src/auth/jwt-middleware.js +263 -13
  41. package/dist/src/auth/jwt-middleware.js.map +1 -1
  42. package/dist/src/auth/msal/msalConnectionSettings.d.ts +8 -0
  43. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.d.ts +34 -0
  44. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.js +126 -0
  45. package/dist/src/auth/msal/msalHttpRetryHandlerHelper.js.map +1 -0
  46. package/dist/src/auth/msal/msalTokenProvider.d.ts +1 -0
  47. package/dist/src/auth/msal/msalTokenProvider.js +23 -14
  48. package/dist/src/auth/msal/msalTokenProvider.js.map +1 -1
  49. package/dist/src/auth/settings.d.ts +18 -0
  50. package/dist/src/auth/settings.js +8 -2
  51. package/dist/src/auth/settings.js.map +1 -1
  52. package/dist/src/cloudAdapter.d.ts +29 -2
  53. package/dist/src/cloudAdapter.js +44 -2
  54. package/dist/src/cloudAdapter.js.map +1 -1
  55. package/dist/src/errorHelper.js +137 -101
  56. package/dist/src/errorHelper.js.map +1 -1
  57. package/dist/src/index.d.ts +1 -0
  58. package/dist/src/index.js +1 -0
  59. package/dist/src/index.js.map +1 -1
  60. package/dist/src/invoke/adaptiveCardInvokeValue.d.ts +5 -0
  61. package/dist/src/oauth/userTokenClient.d.ts +0 -1
  62. package/dist/src/oauth/userTokenClient.js +0 -1
  63. package/dist/src/oauth/userTokenClient.js.map +1 -1
  64. package/dist/src/observability/traces.js +3 -2
  65. package/dist/src/observability/traces.js.map +1 -1
  66. package/dist/src/outboundHostValidator.d.ts +47 -0
  67. package/dist/src/outboundHostValidator.js +149 -0
  68. package/dist/src/outboundHostValidator.js.map +1 -0
  69. package/dist/src/state/agentState.d.ts +5 -0
  70. package/dist/src/state/agentState.js +16 -3
  71. package/dist/src/state/agentState.js.map +1 -1
  72. package/package.json +3 -3
  73. package/src/agent-client/agentClient.ts +46 -23
  74. package/src/agent-client/agentResponseHandler.ts +14 -9
  75. package/src/agent-client/createAgentResponseHandler.ts +70 -9
  76. package/src/app/adaptiveCards/activityValueParsers.ts +3 -9
  77. package/src/app/adaptiveCards/adaptiveCardsActions.ts +52 -24
  78. package/src/app/agentApplication.ts +135 -74
  79. package/src/app/attachmentDownloader.ts +25 -5
  80. package/src/app/auth/handlers/agenticAuthorization.ts +1 -1
  81. package/src/app/auth/handlers/azureBotAuthorization.ts +1 -1
  82. package/src/app/auth/types.ts +3 -3
  83. package/src/app/index.ts +8 -0
  84. package/src/app/streaming/streamingResponse.ts +2 -8
  85. package/src/app/teamsAttachmentDownloader.ts +11 -1
  86. package/src/auth/authConfiguration.ts +12 -0
  87. package/src/auth/index.ts +2 -1
  88. package/src/auth/jwt-middleware.ts +298 -14
  89. package/src/auth/msal/msalConnectionSettings.ts +10 -0
  90. package/src/auth/msal/msalHttpRetryHandlerHelper.ts +161 -0
  91. package/src/auth/msal/msalTokenProvider.ts +32 -14
  92. package/src/auth/settings.ts +18 -3
  93. package/src/cloudAdapter.ts +56 -3
  94. package/src/errorHelper.ts +155 -114
  95. package/src/index.ts +1 -0
  96. package/src/invoke/adaptiveCardInvokeValue.ts +5 -0
  97. package/src/oauth/userTokenClient.ts +0 -1
  98. package/src/observability/traces.ts +2 -1
  99. package/src/outboundHostValidator.ts +172 -0
  100. package/src/state/agentState.ts +24 -6
@@ -48,7 +48,7 @@ interface AzureBotActiveHandler extends ActiveAuthorizationHandler {
48
48
  * where `{handlerId}` is the handler's unique identifier and `{propertyName}` matches the property name.
49
49
  *
50
50
  * @example
51
- * ```env
51
+ * ```
52
52
  * # For a handler with id "myAuth":
53
53
  * AgentApplication__UserAuthorization__handlers__myAuth__settings__azureBotOAuthConnectionName=MyConnection
54
54
  * AgentApplication__UserAuthorization__handlers__myAuth__settings__oboScopes=api://scope1 api://scope2
@@ -126,7 +126,7 @@ export interface AuthorizationHandlerTokenOptions {
126
126
  * where `{handlerId}` is the handler's unique identifier and `{propertyName}` matches the property name.
127
127
  *
128
128
  * @example
129
- * ```env
129
+ * ```
130
130
  * # For a handler with id "myAuth":
131
131
  * AgentApplication__UserAuthorization__handlers__myAuth__settings__azureBotOAuthConnectionName=MyConnection
132
132
  * AgentApplication__UserAuthorization__handlers__myAuth__settings__oboScopes=api://scope1 api://scope2
@@ -170,7 +170,7 @@ export interface AzureBotAuthorizationOptionsLegacy {
170
170
  * @deprecated
171
171
  * Messages configuration for the AzureBotAuthorization handler.
172
172
  */
173
- interface AzureBotAuthorizationOptionsMessages {
173
+ export interface AzureBotAuthorizationOptionsMessages {
174
174
  /**
175
175
  * @deprecated Use `invalidSignInRetryMessage` instead.
176
176
  * Message displayed when an invalid code is entered.
@@ -198,7 +198,7 @@ interface AzureBotAuthorizationOptionsMessages {
198
198
  * @deprecated
199
199
  * Settings for on-behalf-of token acquisition.
200
200
  */
201
- interface AzureBotAuthorizationOptionsOBO {
201
+ export interface AzureBotAuthorizationOptionsOBO {
202
202
  /**
203
203
  * @deprecated Use `oboConnectionName` instead.
204
204
  * Connection name to use for on-behalf-of token acquisition.
package/src/app/index.ts CHANGED
@@ -3,6 +3,14 @@ export * from './agentApplicationBuilder'
3
3
  export * from './agentApplicationOptions'
4
4
  export * from './appRoute'
5
5
  export { Authorization } from './auth/authorization'
6
+ export type { AgenticAuthorizationOptions, AzureBotAuthorizationOptions } from './auth/handlers'
7
+ export type {
8
+ AuthorizationOptions,
9
+ AuthorizationHandlerTokenOptions,
10
+ AzureBotAuthorizationOptionsLegacy,
11
+ AzureBotAuthorizationOptionsMessages,
12
+ AzureBotAuthorizationOptionsOBO
13
+ } from './auth/types'
6
14
  export * from './routeHandler'
7
15
  export * from './routeList'
8
16
  export * from './routeRank'
@@ -537,14 +537,8 @@ export class StreamingResponse {
537
537
  if (activity.deliveryMode === DeliveryModes.ExpectReplies) {
538
538
  this._isStreamingChannel = false
539
539
  } else if (Channels.Msteams === activity.channelIdChannel) {
540
- if (activity.isAgenticRequest()) {
541
- // Agentic requests do not support streaming responses at this time.
542
- // TODO: Enable streaming for agentic requests when supported.
543
- this._isStreamingChannel = false
544
- } else {
545
- this._isStreamingChannel = true
546
- this._delayInMs = 1000
547
- }
540
+ this._isStreamingChannel = true
541
+ this._delayInMs = 1000
548
542
  } else if (Channels.Webchat === activity.channelId || Channels.Directline === activity.channelId || Channels.Emulator === activity.channelId) {
549
543
  this._isStreamingChannel = true
550
544
  this._delayInMs = 500
@@ -11,6 +11,7 @@ import { TurnContext } from '../turnContext'
11
11
  import { TurnState } from './turnState'
12
12
  import { HttpClient } from '../httpClient'
13
13
  import { z } from 'zod'
14
+ import { createOutboundHostValidator, OutboundUrlPolicy } from '../outboundHostValidator'
14
15
 
15
16
  const logger = debug('agents:M365AttachmentDownloader')
16
17
 
@@ -20,10 +21,12 @@ const logger = debug('agents:M365AttachmentDownloader')
20
21
  export class M365AttachmentDownloader<TState extends TurnState = TurnState> implements InputFileDownloader<TState> {
21
22
  private _httpClient: HttpClient
22
23
  private _stateKey: string
24
+ private readonly _hostValidator: OutboundUrlPolicy
23
25
 
24
- public constructor (stateKey: string = 'inputFiles') {
26
+ public constructor (stateKey: string = 'inputFiles', outboundHostValidator?: OutboundUrlPolicy) {
25
27
  this._httpClient = new HttpClient()
26
28
  this._stateKey = stateKey
29
+ this._hostValidator = outboundHostValidator ?? createOutboundHostValidator()
27
30
  }
28
31
 
29
32
  /**
@@ -69,6 +72,13 @@ export class M365AttachmentDownloader<TState extends TurnState = TurnState> impl
69
72
  const contentSchema = z.object({ downloadUrl: z.string().url() })
70
73
  const parsed = contentSchema.safeParse(attachment.content)
71
74
  const downloadUrl = parsed.success ? parsed.data.downloadUrl : attachment.contentUrl
75
+ if (!this._hostValidator.isAllowed(downloadUrl)) {
76
+ let host = '[invalid-url]'
77
+ try { host = new URL(downloadUrl).hostname } catch { /* invalid */ }
78
+ logger.warn(`Attachment downloadUrl host is not in the configured allowed hosts. Host='${host}'`)
79
+ return undefined
80
+ }
81
+
72
82
  const response = await this._httpClient.get(downloadUrl, { responseType: 'arraybuffer' })
73
83
 
74
84
  const content = Buffer.from(response.data as ArrayBuffer)
@@ -8,6 +8,7 @@ import { loadEnvSettings, AuthConfiguration, envParser, envParserUtils, LoadEnv,
8
8
 
9
9
  export { type AuthConfiguration, type ConnectionSettings, type ConnectionSettingsBase, type MsalConnectionSettings, type SidecarConnectionSettings, AuthType, resolveAuthority, type ConnectionMapItem, resolveAuthType } from './settings'
10
10
  import { prune } from '../utils'
11
+ import { parseBooleanEnv } from '../utils/env'
11
12
  import { ExceptionHelper } from '@microsoft/agents-activity'
12
13
  import { Errors } from '../errorHelper'
13
14
 
@@ -28,6 +29,7 @@ function summarizeAuthConfiguration (authConfig: AuthConfiguration) {
28
29
  authorityEndpoint: config.authorityEndpoint ? redactUrl(config.authorityEndpoint) : undefined,
29
30
  scopes: (config.scopes ? redactScopes(config.scopes) : undefined) as any,
30
31
  issuers: config.issuers?.map(redactUrl).filter(e => e !== undefined),
32
+ validateIssuer: config.validateIssuer,
31
33
  federatedClientId: redactString(config.federatedClientId, true),
32
34
  certPemFile: redactString(config.certPemFile),
33
35
  certKeyFile: redactString(config.certKeyFile),
@@ -40,6 +42,7 @@ function summarizeAuthConfiguration (authConfig: AuthConfiguration) {
40
42
  alternateBlueprintConnectionName: undefined, // Alias of altBlueprintConnectionName, avoid logging duplicate info
41
43
  azureRegion: config.azureRegion,
42
44
  sendX5C: config.sendX5C,
45
+ msalRetryCount: config.msalRetryCount,
43
46
  sidecarBaseUrl: config.sidecarBaseUrl ? redactUrl(config.sidecarBaseUrl) : undefined,
44
47
  serviceName: config.serviceName,
45
48
  blueprintServiceName: config.blueprintServiceName,
@@ -96,6 +99,10 @@ const connectionsEnv = {
96
99
  idpmResource: envParserUtils.bypass,
97
100
  azureRegion: envParserUtils.bypass,
98
101
  sendX5C: (value) => ({ value: value === 'true' }),
102
+ msalRetryCount: (value) => {
103
+ const n = parseInt(value, 10)
104
+ return { value: Number.isFinite(n) && n >= 0 ? n : undefined }
105
+ },
99
106
  sidecarBaseUrl: envParserUtils.bypass,
100
107
  serviceName: envParserUtils.bypass,
101
108
  blueprintServiceName: envParserUtils.bypass,
@@ -114,6 +121,7 @@ const connectionsEnv = {
114
121
  }
115
122
  return { value: value.split(/\s+/).filter(Boolean) }
116
123
  },
124
+ validateIssuer: (value) => ({ value: parseBooleanEnv(value) }),
117
125
  }),
118
126
  default (connections?: AuthConfiguration['connections'], connectionsMap?: AuthConfiguration['connectionsMap']) {
119
127
  const conn = connections ?? this.connections
@@ -230,6 +238,7 @@ const legacyBotFrameworkEnv = {
230
238
  WIDAssertionFile: envParserUtils.redirect(connectionsEnv.parser, 'WIDAssertionFile'),
231
239
  azureRegion: envParserUtils.redirect(connectionsEnv.parser, 'azureRegion'),
232
240
  sendX5C: envParserUtils.redirect(connectionsEnv.parser, 'sendX5C'),
241
+ msalRetryCount: envParserUtils.redirect(connectionsEnv.parser, 'msalRetryCount'),
233
242
  authType: envParserUtils.redirect(connectionsEnv.parser, 'authType'),
234
243
  federatedTokenFile: envParserUtils.redirect(connectionsEnv.parser, 'federatedTokenFile'),
235
244
  idpmResource: envParserUtils.redirect(connectionsEnv.parser, 'idpmResource'),
@@ -239,6 +248,7 @@ const legacyBotFrameworkEnv = {
239
248
  bypassLocalNetworkRestriction: envParserUtils.redirect(connectionsEnv.parser, 'bypassLocalNetworkRestriction'),
240
249
  requestTimeout: envParserUtils.redirect(connectionsEnv.parser, 'requestTimeout'),
241
250
  retryCount: envParserUtils.redirect(connectionsEnv.parser, 'retryCount'),
251
+ validateIssuer: envParserUtils.redirect(connectionsEnv.parser, 'validateIssuer'),
242
252
  }),
243
253
  process (env: LoadEnv) {
244
254
  return legacyPrefixEnv.process.call(this, env)
@@ -265,6 +275,7 @@ const legacyPrefixEnv = {
265
275
  WIDAssertionFile: envParserUtils.redirect(connectionsEnv.parser, 'WIDAssertionFile'),
266
276
  azureRegion: envParserUtils.redirect(connectionsEnv.parser, 'azureRegion'),
267
277
  sendX5C: envParserUtils.redirect(connectionsEnv.parser, 'sendX5C'),
278
+ msalRetryCount: envParserUtils.redirect(connectionsEnv.parser, 'msalRetryCount'),
268
279
  authType: envParserUtils.redirect(connectionsEnv.parser, 'authType'),
269
280
  federatedTokenFile: envParserUtils.redirect(connectionsEnv.parser, 'federatedTokenFile'),
270
281
  idpmResource: envParserUtils.redirect(connectionsEnv.parser, 'idpmResource'),
@@ -274,6 +285,7 @@ const legacyPrefixEnv = {
274
285
  bypassLocalNetworkRestriction: envParserUtils.redirect(connectionsEnv.parser, 'bypassLocalNetworkRestriction'),
275
286
  requestTimeout: envParserUtils.redirect(connectionsEnv.parser, 'requestTimeout'),
276
287
  retryCount: envParserUtils.redirect(connectionsEnv.parser, 'retryCount'),
288
+ validateIssuer: envParserUtils.redirect(connectionsEnv.parser, 'validateIssuer'),
277
289
  }),
278
290
  process (env: LoadEnv, prefix?: string) {
279
291
  const settings: Partial<AuthConfiguration> = {}
package/src/auth/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export * from './authConfiguration'
2
2
  export * from './authConstants'
3
3
  export * from './authProvider'
4
- export * from './msal/msalTokenProvider'
4
+ export * from './connections'
5
5
  export * from './request'
6
+ export * from './msal/msalTokenProvider'
6
7
  export * from './msal/msalTokenCredential'
7
8
  export * from './connectionManager'
8
9
  export * from './connections'
@@ -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
  }