Skip to content
12 changes: 12 additions & 0 deletions core/src/Microsoft.Teams.Core/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,15 @@
Justification = "Callers build interpolated URLs with query strings and Uri-escaped segments; string parameters are the natural shape for this HTTP plumbing.",
Scope = "type",
Target = "~T:Microsoft.Teams.Core.Http.BotHttpClient")]

[assembly: SuppressMessage("Design",
"CA1056:URI-like properties should not be strings",
Justification = "Mirrors Microsoft.Identity.Web's MicrosoftIdentityApplicationOptions.Instance convention; the value flows through as a string to configuration consumers.",
Scope = "member",
Target = "~P:Microsoft.Teams.Core.Hosting.BotConfig.OpenIdMetadataUrl")]

[assembly: SuppressMessage("Design",
"CA1056:URI-like properties should not be strings",
Justification = "Mirrors Microsoft.Identity.Web's MicrosoftIdentityApplicationOptions.Instance convention; the value flows through as a string to configuration consumers.",
Scope = "member",
Target = "~P:Microsoft.Teams.Core.Hosting.BotConfig.EntraInstance")]
53 changes: 53 additions & 0 deletions core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ public sealed class BotConfig
{
internal const string DefaultSectionName = "AzureAd";

internal const string BotFrameworkSectionName = "BotFramework";

internal const string DefaultOpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openid-configuration";

internal const string DefaultEntraInstance = "https://login.microsoftonline.com/";

internal const string DefaultBotTokenIssuer = "https://api.botframework.com";

/// <summary>
/// Gets or sets the Azure AD tenant ID.
/// </summary>
Expand All @@ -31,6 +39,32 @@ public sealed class BotConfig
/// </summary>
public string SectionName { get; set; } = DefaultSectionName;

/// <summary>
/// Gets or sets the Bot Framework OpenID metadata URL used to fetch signing keys
/// for validating inbound Bot Framework tokens. For sovereign clouds, set
/// <c>BotFramework:OpenIdMetadataUrl</c> in configuration, e.g.
/// <c>"https://login.botframework.azure.us/v1/.well-known/openid-configuration"</c> for USGov.
/// Defaults to the public-cloud endpoint when not configured.
/// </summary>
public string OpenIdMetadataUrl { get; set; } = DefaultOpenIdMetadataUrl;

/// <summary>
/// Gets or sets the Entra login instance used when validating Entra-issued tokens.
/// For sovereign clouds, set <c>{SectionName}:Instance</c> in configuration
/// (the standard Microsoft.Identity.Web key), e.g.
/// <c>"https://login.microsoftonline.us/"</c> for USGov.
/// Defaults to the public-cloud instance when not configured.
/// </summary>
public string EntraInstance { get; set; } = DefaultEntraInstance;

/// <summary>
/// Gets or sets the expected Bot Framework token issuer used to validate inbound
/// Bot Framework tokens. For sovereign clouds, set <c>BotFramework:BotTokenIssuer</c>
/// in configuration, e.g. <c>"https://api.botframework.us"</c> for USGov.
/// Defaults to the public-cloud issuer when not configured.
/// </summary>
public string BotTokenIssuer { get; set; } = DefaultBotTokenIssuer;
Comment thread
corinagum marked this conversation as resolved.

internal IConfigurationSection? MsalConfigurationSection { get; set; }

/// <summary>
Expand Down Expand Up @@ -71,10 +105,14 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName
ILogger logger = AddBotApplicationExtensions.GetLoggerFromServices(services, typeof(BotConfig));

IConfigurationSection section = configuration.GetSection(sectionName);
IConfigurationSection botFrameworkSection = configuration.GetSection(BotFrameworkSectionName);
BotConfig config = new()
{
TenantId = section["TenantId"] ?? string.Empty,
ClientId = section["ClientId"] ?? string.Empty,
EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance, sectionName),
OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl, BotFrameworkSectionName),
BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer, BotFrameworkSectionName),
MsalConfigurationSection = section,
SectionName = sectionName
};
Expand All @@ -90,6 +128,21 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName
return config;
}

private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue, string sectionName)
Comment thread
corinagum marked this conversation as resolved.
Outdated
Comment thread
corinagum marked this conversation as resolved.
Outdated
{
string? value = section[key];
if (value is null)
{
return defaultValue;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
{
throw new InvalidOperationException(
$"Configuration value '{sectionName}:{key}' is not a valid absolute URI: '{value}'.");
}
return value;
}

private static readonly Action<ILogger, string, Exception?> _logUsingSectionConfig =
LoggerMessage.Define<string>(LogLevel.Debug, new(3), "Resolved bot configuration from '{SectionName}' configuration section");
private static readonly Action<ILogger, Exception?> _logNoConfigFound =
Expand Down
55 changes: 43 additions & 12 deletions core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand All @@ -23,9 +24,6 @@ namespace Microsoft.Teams.Core.Hosting
/// </summary>
public static class JwtExtensions
{
internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration";
internal const string EntraOIDC = "https://login.microsoftonline.com/";

/// <summary>
/// Adds JWT authentication for bots and agents using configuration from appsettings.
/// </summary>
Expand Down Expand Up @@ -77,6 +75,8 @@ public static AuthenticationBuilder AddBotAuthentication(
string schemeName = BotConfig.DefaultSectionName,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(builder);

if (string.IsNullOrWhiteSpace(clientId))
{
builder.AddBypassAuthentication(schemeName, logger);
Expand Down Expand Up @@ -144,25 +144,44 @@ public static AuthorizationBuilder AddBotAuthorization(
});
}

private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId)
internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, string entraInstance, string botTokenIssuer)
{
// Bot Framework tokens
if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase))
// 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))
Comment thread
corinagum marked this conversation as resolved.
{
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
// 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 == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" ||
(issuer == $"{entraInstance}{effectiveTenant}/v2.0" ||
issuer == $"https://sts.windows.net/{effectiveTenant}/"))
return issuer;

throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid.");
}

/// <summary>
/// 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.
/// </summary>
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)
Expand All @@ -188,6 +207,20 @@ token is JsonWebToken jwt
/// </remarks>
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;
}

// One ConfigurationManager per OIDC authority, shared safely across all requests.
ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> configManagerCache = new(StringComparer.OrdinalIgnoreCase);

Expand All @@ -207,15 +240,13 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde
ValidateIssuer = true,
ValidateAudience = true,
ValidAudiences = [audience, $"api://{audience}"],
IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId),
IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance, botTokenIssuer),
IssuerSigningKeyResolver = (_, securityToken, _, _) =>
{
(string? iss, string? tid) = GetTokenClaims(securityToken);
if (iss is null) return [];

string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)
? BotOIDC
: $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
string authority = ResolveSigningAuthority(iss, tid, botTokenIssuer, botOidcUrl, entraInstance);

logger?.ResolvingSigningKeys(authority, iss);

Expand Down
Loading
Loading