diff --git a/.github/workflows/ci-dotnet.yml b/.github/workflows/ci-dotnet.yml new file mode 100644 index 0000000..b4ddc6d --- /dev/null +++ b/.github/workflows/ci-dotnet.yml @@ -0,0 +1,18 @@ +name: ci-dotnet + +on: + workflow_dispatch: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build dotnet samples + run: dotnet build samples/Samples.sln \ No newline at end of file diff --git a/.gitignore b/.gitignore index d8c7fbe..29cd3b3 100644 --- a/.gitignore +++ b/.gitignore @@ -395,3 +395,4 @@ dist/ devTools/ node_modules/ +launchSettings.json \ No newline at end of file diff --git a/samples/README.md b/samples/README.md index 96de2f3..e7ab96b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,16 +1,34 @@ # M365 Agents SDK Samples -To enable debugging, open this folder using Visual Studio Code (VSCode). Follow these steps: +Here you can find samples showing how to use the Agents SDK in different languages. -1. Launch VSCode. -2. Select `File` > `Open Folder...`. -3. Navigate to the `/workspaces/Agents/samples` directory and open it. -4. Once the folder is open, you can start debugging by setting breakpoints and running the samples. ## Samples list |Category | Name | Description | node | dotnet | python | |---------|-------------|-------------|--------|--------|--------| -| Basic | Echo Bot | Simplest bot | [basic/echo-bot/nodejs](./basic/echo-bot/nodejs) | TBD | TBD | +| Basic | Echo Bot | Simplest bot | [basic/echo-bot/nodejs](./basic/echo-bot/nodejs) | [basic/echo-bot/dotnet](./basic/echo-bot/dotnet) | TBD | | Basic | Copilot Studio Client | Consume CopilotStudio Agent | [basic/copilotstudio-client/nodejs](./basic/copilotstudio-client/nodejs) | TBD | TBD | | Complex | Copilot Studio Skill | Call the echo bot from a Copilot Studio skill | [complex/copilotstudio-skill/nodejs](./complex/copilotstudio-skill/nodejs) | TBD | TBD | + + +## Debugging in localhost + +To debug your Agent in `localhost` you can: + +1. Use a client emulator, such as the [Teams App Test Tool](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/debug-your-teams-app-test-tool), or the [BotFramework Emulator](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-debug-emulator) +1. Use the WebChat UI, by registering one instance of Azure Bot Service, and configure the endpoint to use a tunnel to localhost (eg, by using devtunnels) + +### How to register an ABS instance with DevTunnels + +1. Install devtunnels and the Azure CLI + 1. This repo contains a CodeSpaces configuration with all the required tools already installed. +1. Login into devtunnels: `devtunnel user login`. +1. Login into Azure: `az login`. +1. Run the script `_tools/configure-abs-tunnel.sh`. + 1. This script configures devtunnels and ABS, using the machine name, and produces a `.env` file. +1. Copy the generated `.env` file to the sample folder with file name `.env`. +1. Start the tunnel before debugging `devtunnel host ` + +> [!Tip] +> You can use the same ABS instance and tunnel to debug any of these samples, with the only caveat that you can only use one at a time. \ No newline at end of file diff --git a/samples/Samples.sln b/samples/Samples.sln new file mode 100644 index 0000000..b946818 --- /dev/null +++ b/samples/Samples.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35818.85 d17.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoBot", "basic\echo-bot\dotnet\EchoBot.csproj", "{B1FC20C1-9900-FAC4-B8E6-223888633020}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1FC20C1-9900-FAC4-B8E6-223888633020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1FC20C1-9900-FAC4-B8E6-223888633020}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1FC20C1-9900-FAC4-B8E6-223888633020}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1FC20C1-9900-FAC4-B8E6-223888633020}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {46F8E711-A447-4AB5-8357-03A5B5D21B49} + EndGlobalSection +EndGlobal diff --git a/samples/_tools/configure-abs-tunnel.sh b/samples/_tools/configure-abs-tunnel.sh new file mode 100755 index 0000000..0d6786e --- /dev/null +++ b/samples/_tools/configure-abs-tunnel.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +resource_group=$HOSTNAME-dev +tunnel_id=$HOSTNAME-tunnel + +if ! az account show &> /dev/null; then + echo "Please login to Azure CLI." + az login +fi + +if ! devtunnel list &> /dev/null; then + echo "Please login to devtunnel." + devtunnel user login +fi + +if ! devtunnel show $tunnel_id &> /dev/null; then + echo "Creating tunnel: $tunnel_id in port 3978" + devtunnel create $tunnel_id -a + devtunnel port create $tunnel_id -p 3978 +fi + +if ! az group show --name $resource_group &> /dev/null; then + echo "Creating resource group: $resource_group" + az group create --name $resource_group --location eastus +fi + +tunnel_details=$(devtunnel show $tunnel_id -j -v) +tunnel_url=$(echo $tunnel_details | grep -oP '(?<="portForwardingUris": \[ ")[^"]+(?=" \])') +echo $tunnel_url + +appId=$(az ad app create --display-name $tunnel_id --sign-in-audience "AzureADMyOrg" --query appId -o tsv) +echo "Created AppId: " $appId +secretJson=$(az ad app credential reset --id $appId | jq .) + +clientId=$(echo $secretJson | jq .appId | tr -d '"') +tenantId=$(echo $secretJson | jq .tenant | tr -d '"') +clientSecret=$(echo $secretJson | jq .password | tr -d '"') + +echo "clientId=$clientId" > "$tunnel_id.env" +echo "tenantId=$tenantId" >> "$tunnel_id.env" +echo "clientSecret=$clientSecret" >> "$tunnel_id.env" +echo "DEBUG=agents:*.info" >> "$tunnel_id.env" + +echo "Env File created: $tunnel_id.env" + +endpoint=$tunnel_url"api/messages" + +botJson=$(az bot create \ + --app-type SingleTenant \ + --appid $appId \ + --tenant-id $tenantId \ + --name $tunnel_id \ + --resource-group $resource_group \ + --endpoint $endpoint) + +echo $botJson +$teamsBotJson=$(az bot msteams create -n $tunnel_id -g $resource_group) +echo $teamsBotJson + +echo "Bot created: $tunnel_id" \ No newline at end of file diff --git a/samples/basic/echo-bot/dotnet/AspNetExtensions.cs b/samples/basic/echo-bot/dotnet/AspNetExtensions.cs new file mode 100644 index 0000000..9d27d68 --- /dev/null +++ b/samples/basic/echo-bot/dotnet/AspNetExtensions.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System.Collections.Concurrent; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; + +namespace Microsoft.Agents.Samples +{ + public static class AspNetExtensions + { + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds token validation typical for ABS/SMBA and Bot-to-bot. + /// default to Azure Public Cloud. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// Configuration: + /// + /// "TokenValidation": { + /// "Audiences": [ + /// "{required:bot-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + /// + /// `IsGov` can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// `ValidIssuers` can be omitted, in which case the Public Azure Bot Service issuers are used. + /// `TenantId` can be omitted if the Agent is not being called by another Agent. Otherwise it is used to add other known issuers. Only when `ValidIssuers` is omitted. + /// `AzureBotServiceOpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `OpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `AzureBotServiceTokenHandling` defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public static void AddBotAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation", ILogger logger = null!) + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + List validTokenIssuers = tokenValidationSection.GetSection("ValidIssuers").Get>()!; + List audiences = tokenValidationSection.GetSection("Audiences").Get>()!; + + if (!tokenValidationSection.Exists()) + { + logger?.LogError($"Missing configuration section '{tokenValidationSectionName}'. This section is required to be present in appsettings.json"); + throw new InvalidOperationException($"Missing configuration section '{tokenValidationSectionName}'. This section is required to be present in appsettings.json"); + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validTokenIssuers == null || validTokenIssuers.Count == 0) + { + validTokenIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + string tenantId = tokenValidationSection["TenantId"]!; + if (!string.IsNullOrEmpty(tenantId)) + { + validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, tenantId)); + validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, tenantId)); + } + } + + if (audiences == null || audiences.Count == 0) + { + throw new ArgumentException($"{tokenValidationSectionName}:Audiences requires at least one value"); + } + + bool isGov = tokenValidationSection.GetValue("IsGov", false); + bool azureBotServiceTokenHandling = tokenValidationSection.GetValue("AzureBotServiceTokenHandling", true); + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + string? azureBotServiceOpenIdMetadataUrl = tokenValidationSection["AzureBotServiceOpenIdMetadataUrl"]; + if (string.IsNullOrEmpty(azureBotServiceOpenIdMetadataUrl)) + { + azureBotServiceOpenIdMetadataUrl = isGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + string? openIdMetadataUrl = tokenValidationSection["OpenIdMetadataUrl"]; + if (string.IsNullOrEmpty(openIdMetadataUrl)) + { + openIdMetadataUrl = isGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + TimeSpan openIdRefreshInterval = tokenValidationSection.GetValue("OpenIdMetadataRefresh", BaseConfigurationManager.DefaultAutomaticRefreshInterval); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validTokenIssuers, + ValidAudiences = audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string? issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value; + + if (azureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Bot Framework authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(azureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(azureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdRefreshInterval + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(openIdMetadataUrl, key => + { + return new ConfigurationManager(openIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdRefreshInterval + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + logger?.LogDebug("TOKEN Validated"); + return Task.CompletedTask; + }, + OnForbidden = context => + { + logger?.LogWarning(context.Result.ToString()); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + logger?.LogWarning(context.Exception.ToString()); + return Task.CompletedTask; + } + }; + }); + } + } +} diff --git a/samples/basic/echo-bot/dotnet/BotController.cs b/samples/basic/echo-bot/dotnet/BotController.cs new file mode 100644 index 0000000..ae2de87 --- /dev/null +++ b/samples/basic/echo-bot/dotnet/BotController.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.BotBuilder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EchoBot +{ + // ASP.Net Controller that receives incoming HTTP requests from the Azure Bot Service or other configured event activity protocol sources. + // When called, the request has already been authorized and credentials and tokens validated. + [Authorize] + [ApiController] + [Route("api/messages")] + public class BotController(IBotHttpAdapter adapter, IBot bot) : ControllerBase + { + [HttpPost] + public Task PostAsync(CancellationToken cancellationToken) + => adapter.ProcessAsync(Request, Response, bot, cancellationToken); + + } +} diff --git a/samples/basic/echo-bot/dotnet/EchoBot.csproj b/samples/basic/echo-bot/dotnet/EchoBot.csproj new file mode 100644 index 0000000..6dc2e61 --- /dev/null +++ b/samples/basic/echo-bot/dotnet/EchoBot.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/samples/basic/echo-bot/dotnet/MyBot.cs b/samples/basic/echo-bot/dotnet/MyBot.cs new file mode 100644 index 0000000..626b93f --- /dev/null +++ b/samples/basic/echo-bot/dotnet/MyBot.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.BotBuilder; +using Microsoft.Agents.Core.Interfaces; +using Microsoft.Agents.Core.Models; + +namespace EchoBot +{ + // This is the core handler for the Bot Message loop. Each new request will be processed by this class. + public class MyBot : ActivityHandler + { + protected override async Task OnMessageActivityAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // Create a new Activity from the message the user provided and modify the text to echo back. + IActivity message = MessageFactory.Text($"Echo: {turnContext.Activity.Text}"); + + // Send the response message back to the user. + await turnContext.SendActivityAsync(message, cancellationToken); + } + + protected override async Task OnMembersAddedAsync(IList membersAdded, ITurnContext turnContext, CancellationToken cancellationToken) + { + // When someone (or something) connects to the bot, a MembersAdded activity is received. + // For this sample, we treat this as a welcome event, and send a message saying hello. + // For more details around the membership lifecycle, please see the lifecycle documentation. + IActivity message = MessageFactory.Text("Hello and Welcome!"); + + // Send the response message back to the user. + await turnContext.SendActivityAsync(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/samples/basic/echo-bot/dotnet/Program.cs b/samples/basic/echo-bot/dotnet/Program.cs new file mode 100644 index 0000000..9a0ec58 --- /dev/null +++ b/samples/basic/echo-bot/dotnet/Program.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using EchoBot; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Samples; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddHttpClient(); + +// Add AspNet token validation +builder.Services.AddBotAspNetAuthentication(builder.Configuration); + +// Add basic bot functionality +builder.AddBot(); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapGet("/", () => "Microsoft Copilot SDK Sample"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); +} +else +{ + app.MapControllers(); +} +app.Run(); \ No newline at end of file diff --git a/samples/basic/echo-bot/dotnet/Properties/launchSettings.TEMPLATE.json b/samples/basic/echo-bot/dotnet/Properties/launchSettings.TEMPLATE.json new file mode 100644 index 0000000..7490a9b --- /dev/null +++ b/samples/basic/echo-bot/dotnet/Properties/launchSettings.TEMPLATE.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3978", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Connections__BotServiceConnection__Settings__TenantId": "<>", + "Connections__BotServiceConnection__Settings__ClientId": "<>", + "Connections__BotServiceConnection__Settings__AuthorityEndpoint": "https://login.microsoftonline.com/<>", + "Connections__BotServiceConnection__Settings__ClientSecret": "<>" + } + } + } +} diff --git a/samples/basic/echo-bot/dotnet/appsettings.Development.json b/samples/basic/echo-bot/dotnet/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/basic/echo-bot/dotnet/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/basic/echo-bot/dotnet/appsettings.json b/samples/basic/echo-bot/dotnet/appsettings.json new file mode 100644 index 0000000..504969e --- /dev/null +++ b/samples/basic/echo-bot/dotnet/appsettings.json @@ -0,0 +1,35 @@ +{ + "TokenValidation": { + "Audiences": [ + "00000000-0000-0000-0000-000000000000" // this is the Client ID used for the Azure Bot + ] + }, + + "Connections": { + "BotServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. The default is ClientSecret. + "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", + "ClientId": "00000000-0000-0000-0000-000000000000", // this is the Client ID used for the connection. + "ClientSecret": "00000000-0000-0000-0000-000000000000", // this is the Client Secret used for the connection. + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "BotServiceConnection" + } + ], + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} \ No newline at end of file diff --git a/samples/basic/echo-bot/dotnet/nuget.config b/samples/basic/echo-bot/dotnet/nuget.config new file mode 100644 index 0000000..a431f92 --- /dev/null +++ b/samples/basic/echo-bot/dotnet/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + +