Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Libraries/Microsoft.Teams.Apps/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ private async Task<Response> Process(ISenderPlugin sender, ActivityEvent @event,
{
AppId = @event.Token.AppId ?? Id ?? string.Empty,
TenantId = @event.Token.TenantId ?? string.Empty,
Cloud = _cloud,
Log = Logger.Child(path),
Storage = Storage,
Api = api,
Expand Down
7 changes: 7 additions & 0 deletions Libraries/Microsoft.Teams.Apps/Contexts/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public partial interface IContext<TActivity> where TActivity : IActivity
/// </summary>
public string TenantId { get; set; }

/// <summary>
/// the cloud environment configured on the app; drives per-cloud endpoint routing
/// </summary>
public CloudEnvironment Cloud { get; set; }

/// <summary>
/// the app logger instance
/// </summary>
Expand Down Expand Up @@ -130,6 +135,7 @@ public partial class Context<TActivity>(ISenderPlugin sender, IStreamer stream)

public required string AppId { get; set; }
public required string TenantId { get; set; }
public required CloudEnvironment Cloud { get; set; }
public required ILogger Log { get; set; }
public required IStorage<string, object> Storage { get; set; }
public required ApiClient Api { get; set; }
Expand Down Expand Up @@ -176,6 +182,7 @@ public IContext<TToActivity> ToActivityType<TToActivity>() where TToActivity : I
Sender = Sender,
AppId = AppId,
TenantId = TenantId,
Cloud = Cloud,
Log = Log,
Storage = Storage,
Api = Api,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
using Microsoft.Graph;
using System.Text.RegularExpressions;

using Microsoft.Graph;
using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Apps;

namespace Microsoft.Teams.Extensions.Graph;

public static class ContextExtensions
{
// Extracts scheme + host (+ optional port) from a URL-like scope such as
// "https://graph.microsoft.us/.default" -> "https://graph.microsoft.us".
private static readonly Regex _graphBaseUrlRegex = new(@"^(https?://[^/]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);

/// <summary>
/// Get user's graph client from the context.
/// </summary>
Expand All @@ -32,7 +38,27 @@ public static GraphServiceClient GetUserGraphClient<TActivity>(this IContext<TAc
return new Azure.Core.AccessToken(userToken.ToString(), userToken.Token.ValidTo);
});

graphClient = new GraphServiceClient(userGraphTokenProvider);
// Derive per-cloud Graph base URL from the configured cloud's graphScope.
// Falls back to the public Graph endpoint if the scope isn't a URL.
var graphScope = context.Cloud?.GraphScope?.Trim();
string? baseUrl = null;
if (!string.IsNullOrEmpty(graphScope))
{
var match = _graphBaseUrlRegex.Match(graphScope);
if (match.Success)
{
baseUrl = match.Groups[1].Value;
}
else
{
context.Log.Warn($"graphScope \"{graphScope}\" is not a URL; Graph calls will route to the public cloud. " +
"Set graphScope to an \"https://<host>/.default\" value to route to the correct Graph endpoint.");
}
}

graphClient = baseUrl is null
? new GraphServiceClient(userGraphTokenProvider)
: new GraphServiceClient(userGraphTokenProvider, scopes: null, baseUrl: baseUrl);
context.Extra["UserGraphClient"] = graphClient;

return graphClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Teams.Api.Activities;
using Microsoft.Teams.Api.Auth;
using Microsoft.Teams.Apps;
using Microsoft.Teams.Common.Logging;

using Moq;

Expand Down Expand Up @@ -59,4 +60,67 @@ public void ContextExtensions_GetUserGraphClient_ShouldReturnSingleGraphClient()
Assert.NotNull(graphClient);
Assert.Equal(graphClientMock, graphClient);
}

// --- Sovereign cloud Graph routing ---

private static Mock<IContext<IActivity>> MockContextWith(CloudEnvironment? cloud, Mock<ILogger>? logger = null)
{
var token = "eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTc1MzI1MjAzNSwiaWF0IjoxNzUzMjUyMDM1fQ.J-DWberQuMBSnAECP0jmK-zX6BzB4o-rMEshkR0mN-A";
var jwtToken = new JsonWebToken(token);
var context = new Mock<IContext<IActivity>>();
context.Setup(c => c.UserGraphToken).Returns(jwtToken);
context.Setup(c => c.Extra).Returns(new Dictionary<string, object?>());
context.Setup(c => c.Cloud).Returns(cloud!);
context.Setup(c => c.Log).Returns((logger ?? new Mock<ILogger>()).Object);
return context;
}

[Theory]
[InlineData("USGov")]
[InlineData("USGovDoD")]
[InlineData("China")]
[InlineData("Public")]
public void GetUserGraphClient_RoutesToCloudSpecificBaseUrl(string cloudName)
{
var cloud = CloudEnvironment.FromName(cloudName);
var expectedBaseUrl = cloud.GraphScope.Replace("/.default", string.Empty);
var context = MockContextWith(cloud);

var graphClient = context.Object.GetUserGraphClient();

Assert.NotNull(graphClient);
// Full equality rather than StartsWith: avoids the incomplete-URL-substring-sanitization
// smell (a prefix check could match "<expected>.evil.com").
Assert.Equal(expectedBaseUrl, graphClient.RequestAdapter.BaseUrl, ignoreCase: true);
}

[Fact]
public void GetUserGraphClient_NullCloud_UsesPublicDefault_NoWarning()
{
var logger = new Mock<ILogger>();
var context = MockContextWith(cloud: null, logger: logger);

var graphClient = context.Object.GetUserGraphClient();

Assert.NotNull(graphClient);
logger.Verify(l => l.Warn(It.IsAny<object?[]>()), Times.Never);
}

[Fact]
public void GetUserGraphClient_NonUrlGraphScope_LogsWarningAndFallsBack()
{
var cloud = CloudEnvironment.Public.WithOverrides(graphScope: "user.read");
var logger = new Mock<ILogger>();
var context = MockContextWith(cloud, logger);

var graphClient = context.Object.GetUserGraphClient();

Assert.NotNull(graphClient);
// Fallback: public base URL used (because scope didn't parse as URL)
// Fallback path uses the parameterless GraphServiceClient ctor, which the Microsoft.Graph
// SDK initializes with "https://graph.microsoft.com/v1.0" (adds /v1.0, unlike the
// explicit-baseUrl overload which stores the value verbatim).
Assert.Equal("https://graph.microsoft.com/v1.0", graphClient.RequestAdapter.BaseUrl, ignoreCase: true);
logger.Verify(l => l.Warn(It.IsAny<object?[]>()), Times.Once);
}
}
12 changes: 10 additions & 2 deletions core/samples/PABot/SimpleGraphClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,25 @@ namespace PABot
public class SimpleGraphClient
{
private readonly string _token;
private readonly string? _baseUrl;

/// <summary>
/// Initializes a new instance of the <see cref="SimpleGraphClient"/> class.
/// </summary>
/// <param name="token">The token issued to the user.</param>
public SimpleGraphClient(string token)
/// <param name="baseUrl">
/// Optional Graph API base URL override for sovereign clouds
/// (e.g. "https://graph.microsoft.us" for GCCH). When null, the public Graph endpoint is used.
/// </param>
public SimpleGraphClient(string token, string? baseUrl = null)
{
if (string.IsNullOrWhiteSpace(token))
{
throw new ArgumentNullException(nameof(token));
}

_token = token;
_baseUrl = baseUrl;
}

/// <summary>
Expand Down Expand Up @@ -139,7 +145,9 @@ private GraphServiceClient GetAuthenticatedClient()

BaseBearerTokenAuthenticationProvider authProvider = new(tokenProvider);

return new GraphServiceClient(authProvider);
return _baseUrl is null
? new GraphServiceClient(authProvider)
: new GraphServiceClient(authProvider, _baseUrl);
}

public class SimpleAccessTokenProvider : IAccessTokenProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,10 @@ public static IServiceCollection AddBotApplication<TApp>(this IServiceCollection
/// <returns>The service collection for method chaining.</returns>
internal static IServiceCollection AddBotApplication<TApp>(this IServiceCollection services, BotConfig botConfig) where TApp : BotApplication
{
services.AddSingleton<BotApplicationOptions>(sp =>
services.AddSingleton<BotApplicationOptions>(_ => new BotApplicationOptions
{
IConfiguration config = sp.GetRequiredService<IConfiguration>();
return new BotApplicationOptions
{
AppId = botConfig.ClientId
};
AppId = botConfig.ClientId,
Cloud = botConfig.Cloud
});
services.AddHttpContextAccessor();
services.AddBotAuthorization(botConfig);
Expand Down Expand Up @@ -168,6 +165,7 @@ internal static IServiceCollection AddBotClient<TClient>(
{
options.Scope = botConfig.Scope;
options.SectionName = botConfig.SectionName;
options.Cloud = botConfig.Cloud;
});

// TODO: This shouldn't be called multiple times. It will being called once for each client we support.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ public sealed class BotApplicationOptions
/// Defaults to 5 minutes. Set to <see cref="Timeout.InfiniteTimeSpan"/> to disable the timeout.
/// </summary>
public TimeSpan ProcessActivityTimeout { get; set; } = TimeSpan.FromMinutes(5);

/// <summary>
/// Gets or sets the cloud environment. Defaults to <see cref="CloudEnvironment.Public"/>.
/// </summary>
public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ internal sealed class BotClientOptions
/// <summary>
/// Gets or sets the scope for bot authentication.
/// </summary>
public string Scope { get; set; } = "https://api.botframework.com/.default";
public string Scope { get; set; } = CloudEnvironment.Public.BotScope;

/// <summary>
/// Gets or sets the configuration section name.
/// </summary>
public string SectionName { get; set; } = "AzureAd";

/// <summary>
/// Gets or sets the resolved cloud environment. Defaults to <see cref="CloudEnvironment.Public"/>.
/// </summary>
public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public;
}
45 changes: 38 additions & 7 deletions core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ internal sealed class BotConfig
/// </summary>
public const string SystemManagedIdentityIdentifier = "system";

private const string BotScope = "https://api.botframework.com/.default";

private const string DefaultSectionName = "AzureAd";

/// <summary>
Expand Down Expand Up @@ -56,11 +54,17 @@ internal sealed class BotConfig
/// </summary>
public string SectionName { get; set; } = DefaultSectionName;

/// <summary>
/// Gets or sets the cloud environment for sovereign cloud support.
/// Defaults to <see cref="CloudEnvironment.Public"/> if not specified.
/// </summary>
public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public;

/// <summary>
/// Gets or sets the scope for token acquisition.
/// Defaults to "https://api.botframework.com/.default" if not specified.
/// Defaults to the cloud environment's <see cref="CloudEnvironment.BotScope"/>.
/// </summary>
public string Scope { get; set; } = BotScope;
public string Scope { get; set; } = CloudEnvironment.Public.BotScope;

internal IConfigurationSection? MsalConfigurationSection { get; set; }

Expand All @@ -73,12 +77,14 @@ internal sealed class BotConfig
public static BotConfig FromBFConfig(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var cloud = ResolveCloud(configuration["Cloud"], key => configuration[key]);
return new()
{
TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty,
ClientId = configuration["MicrosoftAppId"] ?? string.Empty,
ClientSecret = configuration["MicrosoftAppPassword"],
Scope = configuration["Scope"] ?? BotScope
Cloud = cloud,
Scope = configuration["Scope"] ?? cloud.BotScope
};
}

Expand All @@ -95,13 +101,15 @@ public static BotConfig FromBFConfig(IConfiguration configuration)
public static BotConfig FromCoreConfig(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var cloud = ResolveCloud(configuration["CLOUD"] ?? configuration["Cloud"], key => configuration[key]);
return new()
{
TenantId = configuration["TENANT_ID"] ?? string.Empty,
ClientId = configuration["CLIENT_ID"] ?? string.Empty,
ClientSecret = configuration["CLIENT_SECRET"],
FicClientId = configuration["MANAGED_IDENTITY_CLIENT_ID"],
Scope = configuration["Scope"] ?? BotScope,
Cloud = cloud,
Scope = configuration["Scope"] ?? cloud.BotScope,
};
}

Expand All @@ -120,12 +128,14 @@ public static BotConfig FromMsalConfig(IConfiguration configuration, string sect
{
ArgumentNullException.ThrowIfNull(configuration);
IConfigurationSection section = configuration.GetSection(sectionName);
var cloud = ResolveCloud(section["Cloud"] ?? configuration["Cloud"], key => section[key] ?? configuration[key]);
return new()
{
TenantId = section["TenantId"] ?? string.Empty,
ClientId = section["ClientId"] ?? string.Empty,
ClientSecret = section["ClientSecret"],
Scope = section["Scope"] ?? BotScope,
Cloud = cloud,
Scope = section["Scope"] ?? cloud.BotScope,
MsalConfigurationSection = section,
SectionName = sectionName
};
Expand Down Expand Up @@ -195,6 +205,27 @@ public static BotConfig Resolve(IConfiguration configuration, string sectionName
return new BotConfig { SectionName = sectionName };
}

/// <summary>
/// Resolves a base cloud from <paramref name="cloudName"/> (or Public if unset) and applies any
/// per-endpoint overrides read via <paramref name="readOverride"/>.
/// Override keys: LoginEndpoint, LoginTenant, BotScope, TokenServiceUrl, OpenIdMetadataUrl, TokenIssuer, GraphScope.
/// </summary>
private static CloudEnvironment ResolveCloud(string? cloudName, Func<string, string?> readOverride)
{
var baseCloud = string.IsNullOrWhiteSpace(cloudName)
? CloudEnvironment.Public
: CloudEnvironment.FromName(cloudName);

return baseCloud.WithOverrides(
loginEndpoint: readOverride("LoginEndpoint"),
loginTenant: readOverride("LoginTenant"),
botScope: readOverride("BotScope"),
tokenServiceUrl: readOverride("TokenServiceUrl"),
openIdMetadataUrl: readOverride("OpenIdMetadataUrl"),
tokenIssuer: readOverride("TokenIssuer"),
graphScope: readOverride("GraphScope"));
}

private static readonly Action<ILogger, Exception?> _logUsingBFConfig =
LoggerMessage.Define(LogLevel.Debug, new(1), "Resolved bot configuration from Bot Framework configuration keys");
private static readonly Action<ILogger, Exception?> _logUsingCoreConfig =
Expand Down
Loading
Loading