Skip to content
Draft
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
28 changes: 26 additions & 2 deletions src/Microsoft.Teams.Apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = "<agent-instance-id>";
socket.AgenticTokenScope = "<negotiate-token-scope>";
socket.AgenticTenantId = "<tenant-id>"; // Optional; defaults to the credential tenant.
}));
```

- **Explicit scope** &mdash; 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** &mdash; 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** &mdash; 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 |
Expand Down
86 changes: 86 additions & 0 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeEnvelope.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 System.Text.Json;
using Microsoft.Teams.Core.Schema;

Expand All @@ -11,6 +12,7 @@ namespace Microsoft.Teams.Apps.SocketMode;
/// </summary>
internal static class SocketModeEnvelope
{
private const string AgenticUserRole = "agenticUser";
/// <summary>
/// Attempts to read a Teams activity from an envelope.
/// </summary>
Expand Down Expand Up @@ -83,6 +85,90 @@ internal static SocketReplyFrame CreateInvokeReply(
};
}

/// <summary>
/// Validates an envelope received on an agentic connection, without changing the recipient identity that replies
/// act as.
/// </summary>
/// <param name="envelope">The envelope being validated.</param>
/// <param name="activity">The activity read from the envelope.</param>
/// <param name="blueprintId">The bot's client ID, which is the agent blueprint ID.</param>
/// <param name="error">The reason the envelope is rejected, when validation fails.</param>
/// <returns><c>true</c> when any bot key matches and the recipient is a complete agentic identity for the blueprint.</returns>
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);
}

/// <summary>
/// Validates that a recipient is a complete agentic identity for the given blueprint.
/// </summary>
/// <param name="recipient">The activity recipient.</param>
/// <param name="blueprintId">The bot's client ID, which is the agent blueprint ID.</param>
/// <param name="error">The reason the recipient is rejected, when validation fails.</param>
/// <returns><c>true</c> when the recipient names a blueprint, agentic app, and tenant, plus an agentic user for an
/// Agentic User recipient, and its blueprint matches <paramref name="blueprintId"/>.</returns>
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);

/// <summary>
/// Attempts to deserialize an activity candidate from an envelope.
/// </summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeNegotiator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal sealed class SocketModeNegotiator : ISocketModeNegotiator
/// Initializes a Socket Mode negotiator.
/// </summary>
/// <param name="httpClient">The client used to call the negotiate endpoint.</param>
/// <param name="getBotToken">Acquires a Bot Framework app token.</param>
/// <param name="getBotToken">Acquires the negotiate token: the Bot Framework app token, or the agent instance token for an agentic connection.</param>
/// <param name="timeout">The negotiate request timeout.</param>
/// <param name="timeProvider">The time provider used to evaluate retry delays.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="timeout"/> is not positive.</exception>
Expand Down Expand Up @@ -84,7 +84,7 @@ public async Task<SocketModeNegotiateResponse> 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);
Expand Down
59 changes: 59 additions & 0 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,64 @@ public sealed class SocketModeOptions
/// </summary>
public TimeSpan ServerTimeout { get; set; } = TimeSpan.FromSeconds(30);

/// <summary>
/// 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
/// <see cref="AgenticTokenScope"/>. Leave all agentic settings unset for a classic bot connection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string? AgenticAppId { get; set; }

/// <summary>
/// Gets or sets the token scope requested for agentic negotiation. Required with <see cref="AgenticAppId"/>; no
/// default audience is assumed.
/// </summary>
public string? AgenticTokenScope { get; set; }

/// <summary>
/// Gets or sets the tenant used to acquire the agentic negotiate token. When <c>null</c>, the tenant from the
/// bot's credential configuration is used. Does not affect inbound recipients.
/// </summary>
public string? AgenticTenantId { get; set; }

/// <summary>
/// Gets whether the agentic settings select agentic Socket Mode.
/// </summary>
internal bool IsAgentic => AgenticAppId is not null;

/// <summary>
/// Validates that the agentic settings are either all unset or name an agent instance and a token scope.
/// </summary>
/// <exception cref="ArgumentException">Thrown when the agentic settings are incomplete.</exception>
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.");
}
}

/// <summary>
/// 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.
Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ internal sealed class SocketActivityEnvelope
[JsonPropertyName("type")]
public string? Type { get; init; }

/// <summary>
/// Gets the key identifying the bot the envelope is addressed to, when the service sends one.
/// </summary>
[JsonPropertyName("botKey")]
public string? BotKey { get; init; }

/// <summary>
/// Gets whether the envelope requires a reply.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal static void AddSocketMode<TApp>(IServiceCollection services, BotConfig
BotTokenProvider tokenProvider = sp.GetRequiredKeyedService<BotTokenProvider>(sectionName);
return new SocketModeNegotiator(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(NegotiatorHttpClientName),
cancellationToken => tokenProvider.GetAppTokenAsync(BotFrameworkScope, tenantId: null, cancellationToken));
CreateTokenSource(tokenProvider, options));
});

services.TryAddSingleton<ISocketConnectionFactory>(sp => new SignalRSocketConnectionFactory(
Expand All @@ -73,6 +73,7 @@ internal static void AddSocketMode<TApp>(IServiceCollection services, BotConfig

services.TryAddSingleton(sp =>
{
options.ValidateAgenticSettings();
TApp app = sp.GetRequiredService<TApp>();
return new SocketModeTransport(
options.ToTransportOptions(),
Expand Down Expand Up @@ -100,6 +101,27 @@ internal static async Task<SocketDispatchResult> DispatchAsync(TeamsBotApplicati
: new SocketDispatchResult(response.Status, response.Body);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="tokenProvider">The bot's token provider.</param>
/// <param name="options">The validated Socket Mode options.</param>
/// <returns>The negotiate token source.</returns>
#pragma warning disable ExperimentalTeamsSocketMode // Internal wiring for the experimental options passed to UseSocketMode.
internal static Func<CancellationToken, Task<string?>> 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;
Expand Down
21 changes: 21 additions & 0 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ internal sealed class SocketModeTransportOptions
/// backoff with full jitter is used.
/// </summary>
internal IReadOnlyList<TimeSpan>? ReconnectDelays { get; init; }

/// <summary>
/// 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.
/// </summary>
internal bool RequireAgenticRecipient { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/Microsoft.Teams.Core/Hosting/BotTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ public sealed class BotTokenProvider(
.ConfigureAwait(false));
}

/// <summary>
/// Acquire an app-only token for an agent instance, through the federated identity exchange.
/// <para>Authenticates as the agent instance rather than as the blueprint app the credentials belong to, and without acting as any Agentic User.</para>
/// </summary>
/// <param name="agenticAppId">The agent instance (agentic app) ID to act as.</param>
/// <param name="scope">The resource scope to request. No default is assumed, because the audience depends on the service being called.</param>
/// <param name="tenantId">Tenant to acquire against, overriding the one the named MSAL options configure. <c>null</c> leaves the configured authority alone.</param>
/// <param name="cancellationToken">A token to cancel the acquisition.</param>
/// <returns>The bearer token without its scheme prefix.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="agenticAppId"/> or <paramref name="scope"/> is null, empty, or whitespace.</exception>
public async Task<string?> 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));
}

/// <summary>
/// Acquire a user-delegated token for an Agentic User, through the federated identity exchange.
/// <para>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.</para>
Expand Down
Loading
Loading