From 5b3df2e1da54ffbb78a31ddff8a285ecf8a511f5 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Fri, 16 Oct 2026 16:52:03 -0400 Subject: [PATCH] Add Agentic User support to Socket Mode Mirrors microsoft/teams.ts#928 and microsoft/teams.py#626: opt-in agentic options, agent-instance negotiate token, and inbound botKey/recipient validation before dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/README.md | 28 +++- .../SocketMode/SocketModeEnvelope.cs | 86 ++++++++++ .../SocketMode/SocketModeNegotiator.cs | 4 +- .../SocketMode/SocketModeOptions.cs | 59 +++++++ .../SocketMode/SocketModeProtocolModels.cs | 6 + .../SocketModeServiceRegistration.cs | 24 ++- .../SocketMode/SocketModeTransport.cs | 21 +++ .../Hosting/BotTokenProvider.cs | 28 ++++ .../SocketMode/SocketModeAgenticTests.cs | 152 ++++++++++++++++++ .../SocketMode/SocketModeHostingTests.cs | 137 ++++++++++++++++ .../SocketMode/SocketModeTransportTests.cs | 62 ++++++- .../BotTokenProviderAgenticAppTests.cs | 89 ++++++++++ 12 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeAgenticTests.cs create mode 100644 test/Microsoft.Teams.Core.UnitTests/Hosting/BotTokenProviderAgenticAppTests.cs diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 1cad43d7..bb36ee89 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -164,8 +164,8 @@ host.Run(); transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. - **Public cloud only** — Registration fails for bots configured for another cloud. -- **Classic bot identity only** — The connection is negotiated with the bot's app ID and credentials. Agentic - identities are not supported. +- **Bot or agent instance identity** — By default the connection is negotiated with the bot's app ID and + credentials. See [Agentic Socket Mode](#agentic-socket-mode) to negotiate as an agent instance instead. - **Canary and Pilot1 endpoints** — Socket Mode is currently available only on the Canary and Pilot1 rings. Set `NegotiateBaseUrl` to `https://canary.botapi.skype.com` or `https://pilot1.botapi.skype.com` until it is enabled on the default production endpoint. @@ -178,6 +178,30 @@ host.Run(); timeouts. `UseSocketMode(bool)` turns Socket Mode on with the defaults, or off with `false`, which clears any earlier configuration so the bot uses HTTP. +### Agentic Socket Mode + +To receive activities for an agent, set `AgenticAppId` to the agent instance ID and `AgenticTokenScope` to the +negotiate token scope agreed with the service. The configured `ClientId` remains the agent blueprint ID, and +negotiation (including reconnects and token refreshes) uses an app-only token for the agent instance: + +```csharp +builder.Services.AddTeamsBotApplication(options => options.UseSocketMode(socket => +{ + socket.AgenticAppId = ""; + socket.AgenticTokenScope = ""; + socket.AgenticTenantId = ""; // Optional; defaults to the credential tenant. +})); +``` + +- **Explicit scope** — No default audience is assumed. The host fails to start if `AgenticTokenScope` is missing, + or if `AgenticTokenScope` or `AgenticTenantId` is set without `AgenticAppId`. +- **Recipient validation** — An inbound activity is rejected with HTTP 400 before it reaches a handler unless its + recipient carries non-empty `agenticAppBlueprintId`, `agenticAppId`, and `tenantId` values (plus `agenticUserId` + for an Agentic User), and the blueprint matches `ClientId`. An envelope whose `botKey` names another bot is + rejected the same way. +- **Per-recipient replies** — Replies act as the agentic identity on each activity's recipient, as they do over + HTTP, rather than as the agent instance that opened the connection. + ## Main Types | Type | Description | diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeEnvelope.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeEnvelope.cs index 4b0dd43b..e8ad7273 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeEnvelope.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeEnvelope.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Microsoft.Teams.Core.Schema; @@ -11,6 +12,7 @@ namespace Microsoft.Teams.Apps.SocketMode; /// internal static class SocketModeEnvelope { + private const string AgenticUserRole = "agenticUser"; /// /// Attempts to read a Teams activity from an envelope. /// @@ -83,6 +85,90 @@ internal static SocketReplyFrame CreateInvokeReply( }; } + /// + /// Validates an envelope received on an agentic connection, without changing the recipient identity that replies + /// act as. + /// + /// The envelope being validated. + /// The activity read from the envelope. + /// The bot's client ID, which is the agent blueprint ID. + /// The reason the envelope is rejected, when validation fails. + /// true when any bot key matches and the recipient is a complete agentic identity for the blueprint. + internal static bool TryValidateAgenticEnvelope( + SocketActivityEnvelope envelope, + CoreActivity activity, + string? blueprintId, + [NotNullWhen(false)] out string? error) + { + ArgumentNullException.ThrowIfNull(envelope); + ArgumentNullException.ThrowIfNull(activity); + + if (envelope.BotKey is not null && !IdEquals(envelope.BotKey, blueprintId)) + { + error = "envelope botKey does not match the bot client ID"; + return false; + } + + return TryValidateAgenticRecipient(activity.Recipient, blueprintId, out error); + } + + /// + /// Validates that a recipient is a complete agentic identity for the given blueprint. + /// + /// The activity recipient. + /// The bot's client ID, which is the agent blueprint ID. + /// The reason the recipient is rejected, when validation fails. + /// true when the recipient names a blueprint, agentic app, and tenant, plus an agentic user for an + /// Agentic User recipient, and its blueprint matches . + internal static bool TryValidateAgenticRecipient( + ChannelAccount? recipient, + string? blueprintId, + [NotNullWhen(false)] out string? error) + { + if (recipient is null) + { + error = "agentic activity requires a recipient identity"; + return false; + } + + bool requiresUser = IsAgenticUserRole(recipient) || recipient.AgenticUserId is not null; + if (!IsPresentAndUnpadded(recipient.AgenticAppBlueprintId) + || !IsPresentAndUnpadded(recipient.AgenticAppId) + || !IsPresentAndUnpadded(recipient.TenantId) + || (requiresUser && !IsPresentAndUnpadded(recipient.AgenticUserId))) + { + error = requiresUser + ? "agentic recipient requires non-empty, unpadded agenticAppBlueprintId, agenticAppId, tenantId, and agenticUserId" + : "agentic recipient requires non-empty, unpadded agenticAppBlueprintId, agenticAppId, and tenantId"; + return false; + } + + if (!IdEquals(recipient.AgenticAppBlueprintId, blueprintId)) + { + error = "agentic recipient blueprint does not match the bot client ID"; + return false; + } + + error = null; + return true; + } + + private static bool IsAgenticUserRole(ChannelAccount recipient) + => recipient.Properties.TryGetValue("role", out object? role) + && role switch + { + string value => value == AgenticUserRole, + JsonElement { ValueKind: JsonValueKind.String } element => element.ValueEquals(AgenticUserRole), + _ => false, + }; + + private static bool IsPresentAndUnpadded([NotNullWhen(true)] string? value) + => !string.IsNullOrEmpty(value) && value.Trim().Length == value.Length; + + // App and blueprint IDs are GUIDs, compared case-insensitively like the rest of the SDK's app ID checks. + private static bool IdEquals(string? value, string? expected) + => expected is not null && string.Equals(value, expected, StringComparison.OrdinalIgnoreCase); + /// /// Attempts to deserialize an activity candidate from an envelope. /// diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeNegotiator.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeNegotiator.cs index cb15f6ee..7dad167e 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeNegotiator.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeNegotiator.cs @@ -39,7 +39,7 @@ internal sealed class SocketModeNegotiator : ISocketModeNegotiator /// Initializes a Socket Mode negotiator. /// /// The client used to call the negotiate endpoint. - /// Acquires a Bot Framework app token. + /// Acquires the negotiate token: the Bot Framework app token, or the agent instance token for an agentic connection. /// The negotiate request timeout. /// The time provider used to evaluate retry delays. /// Thrown when is not positive. @@ -84,7 +84,7 @@ public async Task NegotiateAsync( if (string.IsNullOrWhiteSpace(token)) { throw new InvalidOperationException( - "Socket Mode negotiate could not acquire a Bot Framework app token."); + "Socket Mode negotiate could not acquire a connection token. Check that the bot credentials are configured."); } using HttpRequestMessage request = new(HttpMethod.Post, negotiateUri); diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index 35d9e88a..67aa52ca 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -57,6 +57,64 @@ public sealed class SocketModeOptions /// public TimeSpan ServerTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// Gets or sets the agent instance ID used to authenticate negotiation, opting the connection into agentic + /// Socket Mode. The configured client ID remains the agent blueprint ID. Requires + /// . Leave all agentic settings unset for a classic bot connection. + /// + /// + /// In agentic Socket Mode, an inbound activity is rejected with HTTP 400 unless its recipient names an agentic + /// app, a tenant, and a blueprint matching the configured client ID, plus an agentic user when the recipient is + /// an Agentic User. Replies keep acting as the recipient's agentic identity. + /// + public string? AgenticAppId { get; set; } + + /// + /// Gets or sets the token scope requested for agentic negotiation. Required with ; no + /// default audience is assumed. + /// + public string? AgenticTokenScope { get; set; } + + /// + /// Gets or sets the tenant used to acquire the agentic negotiate token. When null, the tenant from the + /// bot's credential configuration is used. Does not affect inbound recipients. + /// + public string? AgenticTenantId { get; set; } + + /// + /// Gets whether the agentic settings select agentic Socket Mode. + /// + internal bool IsAgentic => AgenticAppId is not null; + + /// + /// Validates that the agentic settings are either all unset or name an agent instance and a token scope. + /// + /// Thrown when the agentic settings are incomplete. + internal void ValidateAgenticSettings() + { + if (AgenticAppId is null) + { + if (AgenticTokenScope is not null || AgenticTenantId is not null) + { + throw new ArgumentException( + $"{nameof(AgenticAppId)} is required when {nameof(AgenticTokenScope)} or {nameof(AgenticTenantId)} is set."); + } + + return; + } + + if (string.IsNullOrWhiteSpace(AgenticAppId)) + { + throw new ArgumentException($"{nameof(AgenticAppId)} must not be empty."); + } + + if (string.IsNullOrWhiteSpace(AgenticTokenScope)) + { + throw new ArgumentException( + $"{nameof(AgenticTokenScope)} is required for agentic Socket Mode; no default audience is assumed."); + } + } + /// /// Snapshots the transport-level settings, so later changes to these options do not affect a running transport. /// Invalid values are passed through for the transport to reject when it starts. @@ -68,5 +126,6 @@ public sealed class SocketModeOptions Geos = Geos is null ? null! : [.. Geos], StartupTimeout = StartupTimeout, ReconnectDelays = ReconnectDelays is { Count: > 0 } delays ? [.. delays] : null, + RequireAgenticRecipient = IsAgentic, }; } diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocolModels.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocolModels.cs index babe1733..fa3b7fc1 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocolModels.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocolModels.cs @@ -71,6 +71,12 @@ internal sealed class SocketActivityEnvelope [JsonPropertyName("type")] public string? Type { get; init; } + /// + /// Gets the key identifying the bot the envelope is addressed to, when the service sends one. + /// + [JsonPropertyName("botKey")] + public string? BotKey { get; init; } + /// /// Gets whether the envelope requires a reply. /// diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs index 691b06c5..882a5971 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeServiceRegistration.cs @@ -61,7 +61,7 @@ internal static void AddSocketMode(IServiceCollection services, BotConfig BotTokenProvider tokenProvider = sp.GetRequiredKeyedService(sectionName); return new SocketModeNegotiator( sp.GetRequiredService().CreateClient(NegotiatorHttpClientName), - cancellationToken => tokenProvider.GetAppTokenAsync(BotFrameworkScope, tenantId: null, cancellationToken)); + CreateTokenSource(tokenProvider, options)); }); services.TryAddSingleton(sp => new SignalRSocketConnectionFactory( @@ -73,6 +73,7 @@ internal static void AddSocketMode(IServiceCollection services, BotConfig services.TryAddSingleton(sp => { + options.ValidateAgenticSettings(); TApp app = sp.GetRequiredService(); return new SocketModeTransport( options.ToTransportOptions(), @@ -100,6 +101,27 @@ internal static async Task DispatchAsync(TeamsBotApplicati : new SocketDispatchResult(response.Status, response.Body); } + /// + /// Selects the token that authenticates negotiation: the bot's Bot Framework app token for a classic connection, + /// or the agent instance's token for an agentic one. It is called on every negotiate, including reconnects and + /// token refreshes. + /// + /// The bot's token provider. + /// The validated Socket Mode options. + /// The negotiate token source. +#pragma warning disable ExperimentalTeamsSocketMode // Internal wiring for the experimental options passed to UseSocketMode. + internal static Func> CreateTokenSource(BotTokenProvider tokenProvider, SocketModeOptions options) +#pragma warning restore ExperimentalTeamsSocketMode + { + string? agenticAppId = options.AgenticAppId; + string? scope = options.AgenticTokenScope; + string? tenantId = options.AgenticTenantId; + + return agenticAppId is null + ? cancellationToken => tokenProvider.GetAppTokenAsync(BotFrameworkScope, tenantId: null, cancellationToken) + : cancellationToken => tokenProvider.GetAgenticAppTokenAsync(agenticAppId, scope!, tenantId, cancellationToken); + } + private static void EnsureSupportedCloud(BotConfig botConfig) { string issuer = botConfig.BotTokenIssuer?.Trim().TrimEnd('/') ?? string.Empty; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 252a126d..7590f3e9 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -66,6 +66,12 @@ internal sealed class SocketModeTransportOptions /// backoff with full jitter is used. /// internal IReadOnlyList? ReconnectDelays { get; init; } + + /// + /// Gets whether the connection is agentic, so an inbound envelope is rejected unless any bot key it carries is + /// the transport's, and its activity's recipient is a complete agentic identity whose blueprint is that bot key. + /// + internal bool RequireAgenticRecipient { get; init; } } /// @@ -370,6 +376,21 @@ void IGeoSocketOwner.OnGeoReconnected(string geo) bool invoke = IsInvoke(envelope, activity); + if (_options.RequireAgenticRecipient + && !SocketModeEnvelope.TryValidateAgenticEnvelope(envelope, activity, _botKey, out string? rejection)) + { + _logger.LogWarning( + "Socket Mode rejecting agentic envelope {EnvelopeId}: {Reason}.", + envelope.EnvelopeId, + rejection); + return SocketModeEnvelope.CreateInvokeReply( + envelope, + _botKey, + new SocketDispatchResult(400, new { error = rejection }), + receivedAt, + NowUnixMilliseconds()); + } + try { SocketDispatchResult result = await _dispatch(activity).ConfigureAwait(false); diff --git a/src/Microsoft.Teams.Core/Hosting/BotTokenProvider.cs b/src/Microsoft.Teams.Core/Hosting/BotTokenProvider.cs index f1ea16d7..95683c17 100644 --- a/src/Microsoft.Teams.Core/Hosting/BotTokenProvider.cs +++ b/src/Microsoft.Teams.Core/Hosting/BotTokenProvider.cs @@ -44,6 +44,34 @@ public sealed class BotTokenProvider( .ConfigureAwait(false)); } + /// + /// Acquire an app-only token for an agent instance, through the federated identity exchange. + /// Authenticates as the agent instance rather than as the blueprint app the credentials belong to, and without acting as any Agentic User. + /// + /// The agent instance (agentic app) ID to act as. + /// The resource scope to request. No default is assumed, because the audience depends on the service being called. + /// Tenant to acquire against, overriding the one the named MSAL options configure. null leaves the configured authority alone. + /// A token to cancel the acquisition. + /// The bearer token without its scheme prefix. + /// Thrown when or is null, empty, or whitespace. + public async Task GetAgenticAppTokenAsync(string agenticAppId, string scope, string? tenantId = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agenticAppId); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + AuthorizationHeaderProviderOptions options = CreateOptions(OptionsName, managedIdentityOptions, out _); + if (!string.IsNullOrEmpty(tenantId)) + { + options.AcquireTokenOptions.Tenant = tenantId; + } + + options.WithAgentIdentity(agenticAppId); + + return StripScheme(await _authorizationHeaderProvider + .CreateAuthorizationHeaderForAppAsync(scope, options, cancellationToken) + .ConfigureAwait(false)); + } + /// /// Acquire a user-delegated token for an Agentic User, through the federated identity exchange. /// Reads as the agent rather than as the app, so it sees what was shared with the agent rather than everything the app may read. diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeAgenticTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeAgenticTests.cs new file mode 100644 index 00000000..ba35fd41 --- /dev/null +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeAgenticTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Teams.Apps.SocketMode; +using Microsoft.Teams.Core.Schema; + +namespace Microsoft.Teams.Apps.UnitTests.SocketMode; + +public class SocketModeAgenticTests +{ + private const string BlueprintId = "11111111-1111-1111-1111-111111111111"; + + [Fact] + public void AgenticSettings_DefaultToClassicMode() + { + SocketModeOptions options = new(); + + Assert.Null(options.AgenticAppId); + Assert.Null(options.AgenticTokenScope); + Assert.Null(options.AgenticTenantId); + options.ValidateAgenticSettings(); + Assert.False(options.ToTransportOptions().RequireAgenticRecipient); + } + + [Fact] + public void AgenticSettings_WithAppIdAndScope_SelectAgenticTransport() + { + SocketModeOptions options = new() { AgenticAppId = "agent-instance", AgenticTokenScope = "api://socket/.default" }; + + options.ValidateAgenticSettings(); + + Assert.True(options.ToTransportOptions().RequireAgenticRecipient); + } + + public static TheoryData InvalidAgenticSettings => new() + { + { null, "api://socket/.default", null }, + { null, null, "tenant" }, + { "", "api://socket/.default", null }, + { " ", "api://socket/.default", null }, + { "agent-instance", null, null }, + { "agent-instance", "", null }, + { "agent-instance", " ", "tenant" }, + }; + + [Theory] + [MemberData(nameof(InvalidAgenticSettings))] + public void AgenticSettings_RejectIncompleteConfiguration(string? appId, string? scope, string? tenantId) + { + SocketModeOptions options = new() { AgenticAppId = appId, AgenticTokenScope = scope, AgenticTenantId = tenantId }; + + Assert.Throws(options.ValidateAgenticSettings); + } + + [Theory] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant","agenticUserId":"user","role":"agenticUser"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant","role":"agenticAppInstance"}""")] + public void TryValidateAgenticRecipient_AcceptsCompleteIdentity(string recipient) + { + Assert.True(SocketModeEnvelope.TryValidateAgenticRecipient(Recipient(recipient), BlueprintId, out string? error)); + Assert.Null(error); + } + + [Fact] + public void TryValidateAgenticRecipient_ComparesTheBlueprintCaseInsensitively() + { + ChannelAccount recipient = Recipient("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant"}"""); + + Assert.True(SocketModeEnvelope.TryValidateAgenticRecipient(recipient, BlueprintId.ToUpperInvariant(), out _)); + } + + [Theory] + [InlineData("""{"agenticAppId":"instance","tenantId":"tenant"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","tenantId":"tenant"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"","tenantId":"tenant"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":" instance","tenantId":"tenant"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant "}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant","role":"agenticUser"}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant","agenticUserId":""}""")] + [InlineData("""{"agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","tenantId":"tenant","agenticUserId":" user"}""")] + public void TryValidateAgenticRecipient_RejectsIncompleteOrPaddedIdentity(string recipient) + { + Assert.False(SocketModeEnvelope.TryValidateAgenticRecipient(Recipient(recipient), BlueprintId, out string? error)); + Assert.Contains("non-empty, unpadded", error, StringComparison.Ordinal); + } + + [Fact] + public void TryValidateAgenticRecipient_RejectsMissingRecipient() + { + Assert.False(SocketModeEnvelope.TryValidateAgenticRecipient(null, BlueprintId, out string? error)); + Assert.Equal("agentic activity requires a recipient identity", error); + } + + [Theory] + [InlineData(BlueprintId, "22222222-2222-2222-2222-222222222222")] + [InlineData(BlueprintId, null)] + public void TryValidateAgenticRecipient_RejectsBlueprintMismatch(string recipientBlueprint, string? clientId) + { + ChannelAccount recipient = new() { AgenticAppBlueprintId = recipientBlueprint, AgenticAppId = "instance", TenantId = "tenant" }; + + Assert.False(SocketModeEnvelope.TryValidateAgenticRecipient(recipient, clientId, out string? error)); + Assert.Equal("agentic recipient blueprint does not match the bot client ID", error); + } + + [Fact] + public void TryValidateAgenticRecipient_DoesNotChangeTheIdentity() + { + ChannelAccount recipient = Recipient("""{"id":"agent","agenticAppBlueprintId":"11111111-1111-1111-1111-111111111111","agenticAppId":"instance","agenticUserId":"user","tenantId":"tenant","role":"agenticUser"}"""); + + SocketModeEnvelope.TryValidateAgenticRecipient(recipient, BlueprintId, out _); + + AgenticIdentity identity = Assert.IsType(recipient.GetAgenticIdentity()); + Assert.Equal(BlueprintId, identity.AgenticAppBlueprintId); + Assert.Equal("instance", identity.AgenticAppId); + Assert.Equal("user", identity.AgenticUserId); + Assert.Equal("tenant", identity.TenantId); + } + + [Theory] + [InlineData(null, true)] + [InlineData(BlueprintId, true)] + [InlineData("22222222-2222-2222-2222-222222222222", false)] + public void TryValidateAgenticEnvelope_ChecksAnyBotKeyBeforeTheRecipient(string? botKey, bool valid) + { + SocketActivityEnvelope envelope = new() { EnvelopeId = "env-1", BotKey = botKey }; + CoreActivity activity = new() + { + Type = "message", + Recipient = new() { AgenticAppBlueprintId = BlueprintId, AgenticAppId = "instance", TenantId = "tenant" }, + }; + + Assert.Equal(valid, SocketModeEnvelope.TryValidateAgenticEnvelope(envelope, activity, BlueprintId, out string? error)); + if (!valid) + { + Assert.Equal("envelope botKey does not match the bot client ID", error); + } + } + + [Fact] + public void Envelope_DeserializesBotKey() + { + SocketActivityEnvelope? envelope = SocketModeJson.Deserialize("""{"envelopeId":"env-1","botKey":"bot"}"""); + + Assert.Equal("bot", envelope?.BotKey); + } + + private static ChannelAccount Recipient(string json) + => JsonSerializer.Deserialize(json)!; +} diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs index dc147aae..f1170912 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Apps.SocketMode; @@ -99,6 +100,14 @@ public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash() o => o.ReadinessTimeout = TimeSpan.Zero, o => o.KeepAliveInterval = TimeSpan.Zero, o => o.ServerTimeout = TimeSpan.Zero, + o => o.AgenticAppId = "agent-instance", + o => o.AgenticTokenScope = "api://socket/.default", + o => o.AgenticTenantId = "agent-tenant", + o => + { + o.AgenticAppId = " "; + o.AgenticTokenScope = "api://socket/.default"; + }, }; [Theory] @@ -144,6 +153,50 @@ public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClien Assert.Equal("bot-token", request.Headers.Authorization?.Parameter); } + [Theory] + [InlineData(null)] + [InlineData("agent-tenant")] + public async Task Negotiator_Agentic_UsesTheAgentInstanceTokenOnEveryNegotiate(string? tenantId) + { + List<(string Scope, AuthorizationHeaderProviderOptions Options)> calls = []; + Mock header = new(); + header + .Setup(h => h.CreateAuthorizationHeaderForAppAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string scope, AuthorizationHeaderProviderOptions options, CancellationToken __) => + { + calls.Add((scope, options)); + return Task.FromResult("Bearer agent-instance-token"); + }); + RecordingHandler handler = new(); + + ServiceCollection services = CreateServices(); + services.AddTeamsBotApplication(options => options.UseSocketMode(socket => + { + socket.AgenticAppId = "agent-instance"; + socket.AgenticTokenScope = "api://socket/.default"; + socket.AgenticTenantId = tenantId; + })); + services.AddKeyedSingleton("AzureAd", new BotTokenProvider(header.Object)); + services.AddHttpClient(SocketModeServiceRegistration.NegotiatorHttpClientName).ConfigurePrimaryHttpMessageHandler(() => handler); + + using ServiceProvider provider = services.BuildServiceProvider(); + ISocketModeNegotiator negotiator = provider.GetRequiredService(); + Uri negotiateUri = new("https://botapi.skype.com/amer/v3/websockets/connect"); + + // A reconnect or token refresh negotiates again, so it must mint the agentic token again too. + await negotiator.NegotiateAsync(negotiateUri); + await negotiator.NegotiateAsync(negotiateUri); + + Assert.Equal(2, calls.Count); + Assert.All(calls, call => + { + Assert.Equal("api://socket/.default", call.Scope); + Assert.Equal(tenantId, call.Options.AcquireTokenOptions.Tenant); + Assert.Contains("agent-instance", call.Options.AcquireTokenOptions.ExtraParameters!.Values); + }); + Assert.All(handler.Requests, request => Assert.Equal("agent-instance-token", request.Headers.Authorization?.Parameter)); + } + [Fact] public async Task HostedService_StartsEveryGeoDispatchesToTheAppAndStops() { @@ -472,6 +525,90 @@ public async Task GenericHost_MessageOverSocket_RepliesThroughTheConversationApi Assert.Equal("You said: hello", JsonDocument.Parse(body).RootElement.GetProperty("text").GetString()); } + [Fact] + public async Task GenericHost_AgenticMessageOverSocket_RepliesAsTheRecipientAgenticUser() + { + const string AgenticUserId = "31e29ddb-e4ce-427e-8bda-1a37eb12d43f"; + FakeConnectionFactory factory = new(); + OutboundHandler outbound = new(); + List userTokenOptions = []; + Mock header = new(); + header + .Setup(h => h.CreateAuthorizationHeaderForAppAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("Bearer app-token"); + header + .Setup(h => h.CreateAuthorizationHeaderAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IEnumerable _, AuthorizationHeaderProviderOptions options, System.Security.Claims.ClaimsPrincipal __, CancellationToken ___) => + { + userTokenOptions.Add(options); + return Task.FromResult("Bearer agentic-user-token"); + }); + + HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = "socket-tenant-id", + }); + builder.Services.AddSingleton(factory); + builder.Services.AddTeamsBotApplication(options => options.UseSocketMode(socket => + { + socket.Geos = ["amer"]; + socket.AgenticAppId = "socket-agent-instance"; + socket.AgenticTokenScope = "api://socket/.default"; + })); + builder.Services.AddSingleton(header.Object); + builder.Services.AddHttpClient("BotConversationClient").ConfigurePrimaryHttpMessageHandler(() => outbound); + using IHost host = builder.Build(); + + TeamsBotApplication app = host.UseTeamsBotApplication(); + app.OnMessage((context, cancellationToken) => context.SendAsync("hi", cancellationToken)); + await host.StartAsync(); + + SocketConnectionHandlers handlers = Assert.Single(factory.Connections).Handlers; + SocketReplyFrame? rejected = await handlers.OnActivity(AgenticEnvelope(new { id = ClientId })); + SocketReplyFrame? ack = await handlers.OnActivity(AgenticEnvelope(new + { + id = "agent", + role = "agenticUser", + agenticAppBlueprintId = ClientId, + agenticAppId = "recipient-agent-instance", + agenticUserId = AgenticUserId, + tenantId = "recipient-tenant", + })); + await host.StopAsync(); + + Assert.Equal(400, rejected!.Status); + Assert.Equal(200, ack!.Status); + (HttpRequestMessage request, _) = Assert.Single(outbound.Requests); + Assert.Equal("agentic-user-token", request.Headers.Authorization?.Parameter); + + // The reply acts as the activity's recipient, not as the agent instance that negotiated the socket. + AuthorizationHeaderProviderOptions expected = new() { AcquireTokenOptions = new() }; + expected.WithAgentUserIdentity("recipient-agent-instance", Guid.Parse(AgenticUserId)); + AuthorizationHeaderProviderOptions actual = Assert.Single(userTokenOptions); + Assert.All( + expected.AcquireTokenOptions.ExtraParameters!.Where(parameter => parameter.Value is string), + parameter => Assert.Equal(parameter.Value, actual.AcquireTokenOptions.ExtraParameters![parameter.Key])); + + static SocketActivityEnvelope AgenticEnvelope(object recipient) => new() + { + EnvelopeId = "envelope-1", + Type = TeamsActivityTypes.Message, + Payload = JsonSerializer.SerializeToElement(new + { + type = TeamsActivityTypes.Message, + id = "incoming-1", + text = "hello", + channelId = "msteams", + serviceUrl = "https://smba.trafficmanager.net/amer/", + conversation = new { id = "conversation-1" }, + from = new { id = "user-1" }, + recipient, + }), + }; + } + private sealed class OutboundHandler : HttpMessageHandler { public List<(HttpRequestMessage Request, string Body)> Requests { get; } = []; diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 15cf9db7..c3077962 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -309,6 +309,65 @@ public async Task Dispatch_DropsEnvelopeWithoutActivity() Assert.Empty(harness.Dispatched); } + private const string AgenticRecipient = + """{"agenticAppBlueprintId":"bot-id","agenticAppId":"instance","agenticUserId":"user","tenantId":"tenant","role":"agenticUser"}"""; + + [Theory] + [InlineData("invoke")] + [InlineData("message")] + public async Task Dispatch_Agentic_DispatchesCompleteRecipientUnchanged(string type) + { + Harness harness = new(new SocketModeTransportOptions { RequireAgenticRecipient = true }); + FakeConnection connection = (await harness.StartReadyAsync())[0]; + + SocketReplyFrame? reply = await connection.ActivityAsync( + Envelope(type, $$"""{"type":"{{type}}","recipient":{{AgenticRecipient}}}""", botKey: "bot-id")); + + Assert.Equal(200, reply?.Status); + AgenticIdentity identity = Assert.IsType(Assert.Single(harness.Dispatched).Recipient?.GetAgenticIdentity()); + Assert.Equal("bot-id", identity.AgenticAppBlueprintId); + Assert.Equal("instance", identity.AgenticAppId); + Assert.Equal("user", identity.AgenticUserId); + Assert.Equal("tenant", identity.TenantId); + } + + public static TheoryData InvalidAgenticEnvelopes => new() + { + { "message", """{"type":"message"}""", null, "agentic activity requires a recipient identity" }, + { "invoke", """{"type":"invoke","recipient":{"agenticAppBlueprintId":"bot-id","agenticAppId":"instance","tenantId":"tenant","role":"agenticUser"}}""", null, "agentic recipient requires non-empty, unpadded agenticAppBlueprintId, agenticAppId, tenantId, and agenticUserId" }, + { "message", """{"type":"message","recipient":{"agenticAppBlueprintId":"other-bot","agenticAppId":"instance","tenantId":"tenant"}}""", null, "agentic recipient blueprint does not match the bot client ID" }, + { "message", $$"""{"type":"message","recipient":{{AgenticRecipient}}}""", "other-bot", "envelope botKey does not match the bot client ID" }, + }; + + [Theory] + [MemberData(nameof(InvalidAgenticEnvelopes))] + public async Task Dispatch_Agentic_RejectsInvalidEnvelopeWith400WithoutDispatching(string type, string payload, string? botKey, string error) + { + Harness harness = new(new SocketModeTransportOptions { RequireAgenticRecipient = true }); + FakeConnection connection = (await harness.StartReadyAsync())[0]; + + SocketReplyFrame? reply = await connection.ActivityAsync(Envelope(type, payload, botKey: botKey)); + + Assert.NotNull(reply); + Assert.Equal(400, reply.Status); + Assert.Equal("bot-id", reply.BotKey); + Assert.Equal(error, JsonSerializer.SerializeToElement(reply.Body).GetProperty("error").GetString()); + Assert.Empty(harness.Dispatched); + } + + [Fact] + public async Task Dispatch_Classic_DoesNotValidateRecipientOrBotKey() + { + Harness harness = new(); + FakeConnection connection = (await harness.StartReadyAsync())[0]; + + SocketReplyFrame? reply = await connection.ActivityAsync( + Envelope("message", """{"type":"message","recipient":{"id":"bot"}}""", botKey: "other-bot")); + + Assert.Equal(200, reply?.Status); + Assert.Single(harness.Dispatched); + } + [Fact] public void RetryPolicy_UsesScheduleThenRepeatsLastDelay() { @@ -344,12 +403,13 @@ public void RetryPolicy_ReadsRetryAfterFromNegotiateFailure() Assert.Null(owner.GetRetryAfter(null)); } - private static SocketActivityEnvelope Envelope(string? type, string payload, int protocolVersion = 1) + private static SocketActivityEnvelope Envelope(string? type, string payload, int protocolVersion = 1, string? botKey = null) => new() { ProtocolVersion = protocolVersion, EnvelopeId = "env-1", Type = type, + BotKey = botKey, Payload = JsonDocument.Parse(payload).RootElement.Clone(), }; diff --git a/test/Microsoft.Teams.Core.UnitTests/Hosting/BotTokenProviderAgenticAppTests.cs b/test/Microsoft.Teams.Core.UnitTests/Hosting/BotTokenProviderAgenticAppTests.cs new file mode 100644 index 00000000..e45f8935 --- /dev/null +++ b/test/Microsoft.Teams.Core.UnitTests/Hosting/BotTokenProviderAgenticAppTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.Teams.Core.Hosting; +using Moq; + +namespace Microsoft.Teams.Core.UnitTests.Hosting; + +/// +/// acquires an app-only token as an agent instance. +/// +public class BotTokenProviderAgenticAppTests +{ + private const string AgenticAppId = "d94529f7-d988-4caa-8635-a47201acec74"; + private const string Scope = "api://socket-mode/.default"; + + private sealed class Recorder + { + public List<(string Scope, AuthorizationHeaderProviderOptions Options)> Calls { get; } = []; + + public BotTokenProvider Provider { get; } + + public Recorder() + { + Mock header = new(); + header + .Setup(h => h.CreateAuthorizationHeaderForAppAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string scope, AuthorizationHeaderProviderOptions options, CancellationToken _) => + { + Calls.Add((scope, options)); + return Task.FromResult("Bearer agent-instance-token"); + }); + + Provider = new BotTokenProvider(header.Object, "AzureAd"); + } + } + + [Fact] + public async Task AcquiresAnAppTokenAsTheAgentInstanceForTheScope() + { + Recorder recorder = new(); + + string? token = await recorder.Provider.GetAgenticAppTokenAsync(AgenticAppId, Scope); + + Assert.Equal("agent-instance-token", token); + (string scope, AuthorizationHeaderProviderOptions options) = Assert.Single(recorder.Calls); + Assert.Equal(Scope, scope); + Assert.Equal("AzureAd", options.AcquireTokenOptions.AuthenticationOptionsName); + Assert.Null(options.AcquireTokenOptions.Tenant); + + // WithAgentIdentity marks the acquisition with the agent instance; compare that marker rather than the + // whole dictionary, which also holds a freshly created options object. + AuthorizationHeaderProviderOptions expected = new() { AcquireTokenOptions = new AcquireTokenOptions() }; + expected.WithAgentIdentity(AgenticAppId); + KeyValuePair marker = Assert.Single( + expected.AcquireTokenOptions.ExtraParameters!, + parameter => AgenticAppId.Equals(parameter.Value)); + Assert.Equal(AgenticAppId, options.AcquireTokenOptions.ExtraParameters?[marker.Key]); + } + + [Fact] + public async Task OverridesTheTenantWhenOneIsGiven() + { + Recorder recorder = new(); + + await recorder.Provider.GetAgenticAppTokenAsync(AgenticAppId, Scope, "agent-tenant"); + + Assert.Equal("agent-tenant", Assert.Single(recorder.Calls).Options.AcquireTokenOptions.Tenant); + } + + [Theory] + [InlineData(null, Scope)] + [InlineData("", Scope)] + [InlineData(" ", Scope)] + [InlineData(AgenticAppId, null)] + [InlineData(AgenticAppId, "")] + [InlineData(AgenticAppId, " ")] + public async Task Throws_WithoutAcquiring_WhenTheAgentInstanceOrScopeIsMissing(string? agenticAppId, string? scope) + { + Recorder recorder = new(); + + await Assert.ThrowsAnyAsync( + () => recorder.Provider.GetAgenticAppTokenAsync(agenticAppId!, scope!)); + + Assert.Empty(recorder.Calls); + } +}