microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
samples/migration-bot

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

334lines · 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.Bot.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 internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration";
28 internal const string EntraOIDC = "https://login.microsoftonline.com/";
29
30 /// <summary>
31 /// Adds JWT authentication for bots and agents using configuration from appsettings.
32 /// </summary>
33 /// <param name="services">The service collection to add authentication to.</param>
34 /// <param name="aadSectionName">The configuration section name for the settings. Defaults to "AzureAd".</param>
35 /// <param name="logger">The logger instance for logging.</param>
36 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
37 public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = "AzureAd", ILogger? logger = null)
38 {
39 BotConfig botConfig = ResolveBotConfig(services, aadSectionName);
40 return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger);
41 }
42
43 /// <summary>
44 /// Adds JWT authentication for bots and agents with manually provided configuration values.
45 /// </summary>
46 /// <param name="services">The service collection to add authentication to.</param>
47 /// <param name="clientId">The application (client) ID for token validation.</param>
48 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
49 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
50 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
51 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
52 public static AuthenticationBuilder AddBotAuthentication(
53 this IServiceCollection services,
54 string clientId,
55 string tenantId = "",
56 string schemeName = "AzureAd",
57 ILogger? logger = null)
58 {
59 AuthenticationBuilder builder = services.AddAuthentication();
60 builder.AddBotAuthentication(clientId, tenantId, schemeName, logger);
61 return builder;
62 }
63
64 /// <summary>
65 /// Adds JWT authentication for bots and agents to an existing authentication builder.
66 /// Use this overload when registering multiple authentication schemes to avoid calling AddAuthentication() multiple times.
67 /// </summary>
68 /// <param name="builder">The existing authentication builder.</param>
69 /// <param name="clientId">The application (client) ID for token validation.</param>
70 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
71 /// <param name="schemeName">The authentication scheme name.</param>
72 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
73 /// <returns>The <see cref="AuthenticationBuilder"/> for chaining.</returns>
74 public static AuthenticationBuilder AddBotAuthentication(
75 this AuthenticationBuilder builder,
76 string clientId,
77 string tenantId = "",
78 string schemeName = "AzureAd",
79 ILogger? logger = null)
80 {
81 if (string.IsNullOrWhiteSpace(clientId))
82 {
83 builder.AddBypassAuthentication(schemeName, logger);
84 }
85 else
86 {
87 builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, logger);
88 }
89 return builder;
90 }
91
92 /// <summary>
93 /// Adds authorization policies to the service collection using configuration from appsettings.
94 /// </summary>
95 /// <param name="services">The service collection to add authorization to.</param>
96 /// <param name="aadSectionName">The configuration section name for the settings. Defaults to "AzureAd".</param>
97 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
98 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
99 public static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, string aadSectionName = "AzureAd", ILogger? logger = null)
100 {
101 logger ??= NullLogger.Instance;
102
103 BotConfig botConfig = ResolveBotConfig(services, aadSectionName);
104 return services.AddBotAuthorization(botConfig, logger);
105 }
106
107 /// <summary>
108 /// Adds authorization policies to the service collection using configuration from appsettings.
109 /// </summary>
110 /// <param name="services">The service collection to add authorization to.</param>
111 /// <param name="botConfig">The bot configuration settings.</param>
112 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
113 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
114 internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, BotConfig botConfig, ILogger? logger = null)
115 {
116 logger ??= NullLogger.Instance;
117
118 return services.AddBotAuthorization(botConfig.ClientId, botConfig.TenantId, botConfig.SectionName, logger);
119 }
120
121 /// <summary>
122 /// Adds authorization policies to the service collection with manually provided configuration values.
123 /// </summary>
124 /// <param name="services">The service collection to add authorization to.</param>
125 /// <param name="clientId">The application (client) ID for token validation.</param>
126 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
127 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
128 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
129 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
130 public static AuthorizationBuilder AddBotAuthorization(
131 this IServiceCollection services,
132 string clientId,
133 string tenantId = "",
134 string schemeName = "AzureAd",
135 ILogger? logger = null)
136 {
137 services.AddBotAuthentication(clientId, tenantId, schemeName, logger);
138
139 return services
140 .AddAuthorizationBuilder()
141 .AddDefaultPolicy(schemeName, policy =>
142 {
143 policy.AuthenticationSchemes.Add(schemeName);
144 policy.RequireAuthenticatedUser();
145 });
146 }
147
148 private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId)
149 {
150 // Bot Framework tokens
151 if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase))
152 return issuer;
153
154 // Entra tokens � bot-to-bot (agent) and user (tab/API)
155 // Use the token's own tid claim for multi-tenant; fall back to configured tenant
156 (_, string? tid) = GetTokenClaims(token);
157 string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId;
158
159 if (effectiveTenant is not null &&
160 (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" ||
161 issuer == $"https://sts.windows.net/{effectiveTenant}/"))
162 return issuer;
163
164 throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid.");
165 }
166
167 private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
168 token is JsonWebToken jwt
169 ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null)
170 : (null, null);
171
172 /// <summary>
173 /// Adds Teams JWT Bearer authentication that supports both Bot Framework and Entra ID tokens.
174 /// </summary>
175 /// <param name="builder">The authentication builder.</param>
176 /// <param name="schemeName">The authentication scheme name.</param>
177 /// <param name="audience">The application (client) ID used to validate the audience of tokens.</param>
178 /// <param name="tenantId">The Azure AD tenant ID.</param>
179 /// <param name="logger">Optional logger for authentication events.</param>
180 /// <returns>The authentication builder for chaining.</returns>
181 /// <remarks>
182 /// This method configures authentication to support both types of tokens:
183 /// <list type="bullet">
184 /// <item><description>Bot Framework tokens: Issued by the Bot Connector service when channels send activities to your bot (issuer: https://api.botframework.com).</description></item>
185 /// <item><description>Entra ID tokens: Issued by Azure AD when the bot is registered as an agentic application (issuer: https://login.microsoftonline.com). See https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity#understanding-agent-identity-components</description></item>
186 /// </list>
187 /// The signing keys for both token types are dynamically resolved at runtime using OpenID Connect discovery,
188 /// allowing the same authentication configuration to validate tokens from multiple issuers.
189 /// </remarks>
190 private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null)
191 {
192 // One ConfigurationManager per OIDC authority, shared safely across all requests.
193 ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> configManagerCache = new(StringComparer.OrdinalIgnoreCase);
194
195 builder.AddJwtBearer(schemeName, jwtOptions =>
196 {
197 jwtOptions.SaveToken = true;
198 jwtOptions.IncludeErrorDetails = true;
199 jwtOptions.TokenValidationParameters = new TokenValidationParameters
200 {
201 ValidateIssuerSigningKey = true,
202 RequireSignedTokens = true,
203 ValidateIssuer = true,
204 ValidateAudience = true,
205 ValidAudiences = [audience, $"api://{audience}"],
206 IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId),
207 IssuerSigningKeyResolver = (_, securityToken, _, _) =>
208 {
209 (string? iss, string? tid) = GetTokenClaims(securityToken);
210 if (iss is null) return [];
211
212 string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)
213 ? BotOIDC
214 : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
215
216 ConfigurationManager<OpenIdConnectConfiguration> manager = configManagerCache.GetOrAdd(authority, a =>
217 new ConfigurationManager<OpenIdConnectConfiguration>(
218 a,
219 new OpenIdConnectConfigurationRetriever(),
220 new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata }));
221
222 OpenIdConnectConfiguration config = manager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult();
223 return config.SigningKeys;
224 }
225 };
226 jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
227 jwtOptions.MapInboundClaims = true;
228 jwtOptions.Events = new JwtBearerEvents
229 {
230 OnTokenValidated = context =>
231 {
232 GetLogger(context.HttpContext, logger).LogTraceGuarded("Token validated for scheme: {Scheme}", schemeName);
233 return Task.CompletedTask;
234 },
235 OnForbidden = context =>
236 {
237 GetLogger(context.HttpContext, logger).LogWarning("Forbidden for scheme: {Scheme}", schemeName);
238 return Task.CompletedTask;
239 },
240 OnAuthenticationFailed = context =>
241 {
242 ILogger log = GetLogger(context.HttpContext, logger);
243
244 string? tokenIssuer = null;
245 string? tokenAudience = null;
246 string? tokenExpiration = null;
247 string? tokenSubject = null;
248 string authHeader = context.Request.Headers.Authorization.ToString();
249 if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
250 {
251 try
252 {
253 JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim());
254 (tokenIssuer, _) = GetTokenClaims(jwt);
255 tokenAudience = jwt.GetClaim("aud")?.Value;
256 tokenExpiration = jwt.ValidTo.ToString("o");
257 tokenSubject = jwt.Subject;
258 }
259 catch (ArgumentException) { }
260 }
261
262 TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters;
263 string expectedAudiences = validationParams?.ValidAudiences is not null
264 ? string.Join(", ", validationParams.ValidAudiences)
265 : validationParams?.ValidAudience ?? "n/a";
266 log.LogError(context.Exception,
267 "JWT authentication failed for scheme {Scheme}: {ExceptionMessage} | " +
268 "token iss={TokenIssuer} aud={TokenAudience} exp={TokenExpiration} sub={TokenSubject} | " +
269 "expected aud={ConfiguredAudience}",
270 schemeName,
271 context.Exception.Message,
272 tokenIssuer ?? "n/a",
273 tokenAudience ?? "n/a",
274 tokenExpiration ?? "n/a",
275 tokenSubject ?? "n/a",
276 expectedAudiences);
277
278 return Task.CompletedTask;
279 }
280 };
281 jwtOptions.Validate();
282 });
283 return builder;
284 }
285
286 private static AuthenticationBuilder AddBypassAuthentication(this AuthenticationBuilder builder, string schemeName, ILogger? logger = null)
287 {
288 (logger ?? NullLogger.Instance).LogWarning("ClientId not provided for scheme '{SchemeName}'. Configuring bypass authentication (no token validation). This is INSECURE and should only be used for development.", schemeName);
289
290 builder.AddJwtBearer(schemeName, jwtOptions =>
291 {
292#pragma warning disable CA5404 // Do not disable token validation checks
293 jwtOptions.TokenValidationParameters = new TokenValidationParameters
294 {
295 ValidateIssuer = false,
296 ValidateAudience = false,
297 ValidateLifetime = false,
298 ValidateIssuerSigningKey = false,
299 RequireSignedTokens = false,
300 SignatureValidator = (token, _) => new JsonWebToken(token)
301 };
302#pragma warning restore CA5404 // Do not disable token validation checks
303 jwtOptions.Events = new JwtBearerEvents
304 {
305 OnMessageReceived = context =>
306 {
307 // Always succeed authentication even without a token
308 GetLogger(context.HttpContext, logger).LogWarning("Using bypass authentication scheme succeeded for scheme: {Scheme}. This is INSECURE and should only be used for development.", schemeName);
309 context.NoResult();
310 context.Principal = new System.Security.Claims.ClaimsPrincipal(
311 new System.Security.Claims.ClaimsIdentity("BypassAuth"));
312 context.Success();
313 return Task.CompletedTask;
314 }
315 };
316 });
317 return builder;
318 }
319
320 private static BotConfig ResolveBotConfig(IServiceCollection services, string sectionName)
321 {
322 ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration));
323 IConfiguration configuration = configDescriptor?.ImplementationInstance as IConfiguration
324 ?? services.BuildServiceProvider().GetRequiredService<IConfiguration>();
325
326 return BotConfig.Resolve(configuration, sectionName);
327 }
328
329 private static ILogger GetLogger(HttpContext context, ILogger? fallback) =>
330 context.RequestServices.GetService<ILoggerFactory>()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions")
331 ?? fallback
332 ?? NullLogger.Instance;
333 }
334}
335