// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Concurrent; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Validators; namespace Microsoft.Teams.Core.Hosting { /// /// Provides extension methods for configuring JWT authentication and authorization for bots and agents. /// public static class JwtExtensions { /// /// Adds JWT authentication for bots and agents using configuration from appsettings. /// /// The service collection to add authentication to. /// The configuration section name for the settings. Defaults to "AzureAd". /// The logger instance for logging. /// An for further authentication configuration. public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null) { BotConfig botConfig = BotConfig.Resolve(services, aadSectionName); return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger); } /// /// Adds JWT authentication for bots and agents with manually provided configuration values. /// /// The service collection to add authentication to. /// The application (client) ID for token validation. /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authentication configuration. public static AuthenticationBuilder AddBotAuthentication( this IServiceCollection services, string clientId, string tenantId = "", string schemeName = BotConfig.DefaultSectionName, ILogger? logger = null) { AuthenticationBuilder builder = services.AddAuthentication(); builder.AddBotAuthentication(clientId, tenantId, schemeName, logger); return builder; } /// /// Adds JWT authentication for bots and agents to an existing authentication builder. /// Use this overload when registering multiple authentication schemes to avoid calling AddAuthentication() multiple times. /// /// The existing authentication builder. /// The application (client) ID for token validation. /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. /// Optional logger instance for logging. If null, a NullLogger will be used. /// The for chaining. public static AuthenticationBuilder AddBotAuthentication( this AuthenticationBuilder builder, string clientId, string tenantId = "", string schemeName = BotConfig.DefaultSectionName, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(builder); if (string.IsNullOrWhiteSpace(clientId)) { builder.AddBypassAuthentication(schemeName, logger); } else { builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, logger); } return builder; } /// /// Adds authorization policies to the service collection using configuration from appsettings. /// /// The service collection to add authorization to. /// The configuration section name for the settings. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. public static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null) { logger ??= NullLogger.Instance; BotConfig botConfig = BotConfig.Resolve(services, aadSectionName); return services.AddBotAuthorization(botConfig, logger); } /// /// Adds authorization policies to the service collection using configuration from appsettings. /// /// The service collection to add authorization to. /// The bot configuration settings. /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection services, BotConfig botConfig, ILogger? logger = null) { logger ??= NullLogger.Instance; // Call AddTeamsJwtBearer with the already-resolved BotConfig to avoid a redundant // BotConfig.Resolve call (and duplicate startup log) that would occur through the // public string-based AddBotAuthentication → AddTeamsJwtBearer chain. AuthenticationBuilder authBuilder = services.AddAuthentication(); if (string.IsNullOrWhiteSpace(botConfig.ClientId)) { authBuilder.AddBypassAuthentication(botConfig.SectionName, logger); } else { authBuilder.AddTeamsJwtBearer(botConfig, logger); } return services .AddAuthorizationBuilder() .AddDefaultPolicy(botConfig.SectionName, policy => { policy.AuthenticationSchemes.Add(botConfig.SectionName); policy.RequireAuthenticatedUser(); }); } /// /// Adds authorization policies to the service collection with manually provided configuration values. /// /// The service collection to add authorization to. /// The application (client) ID for token validation. /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. public static AuthorizationBuilder AddBotAuthorization( this IServiceCollection services, string clientId, string tenantId = "", string schemeName = BotConfig.DefaultSectionName, ILogger? logger = null) { services.AddBotAuthentication(clientId, tenantId, schemeName, logger); return services .AddAuthorizationBuilder() .AddDefaultPolicy(schemeName, policy => { policy.AuthenticationSchemes.Add(schemeName); policy.RequireAuthenticatedUser(); }); } internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, string entraInstance, string botTokenIssuer) { // Bot Framework tokens. The expected issuer varies by sovereign cloud // (e.g. https://api.botframework.us for USGov) so it comes from configuration. if (issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase)) { return issuer; } // Entra tokens - bot-to-bot (agent) and user (tab/API) // Use the token's own tid claim for multi-tenant; fall back to configured tenant. // The v2.0 expected issuer is derived from the configured Entra instance so sovereign // tokens (e.g. login.microsoftonline.us) validate correctly. (_, string? tid) = GetTokenClaims(token); string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId; if (effectiveTenant is not null && (issuer == $"{entraInstance}{effectiveTenant}/v2.0" || issuer == $"https://sts.windows.net/{effectiveTenant}/")) { return issuer; } throw new SecurityTokenInvalidIssuerException( $"Issuer '{issuer}' is not valid for tenant '{effectiveTenant ?? ""}'."); } /// /// Picks the OIDC metadata authority to fetch signing keys from based on the token's /// issuer claim. Tokens issued by the configured Bot Framework issuer (e.g. the public /// "https://api.botframework.com" or a sovereign equivalent like "https://api.botframework.us") /// resolve to the configured Bot OIDC URL; all others fall through to the Entra tenant authority. /// internal static string ResolveSigningAuthority(string? iss, string? tid, string botTokenIssuer, string botOidcUrl, string entraInstance) { if (iss is null) return string.Empty; return iss.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase) ? botOidcUrl : $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; } private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => token is JsonWebToken jwt ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null) : (null, null); /// /// Overload that accepts an already-resolved to avoid a redundant /// call during internal registration paths. /// private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, BotConfig botConfig, ILogger? logger = null) { return builder.AddTeamsJwtBearer( botConfig.SectionName, botConfig.ClientId, botConfig.TenantId, botConfig.OpenIdMetadataUrl, botConfig.EntraInstance, botConfig.BotTokenIssuer, logger); } private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null) { // Resolve sovereign-cloud-aware URLs from the same AzureAd section that produced clientId/tenantId. // Defaults to the public-cloud values when IConfiguration is not registered (manual-credentials callers) // or when the section is missing or doesn't override them. string botOidcUrl = BotConfig.DefaultOpenIdMetadataUrl; string entraInstance = BotConfig.DefaultEntraInstance; string botTokenIssuer = BotConfig.DefaultBotTokenIssuer; if (builder.Services.Any(d => d.ServiceType == typeof(IConfiguration))) { BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName); botOidcUrl = botConfig.OpenIdMetadataUrl; entraInstance = botConfig.EntraInstance; botTokenIssuer = botConfig.BotTokenIssuer; } return builder.AddTeamsJwtBearer(schemeName, audience, tenantId, botOidcUrl, entraInstance, botTokenIssuer, logger); } private static AuthenticationBuilder AddTeamsJwtBearer( this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, string botOidcUrl, string entraInstance, string botTokenIssuer, ILogger? logger) { // One ConfigurationManager per OIDC authority, shared safely across all requests. ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); // Cache resolved configurations to avoid blocking async calls on every token validation. // ConfigurationManager handles background refresh internally; we cache the Task so that // only the first call per authority actually blocks. ConcurrentDictionary> configCache = new(StringComparer.OrdinalIgnoreCase); builder.AddJwtBearer(schemeName, jwtOptions => { jwtOptions.SaveToken = true; jwtOptions.IncludeErrorDetails = true; jwtOptions.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, RequireSignedTokens = true, ValidateIssuer = true, ValidateAudience = true, ValidAudiences = [audience, $"api://{audience}"], IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance, botTokenIssuer), IssuerSigningKeyResolver = (_, securityToken, _, _) => { (string? iss, string? tid) = GetTokenClaims(securityToken); if (iss is null) return []; string authority = ResolveSigningAuthority(iss, tid, botTokenIssuer, botOidcUrl, entraInstance); logger?.ResolvingSigningKeys(authority, iss); ConfigurationManager manager = configManagerCache.GetOrAdd(authority, a => new ConfigurationManager( a, new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata })); // Cache the Task so only the first call per authority blocks; // subsequent calls return the already-completed task synchronously. // ConfigurationManager handles background refresh of stale configs internally. Task configTask = configCache.GetOrAdd(authority, _ => manager.GetConfigurationAsync(CancellationToken.None)); OpenIdConnectConfiguration config = configTask.ConfigureAwait(false).GetAwaiter().GetResult(); return config.SigningKeys; } }; jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); jwtOptions.MapInboundClaims = true; jwtOptions.Events = new JwtBearerEvents { OnTokenValidated = context => { ILogger log = GetLogger(context.HttpContext, logger); log.TokenValidated(schemeName); if (log.IsEnabled(LogLevel.Trace) && context.SecurityToken is JsonWebToken jwt) { string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}")); log.IncomingTokenClaims(claims); } return Task.CompletedTask; }, OnForbidden = context => { GetLogger(context.HttpContext, logger).ForbiddenForScheme(schemeName); return Task.CompletedTask; }, OnAuthenticationFailed = context => { ILogger log = GetLogger(context.HttpContext, logger); string? tokenIssuer = null; string? tokenAudience = null; string? tokenExpiration = null; string? tokenSubject = null; string authHeader = context.Request.Headers.Authorization.ToString(); if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { try { JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim()); (tokenIssuer, _) = GetTokenClaims(jwt); tokenAudience = jwt.GetClaim("aud")?.Value; tokenExpiration = jwt.ValidTo.ToString("o"); tokenSubject = jwt.Subject; } catch (ArgumentException) { } } TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters; string expectedAudiences = validationParams?.ValidAudiences is not null ? string.Join(", ", validationParams.ValidAudiences) : validationParams?.ValidAudience ?? "n/a"; log.JwtAuthenticationFailed( context.Exception, schemeName, context.Exception.Message, tokenIssuer ?? "n/a", tokenAudience ?? "n/a", tokenExpiration ?? "n/a", tokenSubject ?? "n/a", expectedAudiences); return Task.CompletedTask; } }; jwtOptions.Validate(); }); return builder; } private static AuthenticationBuilder AddBypassAuthentication(this AuthenticationBuilder builder, string schemeName, ILogger? logger = null) { (logger ?? NullLogger.Instance).BypassAuthenticationConfigured(schemeName); builder.AddJwtBearer(schemeName, jwtOptions => { #pragma warning disable CA5404 // Do not disable token validation checks jwtOptions.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = false, ValidateIssuerSigningKey = false, RequireSignedTokens = false, SignatureValidator = (token, _) => new JsonWebToken(token) }; #pragma warning restore CA5404 // Do not disable token validation checks jwtOptions.Events = new JwtBearerEvents { OnMessageReceived = context => { // Always succeed authentication even without a token GetLogger(context.HttpContext, logger).BypassAuthenticationSucceeded(schemeName); context.NoResult(); context.Principal = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity("BypassAuth")); context.Success(); return Task.CompletedTask; } }; }); return builder; } private static ILogger GetLogger(HttpContext context, ILogger? fallback) => context.RequestServices.GetService()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") ?? fallback ?? NullLogger.Instance; } }