microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
kavin/agents-sdk-interop

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs

406lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Collections.Concurrent;
5using System.Security.Claims;
6using Microsoft.AspNetCore.Authentication;
7using Microsoft.AspNetCore.Authentication.JwtBearer;
8using Microsoft.AspNetCore.Authorization;
9using Microsoft.AspNetCore.Http;
10using Microsoft.Extensions.Configuration;
11using Microsoft.Extensions.DependencyInjection;
12using Microsoft.Extensions.Logging;
13using Microsoft.Extensions.Logging.Abstractions;
14using Microsoft.IdentityModel.JsonWebTokens;
15using Microsoft.IdentityModel.Protocols;
16using Microsoft.IdentityModel.Protocols.OpenIdConnect;
17using Microsoft.IdentityModel.Tokens;
18using Microsoft.IdentityModel.Validators;
19
20namespace Microsoft.Teams.Core.Hosting
21{
22 /// <summary>
23 /// Provides extension methods for configuring JWT authentication and authorization for bots and agents.
24 /// </summary>
25 public static class JwtExtensions
26 {
27 /// <summary>
28 /// Adds JWT authentication for bots and agents using configuration from appsettings.
29 /// </summary>
30 /// <param name="services">The service collection to add authentication to.</param>
31 /// <param name="aadSectionName">The configuration section name for the settings. Defaults to "AzureAd".</param>
32 /// <param name="logger">The logger instance for logging.</param>
33 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
34 public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null)
35 {
36 BotConfig botConfig = BotConfig.Resolve(services, aadSectionName);
37 return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger);
38 }
39
40 /// <summary>
41 /// Adds JWT authentication for bots and agents with manually provided configuration values.
42 /// </summary>
43 /// <param name="services">The service collection to add authentication to.</param>
44 /// <param name="clientId">The application (client) ID for token validation.</param>
45 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
46 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
47 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
48 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
49 public static AuthenticationBuilder AddBotAuthentication(
50 this IServiceCollection services,
51 string clientId,
52 string tenantId = "",
53 string schemeName = BotConfig.DefaultSectionName,
54 ILogger? logger = null)
55 {
56 AuthenticationBuilder builder = services.AddAuthentication();
57 builder.AddBotAuthentication(clientId, tenantId, schemeName, logger);
58 return builder;
59 }
60
61 /// <summary>
62 /// Adds JWT authentication for bots and agents to an existing authentication builder.
63 /// Use this overload when registering multiple authentication schemes to avoid calling AddAuthentication() multiple times.
64 /// </summary>
65 /// <param name="builder">The existing authentication builder.</param>
66 /// <param name="clientId">The application (client) ID for token validation.</param>
67 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
68 /// <param name="schemeName">The authentication scheme name.</param>
69 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
70 /// <returns>The <see cref="AuthenticationBuilder"/> for chaining.</returns>
71 public static AuthenticationBuilder AddBotAuthentication(
72 this AuthenticationBuilder builder,
73 string clientId,
74 string tenantId = "",
75 string schemeName = BotConfig.DefaultSectionName,
76 ILogger? logger = null)
77 {
78 ArgumentNullException.ThrowIfNull(builder);
79
80 if (string.IsNullOrWhiteSpace(clientId))
81 {
82 builder.AddBypassAuthentication(schemeName, logger);
83 }
84 else
85 {
86 builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, logger);
87 }
88 return builder;
89 }
90
91 /// <summary>
92 /// Adds authorization policies to the service collection using configuration from appsettings.
93 /// </summary>
94 /// <param name="services">The service collection to add authorization to.</param>
95 /// <param name="aadSectionName">The configuration section name for the settings. Defaults to "AzureAd".</param>
96 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
97 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
98 public static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null)
99 {
100 logger ??= NullLogger.Instance;
101
102 BotConfig botConfig = BotConfig.Resolve(services, aadSectionName);
103 return services.AddBotAuthorization(botConfig, logger);
104 }
105
106 /// <summary>
107 /// Adds authorization policies to the service collection using configuration from appsettings.
108 /// </summary>
109 /// <param name="services">The service collection to add authorization to.</param>
110 /// <param name="botConfig">The bot configuration settings.</param>
111 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
112 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
113 internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, BotConfig botConfig, ILogger? logger = null)
114 {
115 logger ??= NullLogger.Instance;
116
117 // Call AddTeamsJwtBearer with the already-resolved BotConfig to avoid a redundant
118 // BotConfig.Resolve call (and duplicate startup log) that would occur through the
119 // public string-based AddBotAuthentication → AddTeamsJwtBearer chain.
120 AuthenticationBuilder authBuilder = services.AddAuthentication();
121 if (string.IsNullOrWhiteSpace(botConfig.ClientId))
122 {
123 authBuilder.AddBypassAuthentication(botConfig.SectionName, logger);
124 }
125 else
126 {
127 authBuilder.AddTeamsJwtBearer(botConfig, logger);
128 }
129
130 return services
131 .AddAuthorizationBuilder()
132 .AddDefaultPolicy(botConfig.SectionName, policy =>
133 {
134 policy.AuthenticationSchemes.Add(botConfig.SectionName);
135 policy.RequireAuthenticatedUser();
136 });
137 }
138
139 /// <summary>
140 /// Adds authorization policies to the service collection with manually provided configuration values.
141 /// </summary>
142 /// <param name="services">The service collection to add authorization to.</param>
143 /// <param name="clientId">The application (client) ID for token validation.</param>
144 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
145 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
146 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
147 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
148 public static AuthorizationBuilder AddBotAuthorization(
149 this IServiceCollection services,
150 string clientId,
151 string tenantId = "",
152 string schemeName = BotConfig.DefaultSectionName,
153 ILogger? logger = null)
154 {
155 services.AddBotAuthentication(clientId, tenantId, schemeName, logger);
156
157 return services
158 .AddAuthorizationBuilder()
159 .AddDefaultPolicy(schemeName, policy =>
160 {
161 policy.AuthenticationSchemes.Add(schemeName);
162 policy.RequireAuthenticatedUser();
163 });
164 }
165
166 internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, string entraInstance, string botTokenIssuer)
167 {
168 // Bot Framework tokens. The expected issuer varies by sovereign cloud
169 // (e.g. https://api.botframework.us for USGov) so it comes from configuration.
170 if (issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase))
171 {
172 return issuer;
173 }
174
175 // Entra tokens - bot-to-bot (agent) and user (tab/API)
176 // Use the token's own tid claim for multi-tenant; fall back to configured tenant.
177 // The v2.0 expected issuer is derived from the configured Entra instance so sovereign
178 // tokens (e.g. login.microsoftonline.us) validate correctly.
179 (_, string? tid) = GetTokenClaims(token);
180 string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId;
181
182 if (effectiveTenant is not null &&
183 (issuer == $"{entraInstance}{effectiveTenant}/v2.0" ||
184 issuer == $"https://sts.windows.net/{effectiveTenant}/"))
185 {
186 return issuer;
187 }
188
189 throw new SecurityTokenInvalidIssuerException(
190 $"Issuer '{issuer}' is not valid for tenant '{effectiveTenant ?? "<unknown>"}'.");
191 }
192
193 /// <summary>
194 /// Picks the OIDC metadata authority to fetch signing keys from based on the token's
195 /// issuer claim. Tokens issued by the configured Bot Framework issuer (e.g. the public
196 /// "https://api.botframework.com" or a sovereign equivalent like "https://api.botframework.us")
197 /// resolve to the configured Bot OIDC URL; all others fall through to the Entra tenant authority.
198 /// </summary>
199 internal static string ResolveSigningAuthority(string? iss, string? tid, string botTokenIssuer, string botOidcUrl, string entraInstance)
200 {
201 if (iss is null) return string.Empty;
202 return iss.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase)
203 ? botOidcUrl
204 : $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
205 }
206
207 private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
208 token is JsonWebToken jwt
209 ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null)
210 : (null, null);
211
212 /// <summary>
213 /// Overload that accepts an already-resolved <see cref="BotConfig"/> to avoid a redundant
214 /// <see cref="BotConfig.Resolve"/> call during internal registration paths.
215 /// </summary>
216 private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, BotConfig botConfig, ILogger? logger = null)
217 {
218 return builder.AddTeamsJwtBearer(
219 botConfig.SectionName,
220 botConfig.ClientId,
221 botConfig.TenantId,
222 botConfig.OpenIdMetadataUrl,
223 botConfig.EntraInstance,
224 botConfig.BotTokenIssuer,
225 logger);
226 }
227
228 private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null)
229 {
230 // Resolve sovereign-cloud-aware URLs from the same AzureAd section that produced clientId/tenantId.
231 // Defaults to the public-cloud values when IConfiguration is not registered (manual-credentials callers)
232 // or when the section is missing or doesn't override them.
233 string botOidcUrl = BotConfig.DefaultOpenIdMetadataUrl;
234 string entraInstance = BotConfig.DefaultEntraInstance;
235 string botTokenIssuer = BotConfig.DefaultBotTokenIssuer;
236 if (builder.Services.Any(d => d.ServiceType == typeof(IConfiguration)))
237 {
238 BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName);
239 botOidcUrl = botConfig.OpenIdMetadataUrl;
240 entraInstance = botConfig.EntraInstance;
241 botTokenIssuer = botConfig.BotTokenIssuer;
242 }
243
244 return builder.AddTeamsJwtBearer(schemeName, audience, tenantId, botOidcUrl, entraInstance, botTokenIssuer, logger);
245 }
246
247 private static AuthenticationBuilder AddTeamsJwtBearer(
248 this AuthenticationBuilder builder,
249 string schemeName,
250 string audience,
251 string tenantId,
252 string botOidcUrl,
253 string entraInstance,
254 string botTokenIssuer,
255 ILogger? logger)
256 {
257
258 // One ConfigurationManager per OIDC authority, shared safely across all requests.
259 ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> configManagerCache = new(StringComparer.OrdinalIgnoreCase);
260
261 // Cache resolved configurations to avoid blocking async calls on every token validation.
262 // ConfigurationManager handles background refresh internally; we cache the Task so that
263 // only the first call per authority actually blocks.
264 ConcurrentDictionary<string, Task<OpenIdConnectConfiguration>> configCache = new(StringComparer.OrdinalIgnoreCase);
265
266 builder.AddJwtBearer(schemeName, jwtOptions =>
267 {
268 jwtOptions.SaveToken = true;
269 jwtOptions.IncludeErrorDetails = true;
270 jwtOptions.TokenValidationParameters = new TokenValidationParameters
271 {
272 ValidateIssuerSigningKey = true,
273 RequireSignedTokens = true,
274 ValidateIssuer = true,
275 ValidateAudience = true,
276 ValidAudiences = [audience, $"api://{audience}"],
277 IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance, botTokenIssuer),
278 IssuerSigningKeyResolver = (_, securityToken, _, _) =>
279 {
280 (string? iss, string? tid) = GetTokenClaims(securityToken);
281 if (iss is null) return [];
282
283 string authority = ResolveSigningAuthority(iss, tid, botTokenIssuer, botOidcUrl, entraInstance);
284
285 logger?.ResolvingSigningKeys(authority, iss);
286
287 ConfigurationManager<OpenIdConnectConfiguration> manager = configManagerCache.GetOrAdd(authority, a =>
288 new ConfigurationManager<OpenIdConnectConfiguration>(
289 a,
290 new OpenIdConnectConfigurationRetriever(),
291 new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata }));
292
293 // Cache the Task so only the first call per authority blocks;
294 // subsequent calls return the already-completed task synchronously.
295 // ConfigurationManager handles background refresh of stale configs internally.
296 Task<OpenIdConnectConfiguration> configTask = configCache.GetOrAdd(authority,
297 _ => manager.GetConfigurationAsync(CancellationToken.None));
298
299 OpenIdConnectConfiguration config = configTask.ConfigureAwait(false).GetAwaiter().GetResult();
300 return config.SigningKeys;
301 }
302 };
303 jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
304 jwtOptions.MapInboundClaims = true;
305 jwtOptions.Events = new JwtBearerEvents
306 {
307 OnTokenValidated = context =>
308 {
309 ILogger log = GetLogger(context.HttpContext, logger);
310 log.TokenValidated(schemeName);
311 if (log.IsEnabled(LogLevel.Trace) && context.SecurityToken is JsonWebToken jwt)
312 {
313 string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}"));
314 log.IncomingTokenClaims(claims);
315 }
316 return Task.CompletedTask;
317 },
318 OnForbidden = context =>
319 {
320 GetLogger(context.HttpContext, logger).ForbiddenForScheme(schemeName);
321 return Task.CompletedTask;
322 },
323 OnAuthenticationFailed = context =>
324 {
325 ILogger log = GetLogger(context.HttpContext, logger);
326
327 string? tokenIssuer = null;
328 string? tokenAudience = null;
329 string? tokenExpiration = null;
330 string? tokenSubject = null;
331 string authHeader = context.Request.Headers.Authorization.ToString();
332 if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
333 {
334 try
335 {
336 JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim());
337 (tokenIssuer, _) = GetTokenClaims(jwt);
338 tokenAudience = jwt.GetClaim("aud")?.Value;
339 tokenExpiration = jwt.ValidTo.ToString("o");
340 tokenSubject = jwt.Subject;
341 }
342 catch (ArgumentException) { }
343 }
344
345 TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters;
346 string expectedAudiences = validationParams?.ValidAudiences is not null
347 ? string.Join(", ", validationParams.ValidAudiences)
348 : validationParams?.ValidAudience ?? "n/a";
349 log.JwtAuthenticationFailed(
350 context.Exception,
351 schemeName,
352 context.Exception.Message,
353 tokenIssuer ?? "n/a",
354 tokenAudience ?? "n/a",
355 tokenExpiration ?? "n/a",
356 tokenSubject ?? "n/a",
357 expectedAudiences);
358
359 return Task.CompletedTask;
360 }
361 };
362 jwtOptions.Validate();
363 });
364 return builder;
365 }
366
367 private static AuthenticationBuilder AddBypassAuthentication(this AuthenticationBuilder builder, string schemeName, ILogger? logger = null)
368 {
369 (logger ?? NullLogger.Instance).BypassAuthenticationConfigured(schemeName);
370
371 builder.AddJwtBearer(schemeName, jwtOptions =>
372 {
373#pragma warning disable CA5404 // Do not disable token validation checks
374 jwtOptions.TokenValidationParameters = new TokenValidationParameters
375 {
376 ValidateIssuer = false,
377 ValidateAudience = false,
378 ValidateLifetime = false,
379 ValidateIssuerSigningKey = false,
380 RequireSignedTokens = false,
381 SignatureValidator = (token, _) => new JsonWebToken(token)
382 };
383#pragma warning restore CA5404 // Do not disable token validation checks
384 jwtOptions.Events = new JwtBearerEvents
385 {
386 OnMessageReceived = context =>
387 {
388 // Always succeed authentication even without a token
389 GetLogger(context.HttpContext, logger).BypassAuthenticationSucceeded(schemeName);
390 context.NoResult();
391 context.Principal = new System.Security.Claims.ClaimsPrincipal(
392 new System.Security.Claims.ClaimsIdentity("BypassAuth"));
393 context.Success();
394 return Task.CompletedTask;
395 }
396 };
397 });
398 return builder;
399 }
400
401 private static ILogger GetLogger(HttpContext context, ILogger? fallback) =>
402 context.RequestServices.GetService<ILoggerFactory>()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions")
403 ?? fallback
404 ?? NullLogger.Instance;
405 }
406}
407