microsoft/teams.net

Public

mirrored from https://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.0.7

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

341lines · 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.DependencyInjection;
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Logging.Abstractions;
13using Microsoft.IdentityModel.JsonWebTokens;
14using Microsoft.IdentityModel.Protocols;
15using Microsoft.IdentityModel.Protocols.OpenIdConnect;
16using Microsoft.IdentityModel.Tokens;
17using Microsoft.IdentityModel.Validators;
18
19namespace Microsoft.Teams.Core.Hosting
20{
21 /// <summary>
22 /// Provides extension methods for configuring JWT authentication and authorization for bots and agents.
23 /// </summary>
24 public static class JwtExtensions
25 {
26 internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration";
27 internal const string EntraOIDC = "https://login.microsoftonline.com/";
28
29 /// <summary>
30 /// Adds JWT authentication for bots and agents using configuration from appsettings.
31 /// </summary>
32 /// <param name="services">The service collection to add authentication to.</param>
33 /// <param name="aadSectionName">The configuration section name for the settings. Defaults to "AzureAd".</param>
34 /// <param name="logger">The logger instance for logging.</param>
35 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
36 public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null)
37 {
38 BotConfig botConfig = BotConfig.Resolve(services, aadSectionName);
39 return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger);
40 }
41
42 /// <summary>
43 /// Adds JWT authentication for bots and agents with manually provided configuration values.
44 /// </summary>
45 /// <param name="services">The service collection to add authentication to.</param>
46 /// <param name="clientId">The application (client) ID for token validation.</param>
47 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
48 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
49 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
50 /// <returns>An <see cref="AuthenticationBuilder"/> for further authentication configuration.</returns>
51 public static AuthenticationBuilder AddBotAuthentication(
52 this IServiceCollection services,
53 string clientId,
54 string tenantId = "",
55 string schemeName = BotConfig.DefaultSectionName,
56 ILogger? logger = null)
57 {
58 AuthenticationBuilder builder = services.AddAuthentication();
59 builder.AddBotAuthentication(clientId, tenantId, schemeName, logger);
60 return builder;
61 }
62
63 /// <summary>
64 /// Adds JWT authentication for bots and agents to an existing authentication builder.
65 /// Use this overload when registering multiple authentication schemes to avoid calling AddAuthentication() multiple times.
66 /// </summary>
67 /// <param name="builder">The existing authentication builder.</param>
68 /// <param name="clientId">The application (client) ID for token validation.</param>
69 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
70 /// <param name="schemeName">The authentication scheme name.</param>
71 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
72 /// <returns>The <see cref="AuthenticationBuilder"/> for chaining.</returns>
73 public static AuthenticationBuilder AddBotAuthentication(
74 this AuthenticationBuilder builder,
75 string clientId,
76 string tenantId = "",
77 string schemeName = BotConfig.DefaultSectionName,
78 ILogger? logger = null)
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 return services.AddBotAuthorization(botConfig.ClientId, botConfig.TenantId, botConfig.SectionName, logger);
118 }
119
120 /// <summary>
121 /// Adds authorization policies to the service collection with manually provided configuration values.
122 /// </summary>
123 /// <param name="services">The service collection to add authorization to.</param>
124 /// <param name="clientId">The application (client) ID for token validation.</param>
125 /// <param name="tenantId">The Azure AD tenant ID. Can be empty for multi-tenant scenarios.</param>
126 /// <param name="schemeName">The authentication scheme name. Defaults to "AzureAd".</param>
127 /// <param name="logger">Optional logger instance for logging. If null, a NullLogger will be used.</param>
128 /// <returns>An <see cref="AuthorizationBuilder"/> for further authorization configuration.</returns>
129 public static AuthorizationBuilder AddBotAuthorization(
130 this IServiceCollection services,
131 string clientId,
132 string tenantId = "",
133 string schemeName = BotConfig.DefaultSectionName,
134 ILogger? logger = null)
135 {
136 services.AddBotAuthentication(clientId, tenantId, schemeName, logger);
137
138 return services
139 .AddAuthorizationBuilder()
140 .AddDefaultPolicy(schemeName, policy =>
141 {
142 policy.AuthenticationSchemes.Add(schemeName);
143 policy.RequireAuthenticatedUser();
144 });
145 }
146
147 private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId)
148 {
149 // Bot Framework tokens
150 if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase))
151 return issuer;
152
153 // Entra tokens � bot-to-bot (agent) and user (tab/API)
154 // Use the token's own tid claim for multi-tenant; fall back to configured tenant
155 (_, string? tid) = GetTokenClaims(token);
156 string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId;
157
158 if (effectiveTenant is not null &&
159 (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" ||
160 issuer == $"https://sts.windows.net/{effectiveTenant}/"))
161 return issuer;
162
163 throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid.");
164 }
165
166 private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
167 token is JsonWebToken jwt
168 ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null)
169 : (null, null);
170
171 /// <summary>
172 /// Adds Teams JWT Bearer authentication that supports both Bot Framework and Entra ID tokens.
173 /// </summary>
174 /// <param name="builder">The authentication builder.</param>
175 /// <param name="schemeName">The authentication scheme name.</param>
176 /// <param name="audience">The application (client) ID used to validate the audience of tokens.</param>
177 /// <param name="tenantId">The Azure AD tenant ID.</param>
178 /// <param name="logger">Optional logger for authentication events.</param>
179 /// <returns>The authentication builder for chaining.</returns>
180 /// <remarks>
181 /// This method configures authentication to support both types of tokens:
182 /// <list type="bullet">
183 /// <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>
184 /// <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>
185 /// </list>
186 /// The signing keys for both token types are dynamically resolved at runtime using OpenID Connect discovery,
187 /// allowing the same authentication configuration to validate tokens from multiple issuers.
188 /// </remarks>
189 private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null)
190 {
191 // One ConfigurationManager per OIDC authority, shared safely across all requests.
192 ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> configManagerCache = new(StringComparer.OrdinalIgnoreCase);
193
194 // Cache resolved configurations to avoid blocking async calls on every token validation.
195 // ConfigurationManager handles background refresh internally; we cache the Task so that
196 // only the first call per authority actually blocks.
197 ConcurrentDictionary<string, Task<OpenIdConnectConfiguration>> configCache = new(StringComparer.OrdinalIgnoreCase);
198
199 builder.AddJwtBearer(schemeName, jwtOptions =>
200 {
201 jwtOptions.SaveToken = true;
202 jwtOptions.IncludeErrorDetails = true;
203 jwtOptions.TokenValidationParameters = new TokenValidationParameters
204 {
205 ValidateIssuerSigningKey = true,
206 RequireSignedTokens = true,
207 ValidateIssuer = true,
208 ValidateAudience = true,
209 ValidAudiences = [audience, $"api://{audience}"],
210 IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId),
211 IssuerSigningKeyResolver = (_, securityToken, _, _) =>
212 {
213 (string? iss, string? tid) = GetTokenClaims(securityToken);
214 if (iss is null) return [];
215
216 string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)
217 ? BotOIDC
218 : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
219
220 logger?.ResolvingSigningKeys(authority, iss);
221
222 ConfigurationManager<OpenIdConnectConfiguration> manager = configManagerCache.GetOrAdd(authority, a =>
223 new ConfigurationManager<OpenIdConnectConfiguration>(
224 a,
225 new OpenIdConnectConfigurationRetriever(),
226 new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata }));
227
228 // Cache the Task so only the first call per authority blocks;
229 // subsequent calls return the already-completed task synchronously.
230 // ConfigurationManager handles background refresh of stale configs internally.
231 Task<OpenIdConnectConfiguration> configTask = configCache.GetOrAdd(authority,
232 _ => manager.GetConfigurationAsync(CancellationToken.None));
233
234 OpenIdConnectConfiguration config = configTask.ConfigureAwait(false).GetAwaiter().GetResult();
235 return config.SigningKeys;
236 }
237 };
238 jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation();
239 jwtOptions.MapInboundClaims = true;
240 jwtOptions.Events = new JwtBearerEvents
241 {
242 OnTokenValidated = context =>
243 {
244 ILogger log = GetLogger(context.HttpContext, logger);
245 log.TokenValidated(schemeName);
246 if (log.IsEnabled(LogLevel.Trace) && context.SecurityToken is JsonWebToken jwt)
247 {
248 string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}"));
249 log.IncomingTokenClaims(claims);
250 }
251 return Task.CompletedTask;
252 },
253 OnForbidden = context =>
254 {
255 GetLogger(context.HttpContext, logger).ForbiddenForScheme(schemeName);
256 return Task.CompletedTask;
257 },
258 OnAuthenticationFailed = context =>
259 {
260 ILogger log = GetLogger(context.HttpContext, logger);
261
262 string? tokenIssuer = null;
263 string? tokenAudience = null;
264 string? tokenExpiration = null;
265 string? tokenSubject = null;
266 string authHeader = context.Request.Headers.Authorization.ToString();
267 if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
268 {
269 try
270 {
271 JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim());
272 (tokenIssuer, _) = GetTokenClaims(jwt);
273 tokenAudience = jwt.GetClaim("aud")?.Value;
274 tokenExpiration = jwt.ValidTo.ToString("o");
275 tokenSubject = jwt.Subject;
276 }
277 catch (ArgumentException) { }
278 }
279
280 TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters;
281 string expectedAudiences = validationParams?.ValidAudiences is not null
282 ? string.Join(", ", validationParams.ValidAudiences)
283 : validationParams?.ValidAudience ?? "n/a";
284 log.JwtAuthenticationFailed(
285 context.Exception,
286 schemeName,
287 context.Exception.Message,
288 tokenIssuer ?? "n/a",
289 tokenAudience ?? "n/a",
290 tokenExpiration ?? "n/a",
291 tokenSubject ?? "n/a",
292 expectedAudiences);
293
294 return Task.CompletedTask;
295 }
296 };
297 jwtOptions.Validate();
298 });
299 return builder;
300 }
301
302 private static AuthenticationBuilder AddBypassAuthentication(this AuthenticationBuilder builder, string schemeName, ILogger? logger = null)
303 {
304 (logger ?? NullLogger.Instance).BypassAuthenticationConfigured(schemeName);
305
306 builder.AddJwtBearer(schemeName, jwtOptions =>
307 {
308#pragma warning disable CA5404 // Do not disable token validation checks
309 jwtOptions.TokenValidationParameters = new TokenValidationParameters
310 {
311 ValidateIssuer = false,
312 ValidateAudience = false,
313 ValidateLifetime = false,
314 ValidateIssuerSigningKey = false,
315 RequireSignedTokens = false,
316 SignatureValidator = (token, _) => new JsonWebToken(token)
317 };
318#pragma warning restore CA5404 // Do not disable token validation checks
319 jwtOptions.Events = new JwtBearerEvents
320 {
321 OnMessageReceived = context =>
322 {
323 // Always succeed authentication even without a token
324 GetLogger(context.HttpContext, logger).BypassAuthenticationSucceeded(schemeName);
325 context.NoResult();
326 context.Principal = new System.Security.Claims.ClaimsPrincipal(
327 new System.Security.Claims.ClaimsIdentity("BypassAuth"));
328 context.Success();
329 return Task.CompletedTask;
330 }
331 };
332 });
333 return builder;
334 }
335
336 private static ILogger GetLogger(HttpContext context, ILogger? fallback) =>
337 context.RequestServices.GetService<ILoggerFactory>()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions")
338 ?? fallback
339 ?? NullLogger.Instance;
340 }
341}