microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs
334lines · 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.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 | |