Skip to content
38 changes: 38 additions & 0 deletions core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand All @@ -15,6 +16,12 @@ public sealed class BotConfig
{
internal const string DefaultSectionName = "AzureAd";

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 +38,34 @@ 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>{SectionName}: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>
[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.")]
Comment thread
corinagum marked this conversation as resolved.
Outdated
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>
[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.")]
Comment thread
rido-min marked this conversation as resolved.
Outdated
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>{SectionName}: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 @@ -75,6 +110,9 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName
{
TenantId = section["TenantId"] ?? string.Empty,
ClientId = section["ClientId"] ?? string.Empty,
OpenIdMetadataUrl = section["OpenIdMetadataUrl"] ?? DefaultOpenIdMetadataUrl,
Comment thread
corinagum marked this conversation as resolved.
Outdated
EntraInstance = section["Instance"] ?? DefaultEntraInstance,
BotTokenIssuer = section["BotTokenIssuer"] ?? DefaultBotTokenIssuer,
MsalConfigurationSection = section,
SectionName = sectionName
};
Expand Down
53 changes: 41 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,42 @@ 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 +205,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 +238,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
130 changes: 130 additions & 0 deletions core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Teams.Core.Hosting;

namespace Microsoft.Teams.Core.UnitTests.Hosting;

public class BotConfigTests
{
private static ServiceCollection BuildServices(Dictionary<string, string?> configData)
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();

ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging();
return services;
}

[Fact]
public void Resolve_OpenIdMetadataUrl_DefaultsToPublicCloud_WhenNotConfigured()
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal("https://login.botframework.com/v1/.well-known/openid-configuration", config.OpenIdMetadataUrl);
}

[Fact]
public void Resolve_EntraInstance_DefaultsToPublicCloud_WhenNotConfigured()
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal("https://login.microsoftonline.com/", config.EntraInstance);
}

[Theory]
[InlineData("https://login.botframework.azure.us/v1/.well-known/openid-configuration")]
[InlineData("https://login.botframework.azure.cn/v1/.well-known/openid-configuration")]
public void Resolve_OpenIdMetadataUrl_HonorsAzureAdOverride(string configured)
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
["AzureAd:OpenIdMetadataUrl"] = configured,
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal(configured, config.OpenIdMetadataUrl);
}

[Theory]
[InlineData("https://login.microsoftonline.us/")]
[InlineData("https://login.partner.microsoftonline.cn/")]
public void Resolve_EntraInstance_HonorsAzureAdInstanceOverride(string configured)
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
["AzureAd:Instance"] = configured,
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal(configured, config.EntraInstance);
}

[Fact]
public void Resolve_BotTokenIssuer_DefaultsToPublicCloud_WhenNotConfigured()
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal("https://api.botframework.com", config.BotTokenIssuer);
}

[Theory]
[InlineData("https://api.botframework.us")]
[InlineData("https://api.botframework.azure.cn")]
public void Resolve_BotTokenIssuer_HonorsAzureAdOverride(string configured)
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["AzureAd:ClientId"] = "client-id",
["AzureAd:TenantId"] = "tenant-id",
["AzureAd:BotTokenIssuer"] = configured,
});

BotConfig config = BotConfig.Resolve(services);

Assert.Equal(configured, config.BotTokenIssuer);
}

[Fact]
public void Resolve_OpenIdMetadataUrl_ReadsFromCustomSection_WhenSectionNameProvided()
{
ServiceCollection services = BuildServices(new Dictionary<string, string?>
{
["CustomAuth:ClientId"] = "client-id",
["CustomAuth:OpenIdMetadataUrl"] = "https://login.botframework.azure.us/v1/.well-known/openid-configuration",
});

BotConfig config = BotConfig.Resolve(services, "CustomAuth");

Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openid-configuration", config.OpenIdMetadataUrl);
}
}
Loading
Loading