microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
406lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Collections.Concurrent; |
| 5 | using System.Security.Claims; |
| 6 | using Microsoft.AspNetCore.Authentication; |
| 7 | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| 8 | using Microsoft.AspNetCore.Authorization; |
| 9 | using Microsoft.AspNetCore.Http; |
| 10 | using Microsoft.Extensions.Configuration; |
| 11 | using Microsoft.Extensions.DependencyInjection; |
| 12 | using Microsoft.Extensions.Logging; |
| 13 | using Microsoft.Extensions.Logging.Abstractions; |
| 14 | using Microsoft.IdentityModel.JsonWebTokens; |
| 15 | using Microsoft.IdentityModel.Protocols; |
| 16 | using Microsoft.IdentityModel.Protocols.OpenIdConnect; |
| 17 | using Microsoft.IdentityModel.Tokens; |
| 18 | using Microsoft.IdentityModel.Validators; |
| 19 | |
| 20 | namespace 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 | |