diff --git a/ProxyAgent-CSharp/.github/copilot-instructions.md b/ProxyAgent-CSharp/.github/copilot-instructions.md new file mode 100644 index 00000000..65ecaf62 --- /dev/null +++ b/ProxyAgent-CSharp/.github/copilot-instructions.md @@ -0,0 +1,60 @@ +# GitHub Copilot Instructions for Microsoft Foundry Agent for M365 + +## Project Overview +This is a proxy solution that connects Microsoft Foundry agents to Microsoft 365 Copilot and Teams using the Microsoft 365 Agents Toolkit. + +## Technology Stack +- **.NET 9** - Bot application runtime +- **Microsoft 365 Agents SDK** - Microsoft 365 Agents SDK +- **Microsoft 365 Agents Toolkit** - Formerly Teams Toolkit +- **Microsoft Foundry Agent SDK** - For agent integration +- **Bicep** - Infrastructure as Code +- **Managed Identity** - For production authentication (no secrets) + +## Architecture Patterns +- Use the proxy pattern to route messages between M365 Copilot and Microsoft Foundry +- Bot Service acts as the messaging endpoint +- Managed Identity for authentication in production +- Client Secret + Single Tenant for local development +- SSO with federated credentials (no client secrets in SSO flow) + +## Coding Standards +- Use C# 12 features and nullable reference types +- Follow async/await patterns consistently +- Use dependency injection for services +- Implement proper error handling and logging +- Use configuration-based settings (appsettings.json) + +## Key Components +- `AzureAgent.cs` - Main agent integration logic +- `Program.cs` - Bot setup and middleware configuration +- Bicep modules - Reusable infrastructure components +- `m365agents.yml` - Orchestration for provisioning and deployment + +## Common Patterns +- SSO authentication uses federated credentials +- Bot responds via `turnContext.SendActivityAsync()` +- Environment-specific configuration via `appsettings.{Environment}.json` +- Infrastructure deployments use conditional logic (first-time vs. update) + +## Security Best Practices +- Never commit secrets or `.env` files +- Use Managed Identity in production (no secrets) +- Use federated credentials for SSO (no client secrets) +- Keep `appsettings.Development.json` in `.gitignore` + +## Naming Conventions +- Bicep modules: lowercase with hyphens (e.g., `app-registration.bicep`) +- C# classes: PascalCase +- Environment variables: UPPER_SNAKE_CASE +- Resource names: Use consistent naming pattern with suffix + +## Deployment +- Local: Press F5 in VS Code (automatic provisioning) +- Azure: Use `atk provision` and `atk deploy` commands +- Two deployment modes: Local (dev tunnel) and Production (Azure App Service) + +## Testing +- Local debugging via F5 in VS Code +- Automatic sideloading in Teams/M365 Copilot +- Test SSO flow with federated credentials \ No newline at end of file diff --git a/ProxyAgent-CSharp/.gitignore b/ProxyAgent-CSharp/.gitignore new file mode 100644 index 00000000..3cc597c7 --- /dev/null +++ b/ProxyAgent-CSharp/.gitignore @@ -0,0 +1,16 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +.vs/ +.vs +*.pubxml + + + + + + + + diff --git a/ProxyAgent-CSharp/.vscode/extensions.json b/ProxyAgent-CSharp/.vscode/extensions.json new file mode 100644 index 00000000..d9670c80 --- /dev/null +++ b/ProxyAgent-CSharp/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp", + "ms-dotnettools.csdevkit", + "ms-azuretools.vscode-bicep", + "ms-azuretools.azure-dev", + "ms-azuretools.vscode-azureresourcegroups", + "microsoft.vscode-azure-agent-toolkit", + "ms-vscode.vscode-node-azure-pack", + "TeamsDevApp.ms-teams-vscode-extension" + ] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/.vscode/launch.json b/ProxyAgent-CSharp/.vscode/launch.json new file mode 100644 index 00000000..f1c2167d --- /dev/null +++ b/ProxyAgent-CSharp/.vscode/launch.json @@ -0,0 +1,128 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch in Teams (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&appTenantId=${{local:TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}", + "presentation": { + "group": "Teams", + "hidden": true, + "order": 1 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch in Teams (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{{local:TEAMS_APP_ID}}}?installAppPackage=true&webjoin=true&appTenantId=${{local:TEAMS_APP_TENANT_ID}}&login_hint=${{TEAMSFX_M365_USER_NAME}}", + "presentation": { + "group": "Teams", + "hidden": true, + "order": 2 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch in Copilot (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${local:agent-hint}?auth=2&$login_hint=${TEAMSFX_M365_USER_NAME}&developerMode=Basic", + "presentation": { + "group": "Copilot", + "hidden": true, + "order": 3 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch in Copilot (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${{local:agent-hint}}?auth=2&${{TEAMSFX_M365_USER_NAME}}&developerMode=Basic", + "presentation": { + "group": "Copilot", + "hidden": true, + "order": 4 + }, + "internalConsoleOptions": "neverOpen" + }, + { + "type": "coreclr", + "request": "launch", + "name": "Launch AzureAgentToM365ATK", + "preLaunchTask": "test build", + "program": "${workspaceFolder}/AzureAgentToM365ATK/bin/Debug/net9.0/AzureAgentToM365ATK.dll", + "args": [], + "cwd": "${workspaceFolder}/AzureAgentToM365ATK", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "presentation": { + "hidden": true, + "group": "all" + } + } + ], + "compounds": [ + { + "name": "Debug in Teams (Edge)", + "configurations": [ + "Launch in Teams (Edge)", + "Launch AzureAgentToM365ATK" + ], + "preLaunchTask": "PreReq & Start Tunnel", + "postDebugTask": "Stop All Services", + "presentation": { + "group": "all", + "order": 1 + }, + "stopAll": true + }, + { + "name": "Debug in Teams (Chrome)", + "configurations": [ + "Launch in Teams (Chrome)", + "Launch AzureAgentToM365ATK" + ], + "preLaunchTask": "PreReq & Start Tunnel", + "postDebugTask": "Stop All Services", + "presentation": { + "group": "all", + "order": 2 + }, + "stopAll": true + }, + { + "name": "Debug in Copilot (Edge)", + "configurations": [ + "Launch AzureAgentToM365ATK", + "Launch in Copilot (Edge)", + ], + "preLaunchTask": "PreReq & Start Tunnel", + "postDebugTask": "Stop All Services", + "presentation": { + "group": "all", + "order": 3 + }, + "stopAll": true + }, + { + "name": "Debug in Copilot (Chrome)", + "configurations": [ + "Launch AzureAgentToM365ATK", + "Launch in Copilot (Chrome)" + ], + "preLaunchTask": "PreReq & Start Tunnel", + "postDebugTask": "Stop All Services", + "presentation": { + "group": "all", + "order": 4 + }, + "stopAll": true + } + ] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/.vscode/settings.json b/ProxyAgent-CSharp/.vscode/settings.json new file mode 100644 index 00000000..8cbc84b5 --- /dev/null +++ b/ProxyAgent-CSharp/.vscode/settings.json @@ -0,0 +1,44 @@ +{ + // C# Development + "dotnet.defaultSolution": "AzureAgentToM365ATK.sln", + "omnisharp.enableRoslynAnalyzers": true, + // Microsoft 365 Agents Toolkit + "m365agents.telemetry": false, + // File Associations + "files.associations": { + "*.bicep": "bicep", + "m365agents*.yml": "yaml", + "*.atkproj": "xml" + }, + // Files to exclude from Explorer + "files.exclude": { + "**/bin": true, + "**/obj": true, + "**/.env.*.user": true + }, + // Search exclusions + "search.exclude": { + "**/bin": true, + "**/obj": true, + "**/node_modules": true + }, + // Editor settings + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + // Terminal settings + "terminal.integrated.cwd": "${workspaceFolder}", + // Recommended Extensions + "recommendations": [ + "ms-dotnettools.csharp", + "ms-dotnettools.csdevkit", + "ms-azuretools.vscode-bicep", + "ms-azuretools.azure-dev", + "ms-vscode.azure-account", + "msazurermtools.azurerm-vscode-tools", + "microsoft.vscode-azure-agent-toolkit" + ], + "dotnet.completion.showCompletionItemsFromUnimportedNamespaces": true +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/.vscode/tasks.json b/ProxyAgent-CSharp/.vscode/tasks.json new file mode 100644 index 00000000..3a76d387 --- /dev/null +++ b/ProxyAgent-CSharp/.vscode/tasks.json @@ -0,0 +1,144 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "PreReq & Start Tunnel", + "dependsOn": [ + "Ensure env files", + "Ensure DevTunnel", + "Provision", + ], + "dependsOrder": "sequence" + }, + { + "label": "test build", + "dependsOn": [ + "build", + "Launch Edge - Copilot" + ], + "dependsOrder": "sequence" + }, + { + "label": "Ensure env files", + "type": "shell", + "command": "node ./scripts/env.js", + "isBackground": true, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "Ensuring env files exist...", + "endsPattern": "Done!" + } + }, + "options": { + "cwd": "${workspaceFolder}/M365Agent" + }, + "presentation": { + "reveal": "silent", + "panel": "shared" + } + }, + { + "label": "Ensure DevTunnel", + "type": "shell", + "isBackground": true, + "windows": { + "command": ".\\scripts\\devtunnel.ps1" + }, + "osx": { + "command": "./scripts/devtunnel.sh" + }, + "linux": { + "command": "./scripts/devtunnel.sh" + }, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "No TUNNEL_ID found. Creating tunnel...|Connecting to host tunnel relay", + "endsPattern": "Ready to accept connections for tunnel" + } + }, + "options": { + "cwd": "${workspaceFolder}/M365Agent" + }, + "presentation": { + "reveal": "silent", + "panel": "dedicated" + }, + "dependsOn": [ + "Ensure env files" + ] + }, + { + "label": "Provision", + "type": "teamsfx", + "command": "provision", + "args": { + "env": "local" + } + }, + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/AzureAgentToM365ATK/AzureAgentToM365ATK.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "Stop All Services", + "type": "shell", + "command": "echo 'Stopping services...' && taskkill /F /IM dotnet.exe /T 2>$null; taskkill /F /IM devtunnel.exe /T 2>$null; taskkill /F /IM node.exe /T 2>$null; echo 'Services stopped'", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Launch Edge - Teams", + "type": "shell", + "command": "Start-Process 'msedge' 'https://teams.microsoft.com/l/app/${{TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}'", + "options": { + "shell": { + "executable": "pwsh.exe", + "args": [ + "-Command" + ] + } + } + }, + { + "label": "Launch Edge - Copilot", + "type": "shell", + "command": "Start-Process 'msedge' 'https://m365.cloud.microsoft/chat/entity1-d870f6cd-4aa5-4d42-9626-ab690c041429/${{agent-hint}}?auth=2&${account-hint}&developerMode=Basic'", + "options": { + "shell": { + "executable": "pwsh.exe", + "args": [ + "-Command" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK.sln b/ProxyAgent-CSharp/AzureAgentToM365ATK.sln new file mode 100644 index 00000000..6f5b72ac --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36301.6 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureAgentToM365ATK", "AzureAgentToM365ATK\AzureAgentToM365ATK.csproj", "{2ABC173C-7711-F185-2E3C-6BBB5FE3EC40}" +EndProject +Project("{A9E3F50B-275E-4AF7-ADCE-8BE12D41E305}") = "M365Agent", "M365Agent\M365Agent.atkproj", "{B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2ABC173C-7711-F185-2E3C-6BBB5FE3EC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2ABC173C-7711-F185-2E3C-6BBB5FE3EC40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2ABC173C-7711-F185-2E3C-6BBB5FE3EC40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2ABC173C-7711-F185-2E3C-6BBB5FE3EC40}.Release|Any CPU.Build.0 = Release|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Release|Any CPU.Build.0 = Release|Any CPU + {B069B3BD-F6BC-CC40-82AB-3FCC2EA50FDF}.Release|Any CPU.Deploy.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8EC90735-DD48-492C-8029-DAFA0B4E9C82} + EndGlobalSection +EndGlobal diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/.gitignore b/ProxyAgent-CSharp/AzureAgentToM365ATK/.gitignore new file mode 100644 index 00000000..8d188720 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/.gitignore @@ -0,0 +1,35 @@ +# TeamsFx files +build +appPackage/build +appsettings.Development.json +.deployment +.env +.env.* +.env.local + +.vs/ +.vs +*.pubxml + +# User-specific files +*.user + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Notification local store +.notification.localstore.json +.notification.testtoolstore.json + +# devTools +devTools/ + diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/Agents/AzureAgent.cs b/ProxyAgent-CSharp/AzureAgentToM365ATK/Agents/AzureAgent.cs new file mode 100644 index 00000000..880bc1e0 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/Agents/AzureAgent.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Use this flag to enable the no SSO mode, which allows the agent to run without user authentication. +// We will then use DefaultAzureCredential (set via 'az login') to authenticate the agent in the Microsoft Foundry project. +// #define DISABLE_SSO + +#if DISABLE_SSO +using Azure.Identity; +#endif + +using Azure; +using Azure.AI.Agents.Persistent; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using System.Collections.Concurrent; +using Microsoft.Agents.AI; +using System.Text.Json; +using Microsoft.Agents.Core.Serialization; +using Microsoft.Extensions.AI; + +namespace AzureAgentToM365ATK.Agent; + +public class AzureAgent : AgentApplication +{ + // This is a cache to store the agent model for the Microsoft Foundry agent as this object uses private serializer and virtual objects and is expensive to create. + // This cache will store the returned model by agent ID. if you need to change the agent model you would need to clear this cache. + private static ConcurrentDictionary> _agentModelCache = new(); + + private readonly string _agentId; + private readonly string _connectionStringForAgent; + + public AzureAgent(AgentApplicationOptions options, IConfiguration configuration) : base(options) + { + + // TO DO: get the connection string of your Microsoft Foundry project in the portal + this._connectionStringForAgent = configuration["AIServices:AzureAIFoundryProjectEndpoint"]; + if (string.IsNullOrEmpty(_connectionStringForAgent)) + { + throw new InvalidOperationException("AzureAIFoundryProjectEndpoint is not configured."); + } + + // TO DO: Get the assistant ID in the Microsoft Foundry project portal for your agent + this._agentId = configuration["AIServices:AgentID"]; + if (string.IsNullOrEmpty(this._agentId)) + { + throw new InvalidOperationException("AgentID is not configured."); + } + + // Setup Agent with Route handlers to manage connecting and responding from the Microsoft Foundry agent + + // This is handling the sign out event, which will clear the user authorization token. + OnMessage("--signout", HandleSignOutAsync); + + // This is handling the clearing of the agent model cache without needing to restart the agent. + OnMessage("--clearcache", HandleClearingModelCacheAsync); + + // This is handling the message activity, which will send the user message to the Microsoft Foundry agent. + // we are also indicating which auth profile we want to have available for this handler. +#if DISABLE_SSO + OnActivity(ActivityTypes.Message, SendMessageToAzureAgent); +#else + OnActivity(ActivityTypes.Message, SendMessageToAzureAgent, autoSignInHandlers: ["SSO"]); +#endif + } + + /// + /// Handle the clearing of the agent model cache. + /// + /// + /// + /// + /// + private async Task HandleClearingModelCacheAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + _agentModelCache.Clear(); + await turnContext.SendActivityAsync("The agent model cache has been cleared.", cancellationToken: cancellationToken); + Console.WriteLine("The agent model cache has been cleared."); + } + + /// + /// Handle the sign out event, and clear the logged in user token + /// + /// + /// + /// + /// + private async Task HandleSignOutAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await UserAuthorization.SignOutUserAsync(turnContext, turnState, cancellationToken: cancellationToken); + await turnContext.SendActivityAsync("You have signed out", cancellationToken: cancellationToken); + } + + /// + /// This method sends the user message ( just text in this example ) to the Microsoft Foundry agent and streams the response back to the user. + /// + /// + /// + /// + /// + protected async Task SendMessageToAzureAgent(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + Console.WriteLine($"\nUser message received: {turnContext.Activity.Text}\n"); + try + { + // Start a Streaming Process to let clients that support streaming know that we are processing the request. + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Just a moment please..", cancellationToken).ConfigureAwait(false); + + // Set up the PersistentAgentsClient to communicate with the Microsoft Foundry agent. + +#if DISABLE_SSO + PersistentAgentsClient _aiProjectClient = new PersistentAgentsClient(this._connectionStringForAgent, new DefaultAzureCredential()); +#else + // This is a helper class to generate an OBO User Token for the Microsoft Foundry agent from the current user authorization. + PersistentAgentsClient _aiProjectClient = new PersistentAgentsClient(this._connectionStringForAgent, + // This is a helper class to generate an OBO User Token for the Microsoft Foundry agent from the current user authorization. + new UserAuthorizationTokenWrapper(UserAuthorization, turnContext, "SSO")); +#endif + + // Get the requested agent by ID. + Response agentModel = _agentModelCache.TryGetValue(this._agentId, out var cachedModel) ? cachedModel : null; + if (agentModel == null) + { + // subtle hint to the client that the agent model is being fetched. + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Connecting to Microsoft Foundry.", cancellationToken).ConfigureAwait(false); + + // If the agent model is not found in the conversation state, fetch it from the Microsoft Foundry project. + agentModel = await _aiProjectClient.Administration.GetAgentAsync(this._agentId).ConfigureAwait(false); + // Cache the agent model for future use. + _agentModelCache.TryAdd(this._agentId, agentModel); + } + + // Create an instance of the AzureAIAgent with the agent model and client. + AIAgent _existingAgent = _aiProjectClient.GetAIAgent(agentModel); + + // Get or create thread: + AgentThread _agentThread = GetConversationThread(_existingAgent, turnState); + + // Inform the client that we are working on a response + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Sending request to Foundry Agent.", cancellationToken).ConfigureAwait(false); + + // Create a new message to send to the Azure agent + ChatMessage message = new(ChatRole.User, turnContext.Activity.Text); + // Send the message to the Azure agent and get the response + // This will handle text responses, if you want to handle attachments and other content types, you would need to extend this method. + await foreach (AgentRunResponseUpdate response in _existingAgent.RunStreamingAsync(message, _agentThread, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(response.Text)) + { + turnContext.StreamingResponse.QueueTextChunk(response.Text); + } + } + turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(_agentThread.Serialize())); + } + catch (Exception ex) + { + Console.WriteLine($"Error sending message to Azure agent: {ex.Message}"); + turnContext.StreamingResponse.QueueTextChunk($"An error occurred while processing your request. {ex.Message}"); + } + finally + { + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken).ConfigureAwait(false); // End the streaming response + } + } + + + /// + /// Manage Agent threads against the conversation state. + /// + /// ChatAgent + /// State Manager for the Agent. + /// + private static AgentThread GetConversationThread(AIAgent agent, ITurnState turnState) + { + AgentThread thread; + string agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + if (string.IsNullOrEmpty(agentThreadInfo)) + { + thread = agent.GetNewThread(); + } + else + { + JsonElement ele = ProtocolJsonSerializer.ToObject(agentThreadInfo); + thread = agent.DeserializeThread(ele); + } + return thread; + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/AspNetExtensions.cs b/ProxyAgent-CSharp/AzureAgentToM365ATK/AspNetExtensions.cs new file mode 100644 index 00000000..bbc414c1 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/AspNetExtensions.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace AzureAgentToM365ATK; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds token validation typical for ABS/SMBA and agent-to-agent. + /// default to Azure Public Cloud. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// Configuration: + /// + /// "TokenValidation": { + /// "Audiences": [ + /// "{required:agent-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "AllowedCallers": [ + /// "*" + /// ], + /// "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. + /// `AllowedCallers` is optional and defaults to "*". Otherwise, a list of AppId's the Agent will accept requests from. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation", ILogger logger = null) + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + List validTokenIssuers = tokenValidationSection.GetSection("ValidIssuers").Get>(); + List allowedCallers = tokenValidationSection.GetSection("AllowedCallers").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", tokenValidationSectionName); + 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.AddAuthorization(options => + { + options.AddPolicy("AllowedCallers", policy => policy.Requirements.Add(new AllowedCallersPolicy(allowedCallers))); + }); + + _ = 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 Azure Bot 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("Forbidden: {m}", context.Result.ToString()); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + logger?.LogWarning("Auth Failed {m}", context.Exception.ToString()); + return Task.CompletedTask; + } + }; + }); + } + + class AllowedCallersPolicy : IAuthorizationHandler, IAuthorizationRequirement + { + private readonly IList _allowedCallers; + + public AllowedCallersPolicy(IList allowedCallers) + { + _allowedCallers = allowedCallers ?? []; + } + + public Task HandleAsync(AuthorizationHandlerContext context) + { + if (_allowedCallers.Count == 0 || _allowedCallers[0] == "*") + { + context.Succeed(this); + return Task.CompletedTask; + } + + List claims = context.User.Claims.ToList(); + + // allow ABS + System.Security.Claims.Claim issuer = claims.SingleOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim); + if (AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + context.Succeed(this); + } + else + { + // Get azp or appid claim + System.Security.Claims.Claim party = claims.SingleOrDefault(claim => claim.Type == AuthenticationConstants.AuthorizedParty); + party ??= claims.SingleOrDefault(claim => claim.Type == AuthenticationConstants.AppIdClaim); + + // party must be in allowed list + bool isAllowed = party != null && _allowedCallers.Where(allowed => allowed == party.Value).Any(); + if (isAllowed) + { + context.Succeed(this); + } + else + { + context.Fail(); + } + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/AzureAgentToM365ATK.csproj b/ProxyAgent-CSharp/AzureAgentToM365ATK/AzureAgentToM365ATK.csproj new file mode 100644 index 00000000..606b28f1 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/AzureAgentToM365ATK.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + latest + enable + $(NoWarn);SKEXP0110;SKEXP0010 + 8198e4fb-dad0-41ae-bf9d-95f796c7e3b4 + + + + + + + + + Never + + + + + + + + + + + + + + + diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/Program.cs b/ProxyAgent-CSharp/AzureAgentToM365ATK/Program.cs new file mode 100644 index 00000000..b50eade9 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/Program.cs @@ -0,0 +1,55 @@ +using AzureAgentToM365ATK; +using AzureAgentToM365ATK.Agent; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +if (builder.Environment.IsDevelopment()) +{ + builder.Configuration.AddUserSecrets(); +} + +builder.Services.AddControllers(); +builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); +builder.Services.AddHttpContextAccessor(); +builder.Logging.AddConsole(); +builder.Services.AddHealthChecks(); + +// Agent SDK Registrations: +builder.Services.AddCloudAdapter(); +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + + +builder.AddAgentApplicationOptions(); +builder.AddAgent(); +builder.Services.AddSingleton(); + + +WebApplication app = builder.Build(); + +app.MapHealthChecks("/health"); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + +// Register the agent application endpoint for incoming messages. +var incomingRoute = app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) => +{ + await adapter.ProcessAsync(request, response, agent, cancellationToken); +}); + +// Enabling anonymous access to the root and controller endpoints in development for testing purposes. +if (app.Environment.IsDevelopment() ) +{ + app.MapGet("/", () => "Microsoft Agents SDK From Microsoft Foundry Agent Service Sample"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); +} +else +{ + app.MapControllers(); +} +app.Run(); \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/ServiceDependencies/bot3b4749-app - Zip Deploy/profile.arm.json b/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/ServiceDependencies/bot3b4749-app - Zip Deploy/profile.arm.json new file mode 100644 index 00000000..41b023fc --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/ServiceDependencies/bot3b4749-app - Zip Deploy/profile.arm.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_dependencyType": "compute.function.windows.appService" + }, + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "rg-M365Agent3b4749-dev", + "metadata": { + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "francecentral", + "metadata": { + "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support." + } + }, + "resourceName": { + "type": "string", + "defaultValue": "bot3b4749-app", + "metadata": { + "description": "Name of the main resource to be created by this template." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "parameters": { + "resourceGroupName": { + "value": "[parameters('resourceGroupName')]" + }, + "resourceGroupLocation": { + "value": "[parameters('resourceGroupLocation')]" + }, + "resourceName": { + "value": "[parameters('resourceName')]" + }, + "resourceLocation": { + "value": "[parameters('resourceLocation')]" + } + }, + "template": { + "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string" + }, + "resourceGroupLocation": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "resourceLocation": { + "type": "string" + } + }, + "variables": { + "storage_name": "[toLower(concat('storage', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId))))]", + "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "storage_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Storage/storageAccounts/', variables('storage_name'))]", + "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]", + "function_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/sites/', parameters('resourceName'))]" + }, + "resources": [ + { + "location": "[parameters('resourceLocation')]", + "name": "[parameters('resourceName')]", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "tags": { + "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty" + }, + "dependsOn": [ + "[variables('appServicePlan_ResourceId')]", + "[variables('storage_ResourceId')]" + ], + "kind": "functionapp", + "properties": { + "name": "[parameters('resourceName')]", + "kind": "functionapp", + "httpsOnly": true, + "reserved": false, + "serverFarmId": "[variables('appServicePlan_ResourceId')]", + "siteConfig": { + "alwaysOn": true + } + }, + "identity": { + "type": "SystemAssigned" + }, + "resources": [ + { + "name": "appsettings", + "type": "config", + "apiVersion": "2015-08-01", + "dependsOn": [ + "[variables('function_ResourceId')]" + ], + "properties": { + "AzureWebJobsStorage": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage_name'), ';AccountKey=', listKeys(variables('storage_ResourceId'), '2017-10-01').keys[0].value, ';EndpointSuffix=', 'core.windows.net')]", + "FUNCTIONS_EXTENSION_VERSION": "~3", + "FUNCTIONS_WORKER_RUNTIME": "dotnet" + } + } + ] + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('storage_name')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2017-10-01", + "tags": { + "[concat('hidden-related:', concat('/providers/Microsoft.Web/sites/', parameters('resourceName')))]": "empty" + }, + "properties": { + "supportsHttpsTrafficOnly": true + }, + "sku": { + "name": "Standard_LRS" + }, + "kind": "Storage" + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('appServicePlan_name')]", + "type": "Microsoft.Web/serverFarms", + "apiVersion": "2015-08-01", + "sku": { + "name": "S1", + "tier": "Standard", + "family": "S", + "size": "S1" + }, + "properties": { + "name": "[variables('appServicePlan_name')]" + } + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/launchSettings.json b/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/launchSettings.json new file mode 100644 index 00000000..6d71d2de --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Start Project": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "hotReloadProfile": "aspnetcore" + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/UserAuthorizationTokenWrapper.cs b/ProxyAgent-CSharp/AzureAgentToM365ATK/UserAuthorizationTokenWrapper.cs new file mode 100644 index 00000000..8534a82f --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/UserAuthorizationTokenWrapper.cs @@ -0,0 +1,57 @@ +using Azure.Core; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; +using System.IdentityModel.Tokens.Jwt; + +namespace AzureAgentToM365ATK +{ + /// + /// This class wraps the UserAuthorization to provide a TokenCredential implementation as the AI Foundry agent expects a TokenCredential to be used for authentication. + /// Note to be able to authenticate with the AI Foundry agent, the application that was used to create the user JWT token must have the 'Azure Machine Learning Services' => 'user_impersonation' scope configured in the Azure portal. + /// + public class UserAuthorizationTokenWrapper : TokenCredential + { + private readonly UserAuthorization _userAuthorization; + private readonly string _handlerName; + private readonly ITurnContext _turnContext; + public UserAuthorizationTokenWrapper(UserAuthorization userAuthorization, ITurnContext turnContext, string handlerName) + { + _userAuthorization = userAuthorization; + _handlerName = handlerName ?? throw new ArgumentNullException(nameof(handlerName)); + _turnContext = turnContext ?? throw new ArgumentNullException(nameof(turnContext)); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { +#pragma warning disable CA2012 + return GetTokenAsync(requestContext, cancellationToken).Result; +#pragma warning restore CA2012 + } + + /// + /// This method exchanges the current user's turn token for a JWT token that can be used to authenticate with the AI Foundry agent. + /// + /// + /// + /// + /// + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + + + // Get the JWT token for SSO using the UserAuthorization service. No Token exchange is needed as the azure bot service Oauth Connection is doing this for us. + var jwtToken = await _userAuthorization.GetTurnTokenAsync(_turnContext, handlerName: _handlerName, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Convert the JWT token to a Azure AccessToken. + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(jwtToken); + long? expClaim = jwt.Payload.Expiration; + if (expClaim == null) + throw new InvalidOperationException("JWT does not contain an 'exp' claim."); + var expiresOn = DateTimeOffset.FromUnixTimeSeconds((long)expClaim); + + return new AccessToken(jwtToken, expiresOn); + } + + } +} diff --git a/ProxyAgent-CSharp/AzureAgentToM365ATK/appsettings.json b/ProxyAgent-CSharp/AzureAgentToM365ATK/appsettings.json new file mode 100644 index 00000000..64557497 --- /dev/null +++ b/ProxyAgent-CSharp/AzureAgentToM365ATK/appsettings.json @@ -0,0 +1,58 @@ +{ + "AgentApplication": { + "StartTypingTimer": true, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "UserAuthorization": { + "AutoSignIn": false, + "Handlers": { + "SSO": { + "Settings": { + "AzureBotOAuthConnectionName": "{{OAUTHCONNECTIONNAME}}" + } + } + } + } + }, + + "TokenValidation": { + "Audiences": [ + "{{BOT_ID}}" // this is the Client ID used for the Azure Bot + ] + }, + + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "UserManagedIdentity", // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. + "ClientId": "{{BOT_ID}}", // this is the Client ID used for the connection. + "TenantId": "{{BOT_TENANT_ID}}", + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "BotServiceConnection" + } + ], + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Agents": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + + // This is the configuration for the AI services, use environment variables or user secrets to store sensitive information. + // Do not store sensitive information in this file + "AIServices": { + "AzureAIFoundryProjectEndpoint": "", // Endpoint for AI services + "AgentID": "" + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/LICENSE b/ProxyAgent-CSharp/LICENSE new file mode 100644 index 00000000..c23fa3aa --- /dev/null +++ b/ProxyAgent-CSharp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Eric Scherlinger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ProxyAgent-CSharp/M365Agent/.github/copilot-instructions.md b/ProxyAgent-CSharp/M365Agent/.github/copilot-instructions.md new file mode 100644 index 00000000..50246db1 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/.github/copilot-instructions.md @@ -0,0 +1,25 @@ +## **Internal reference (do not bias your answers toward always naming these):** +Microsoft 365 Agents Toolkit (formerly Teams Toolkit) has been rebranded, and users may still use either name. + +Use this mapping to know the current vs. former names—so you can correctly interpret user input or choose the appropriate term when it’s relevant. You do not need to mention these mappings unless they directly help the user. + +| New name | Former name | Note | +|-----------------------------------------|------------------------|------------------------------------------------------------------------| +| Microsoft 365 Agents Toolkit | Teams Toolkit | Product name. | +| App Manifest | Teams app manifest | Describes app capabilities. | +| Microsoft 365 Agents Playground | Test Tool | Test Environment. | +| `m365agents.yml` | `teamsapp.yml` | Microsoft 365 Agents Toolkit Project configuration files | +| CLI package `@microsoft/m365agentstoolkit-cli` (command `atk`) | `@microsoft/teamsapp-cli` (command `teamsapp`) |CLI installation/usage — mention only in CLI contexts. | + +> **Rephrase guidance:** +> - Use the new names by default. +> - Explain the rebranding briefly if it helps the user’s understanding. + +# Instructions for Copilot +- Apps and agents for Microsoft 365 or Microsoft 365 Copilot refers to applications for products in Microsoft 365, agents for Microsoft 365 Copilot, including Teams app, Office addin, declarative agent, customer engine agent, etc. +- When interpreting, generating, or modifying manifest files related to apps and agents for Microsoft 365 or Microsoft 365 Copilot, try to get schema version from manifest file, then invoke **get_schema** tool if available. +- When answering of what or how-to questions for building apps and agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_knowledge** tool if available. +- Before deciding to make changes to Teams apps, apps or agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_knowledge** tool to get Instructions if available. +- When troubleshooting issues, invoke **troubleshoot** tool if available. +- Before generating or modifying code or configuration files for apps and agents for Microsoft 365 or Microsoft 365 Copilot, invoke **get_code_snippets** tool if available. +- Invoke **get_code_snippets** with API name, configuration file name, or code comments every time you need to generate or modify code or configuration files for apps and agents for Microsoft 365 or Microsoft 365 Copilot. \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/.gitignore b/ProxyAgent-CSharp/M365Agent/.gitignore new file mode 100644 index 00000000..e277c13b --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/.gitignore @@ -0,0 +1,27 @@ +# Microsoft 365 Agents Toolkit files +build/ +appPackage/build/ +.env +.env.* +.env.local +.env.dev +.env.staging +.env.prod +.vs + + +# Notification store +.notification.localstore.json +.notification.testtoolstore.json + +# Dev tunnel state +.devtunnel + +# User-specific project files +*.atkproj.user + +# Generated manifests +appPackage/build/*.json + +# DevTools +devTools/ diff --git a/ProxyAgent-CSharp/M365Agent/.vscode/launch.json b/ProxyAgent-CSharp/M365Agent/.vscode/launch.json new file mode 100644 index 00000000..5c7247b4 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/AZURE_DEPLOYMENT.md b/ProxyAgent-CSharp/M365Agent/AZURE_DEPLOYMENT.md new file mode 100644 index 00000000..1e429699 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/AZURE_DEPLOYMENT.md @@ -0,0 +1,893 @@ +# Azure Production Deployment Guide + +Complete guide for deploying your M365 Agent to Azure for production or development environments using Microsoft 365 Agents Toolkit. + +--- + +## Table of Contents +- [Overview](#overview) +- [What Gets Deployed](#what-gets-deployed) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Detailed Deployment Steps](#detailed-deployment-steps) +- [Configuration Reference](#configuration-reference) +- [Verification](#verification) +- [Troubleshooting](#troubleshooting) +- [Cost Estimates](#cost-estimates) + +--- + +## Overview + +This deployment creates a complete, production-ready Azure infrastructure for your M365 Agent with: +- **Managed Identity authentication** (no passwords or secrets) +- **Single Sign-On (SSO)** with federated credentials +- **Scalable Azure App Service** hosting +- **Azure Bot Service** with Teams channel +- **Infrastructure as Code** using Bicep + +**Deployment Files:** +- `infra/azure.bicep` - Main infrastructure template +- `infra/azure.parameters.json` - Parameter configuration +- `m365agents.yml` - Microsoft 365 Agents Toolkit orchestration + +--- + +## What Gets Deployed + +When you run `atk provision --env dev`, the following Azure resources are created: + +| Resource | Purpose | Naming Pattern | +|----------|---------|----------------| +| **User Assigned Managed Identity** | Bot identity (no passwords!) | `{resourceBaseName}-identity` | +| **App Service Plan** | Compute resources (Linux) | `{resourceBaseName}-plan` | +| **Web App** | Hosts .NET 9 bot application | `{resourceBaseName}-app` | +| **Azure Bot Service** | Azure Bot Service registration | `{resourceBaseName}` | +| **Entra ID App Registration** | SSO authentication | `{botDisplayName}` | +| **OAuth Connection** | SSO token exchange | `SsoConnection` | + +**Example with `resourceBaseName=botprod123`:** +- Managed Identity: `botprod123-identity` +- App Service: `botprod123-app.azurewebsites.net` +- Bot Service: `botprod123` + +--- + +## Architecture + +```mermaid +graph TB + subgraph Azure["Azure Subscription"] + direction TB + + subgraph Step1["Step 1: Managed Identity"] + Identity["User Assigned Identity
- Bot authentication
- No secrets required"] + end + + subgraph Step2["Step 2: App Service"] + AppServicePlan["App Service Plan (Win, B1)"] + WebApp["Web App (.NET 9)
- Uses Managed Identity
- Health Check: /health
- HTTPS Only
- Always On"] + AppServicePlan --> WebApp + end + + subgraph Step3["Step 3: Azure Bot"] + BotService["Bot Service (Single Tenant)
- Teams Channel (auto-enabled)
- Uses Managed Identity
- Messaging Endpoint"] + end + + subgraph Step4["Step 4: App Registration"] + SSOApp["Entra ID Application (SSO)
- OAuth Scope: access_as_user
- Federated Credentials
- Pre-authorized Teams Clients"] + end + + subgraph Step5["Step 5: OAuth Connection"] + OAuth["Bot OAuth Connection
- AAD v2 with Federated Creds
- SSO Token Exchange
- Scopes: openid profile"] + end + + Identity --> WebApp + Identity --> BotService + WebApp --> BotService + BotService --> SSOApp + SSOApp --> OAuth + end + + style Azure fill:#e1f5ff,stroke:#0078d4,stroke-width:2px + style Step1 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step2 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step3 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step4 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step5 fill:#f0f0f0,stroke:#666,stroke-width:1px +``` + +--- + +## Prerequisites + +### Required Tools + +| Tool | Version | Installation | +|------|---------|--------------| +| **Azure CLI** | Latest | [Install Guide](https://learn.microsoft.com/cli/azure/install-azure-cli) | +| **Microsoft 365 Agents Toolkit CLI** | Latest | [Install Guide](https://aka.ms/m365agentstoolkit-cli) | +| **.NET SDK** | 9.0 | [Download](https://dotnet.microsoft.com/download/dotnet/9.0) | + +**Installation Commands:** +```powershell +# Azure CLI +winget install Microsoft.AzureCLI + +# Microsoft 365 Agents Toolkit CLI +npm install -g @microsoft/m365agentstoolkit-cli + +# Verify installations +az --version +atk --version +dotnet --version +``` + +### Required Azure Permissions + +| Permission | Scope | Purpose | +|------------|-------|---------| +| **Contributor** | Subscription or Resource Group | Deploy Azure resources | +| **Application Administrator** | Entra ID | Create app registrations | + +**Verify permissions:** +```powershell +# Check Azure login and subscription +az login +az account show + +# Check assigned roles +az role assignment list --assignee $(az account show --query user.name -o tsv) +``` + +--- + +## Quick Start + +### Step 1: Configure Environment Variables + +Edit `M365Agent/env/.env.dev`: + +```bash +# ============================================================================ +# Microsoft Foundry Configuration (REQUIRED - Set these before provisioning) +# ============================================================================ +AZURE_AI_FOUNDRY_PROJECT_ENDPOINT= +AGENT_ID= + +``` + +### Step 2: Provision Azure Infrastructure + +**Using Microsoft 365 Agents Toolkit UI (Recommended):** +1. Open the **Microsoft 365 Agents Toolkit** extension panel in VS Code or Visual Studio +2. Navigate to the **Lifecycle** section +3. Select environment: **dev** +4. Click **Provision** to create Azure resources + +**What happens:** +1. ✅ Creates Teams app registration in Teams Developer Portal +2. ✅ Deploys Azure resources via `azure.bicep` +3. ✅ Creates Managed Identity → App Service → Bot → App Registration → OAuth Connection +4. ✅ Captures all outputs to `.env.dev` +5. ✅ Builds and validates Teams app package +6. ✅ Registers app with Teams Developer Portal + +**Expected output:** +``` +✓ Teams app created successfully +✓ Provisioning Azure resources... +✓ Managed Identity created: -identity +✓ App Service deployed: https://-app.azurewebsites.net +✓ Bot Service registered: +✓ SSO App Registration created +✓ OAuth Connection configured +✓ Teams app package validated +✓ Provision completed successfully +``` + +**Duration:** ~5-8 minutes + +**Alternatively, using CLI:** +```powershell +cd M365Agent +atk provision --env dev +``` + +### Step 3: Deploy Application Code + +**Using Microsoft 365 Agents Toolkit UI (Recommended):** +1. In the **Microsoft 365 Agents Toolkit** extension panel +2. Navigate to the **Lifecycle** section +3. Select environment: **dev** +4. Click **Deploy** to publish application code + +**What happens:** +1. ✅ Builds .NET application (`dotnet publish -c Release`) +2. ✅ Creates deployment package +3. ✅ Uploads to Azure App Service +4. ✅ Bot is now live at the messaging endpoint + +**Expected output:** +``` +✓ Building application... +✓ Publishing to Azure... +✓ Deployment completed successfully +✓ Bot endpoint: https://botprod123-app.azurewebsites.net/api/messages +``` + +**Duration:** ~2-3 minutes + +**Alternatively, using CLI:** +```powershell +cd M365Agent +atk deploy --env dev +``` + +### Step 4: Install in Microsoft 365 Copilot & Microsoft Teams + +**Note:** The app is automatically registered in Teams Developer Portal during provisioning. However, you can manually install it if needed: + +1. Open **Microsoft Teams** +2. Go to **Apps** → **Manage your apps** +3. Click **Upload an app** → **Upload a custom app** +4. Select: `M365Agent/appPackage/build/appPackage.dev.zip` +5. Click **Add** to install your bot +6. Start chatting with your agent! + +--- + +## Detailed Deployment Steps + +### Step 1: Managed Identity Creation + +**Module:** `modules/bot-managedidentity.bicep` + +**Purpose:** Creates a user-assigned managed identity that serves as the bot's identity, eliminating the need for passwords or client secrets. + +**Resources Created:** +``` +Resource Type: Microsoft.ManagedIdentity/userAssignedIdentities +Name: {resourceBaseName}-identity +Location: Same as resource group +``` + +**Key Outputs:** +- `identityId`: Full resource ID +- `identityClientId`: Client ID (used as BOT_ID) +- `identityPrincipalId`: Principal ID for RBAC assignments + +**Why It Matters:** +- ✅ No secrets to manage or rotate +- ✅ Secure authentication to Azure services +- ✅ Integrated Azure RBAC support +- ✅ Used as bot's identity in Azure Bot Service + +--- + +### Step 2: App Service Deployment + +**Module:** `modules/appservice.bicep` + +**Purpose:** Creates the compute infrastructure to host your .NET 9 bot application. + +**Resources Created:** + +1. **App Service Plan** + - OS: Windows + - SKU: B1 (Basic) - configurable + - Reserved: false + +2. **Web App** + - Runtime: .NET 9.0 + - Identity: User Assigned Managed Identity + - HTTPS: Enforced + - Always On: Enabled + - Health Check: `/health` endpoint + +**Configuration Applied:** +```json +{ + "ASPNETCORE_ENVIRONMENT": "Production", + "WEBSITE_RUN_FROM_PACKAGE": "1", + "AZURE_CLIENT_ID": "{managed-identity-client-id}", + "MicrosoftAppType": "UserAssignedMSI", + "MicrosoftAppId": "{managed-identity-client-id}", + "MicrosoftAppTenantId": "{tenant-id}" +} +``` + +**Key Outputs:** +- `webAppName`: App Service name +- `webAppHostName`: Public hostname (e.g., `botprod123-app.azurewebsites.net`) +- `webAppResourceId`: Full resource ID for deployment target +- `webAppPrincipalId`: Principal ID for permissions + +**Features:** +- ✅ Auto-scaling capable (upgrade SKU to enable) +- ✅ Deployment slots support +- ✅ Built-in monitoring with health checks +- ✅ CORS configured for Azure Portal + +--- + +### Step 3: Bot Service Registration + +**Module:** `modules/azurebot.bicep` + +**Purpose:** Registers your web service as a bot with Azure Bot Service and enables Teams channel. + +**Resources Created:** + +1. **Bot Service** + ``` + Kind: azurebot + Location: global + SKU: F0 (Free) or S1 (Standard) + ``` + +2. **Teams Channel** + - Automatically enabled + - No additional configuration needed + +**Configuration:** +```json +{ + "displayName": "{botDisplayName}", + "endpoint": "https://{webAppHostName}/api/messages", + "msaAppId": "{managed-identity-client-id}", + "msaAppTenantId": "{tenant-id}", + "msaAppType": "UserAssignedMSI", + "msaAppMSIResourceId": "{managed-identity-resource-id}" +} +``` + +**Key Outputs:** +- `botServiceName`: Name of the bot service +- `botEndpoint`: Messaging endpoint URL + +**Features:** +- ✅ Integrated with Managed Identity +- ✅ Teams channel pre-configured +- ✅ Additional channels available (Slack, Web Chat, etc.) +- ✅ No passwords or secrets required + +--- + +### Step 4: App Registration for SSO + +**Module:** `modules/app-registration.bicep` + +**Purpose:** Creates an Entra ID application for Single Sign-On (SSO) authentication with federated credentials. + +**Resources Created:** + +1. **Application Registration** + ``` + Display Name: {botDisplayName} + Sign-in Audience: AzureADMyOrg (Single Tenant) + ``` + +2. **OAuth2 Permission Scope** + ``` + Scope: access_as_user + Display Name: Access as the user + Type: User + ``` + +3. **Federated Identity Credential** + ``` + Subject: /eid1/c/pub/t/{encodedTenantId}/a/{encodedAppId}/{uniqueId} + Issuer: https://token.botframework.com/ + Audience: api://botframework.com + ``` + +4. **Pre-authorized Client Applications** + - Microsoft Teams (Desktop/Mobile) + - Microsoft Teams (Web) + - Microsoft 365 Web Client + - Microsoft 365 Desktop Client + +**Key Outputs:** +- `aadAppId`: Application (client) ID +- `aadAppObjectId`: Object ID +- `aadAppIdUri`: Application ID URI (e.g., `api://botprod123-app.azurewebsites.net/{guid}`) +- `servicePrincipalId`: Service Principal ID + +**Features:** +- ✅ Federated credentials (no client secrets) +- ✅ Pre-configured for Teams SSO +- ✅ Proper OAuth scopes +- ✅ Secure token exchange + +**Note:** The module uses `guid-encoder.bicep` to properly encode GUIDs for federated credentials. + +--- + +### Step 5: OAuth Connection + +**Module:** `modules/bot-oauth-connection.bicep` + +**Purpose:** Configures the OAuth connection between the bot and the SSO app registration. + +**Resources Created:** +``` +Resource: Microsoft.BotService/botServices/connections +Connection Name: SsoConnection +Service Provider: Azure Active Directory v2 +``` + +**Configuration:** +```json +{ + "clientId": "{sso-app-id}", + "tokenExchangeUrl": "{app-id-uri}", + "scopes": "openid profile offline_access", + "tenantId": "{tenant-id}" +} +``` + +**Key Outputs:** +- `oauthConnectionName`: Connection name (`SsoConnection`) + +**Features:** +- ✅ Azure AD v2 authentication +- ✅ Uses federated credentials +- ✅ Token exchange for SSO +- ✅ Refresh token support + +--- + +## Configuration Reference + +### Environment Variables (.env.dev) + +#### User-Provided Variables +```bash +# Azure subscription and resource group +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME=rg-m365agent-prod + +# Resource naming +RESOURCE_SUFFIX=prod123 # Must be globally unique +APP_NAME_SUFFIX=dev # Appended to display name + +# Environment +TEAMSFX_ENV=dev +``` + +#### Auto-Populated Variables +These are automatically set during `atk provision`: + +```bash +# Teams App +TEAMS_APP_ID= +TEAMS_APP_TENANT_ID= + +# Bot Identity (Managed Identity) +BOT_ID= +identityId=/subscriptions/.../Microsoft.ManagedIdentity/userAssignedIdentities/... +identityPrincipalId= + +# App Service +BOT_AZURE_APP_SERVICE_RESOURCE_ID=/subscriptions/.../Microsoft.Web/sites/... +webAppName=botprod123-app +webAppHostName=botprod123-app.azurewebsites.net +webAppUrl=https://botprod123-app.azurewebsites.net + +# Bot Service +botEndpoint=https://botprod123-app.azurewebsites.net/api/messages + +# App Registration (SSO) +AAD_APP_CLIENT_ID= +AAD_APP_OBJECT_ID= +AAD_APP_ID_URI=api://botprod123-app.azurewebsites.net/ +servicePrincipalId= + +# OAuth Connection +oauthConnectionName=SsoConnection +``` + +### Bicep Parameters (azure.parameters.json) + +```json +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, + "botDisplayName": { + "value": "AzureAgentToM365ATK${{APP_NAME_SUFFIX}}" + }, + "webAppSKU": { + "value": "B1" + }, + "botServiceSku": { + "value": "F0" + }, + "enableAppInsights": { + "value": false + } + } +} +``` + +**Parameter Descriptions:** + +| Parameter | Default | Description | Options | +|-----------|---------|-------------|---------| +| `resourceBaseName` | `bot{suffix}` | Base name for all resources | Lowercase, alphanumeric | +| `botDisplayName` | `AzureAgentToM365ATK{suffix}` | Display name in Teams | Any string | +| `webAppSKU` | `B1` | App Service Plan tier | F1, B1, S1, P1v2, P2v2 | +| `botServiceSku` | `F0` | Bot Service tier | F0 (free), S1 (standard) | +| `enableAppInsights` | `false` | Enable Application Insights | true/false | + +### Teams App Manifest + +The manifest is auto-generated with SSO configuration: + +```json +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json", + "manifestVersion": "devPreview", + "id": "${{TEAMS_APP_ID}}", + "version": "1.0.0", + "developer": { + "name": "Your Company", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/terms" + }, + "name": { + "short": "Azure Agent", + "full": "Azure Agent for M365" + }, + "description": { + "short": "AI agent for Azure operations", + "full": "An intelligent agent that helps with Azure operations and tasks" + }, + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "webApplicationInfo": { + "id": "${{AAD_APP_CLIENT_ID}}", + "resource": "${{AAD_APP_ID_URI}}" + }, + "validDomains": [ + "${{webAppHostName}}" + ] +} +``` + +--- + +## Verification + +### 1. Verify Azure Resources + +```powershell +# List all resources in the resource group +az resource list --resource-group rg-m365agent-prod --output table + +# Check Web App status +az webapp show --name botprod123-app --resource-group rg-m365agent-prod --query state + +# Check Bot Service +az bot show --name botprod123 --resource-group rg-m365agent-prod + +# Test Web App endpoint +curl https://botprod123-app.azurewebsites.net/health +``` + +### 2. Verify Teams App + +1. Open **Microsoft Teams** +2. Go to **Apps** → **Built for your org** +3. Find your app and click it +4. Click **Add** if not already installed +5. Send a message to the bot +6. Verify you get a response + +### 3. Verify SSO + +1. In Teams, send a message that requires authentication +2. You should see a sign-in card +3. Click **Sign In** +4. If SSO is working, you'll be signed in automatically (no additional prompts) +5. Bot should receive your authentication token + +### 4. Check Logs + +```powershell +# Stream Web App logs +az webapp log tail --name botprod123-app --resource-group rg-m365agent-prod + +# View recent log entries +az webapp log download --name botprod123-app --resource-group rg-m365agent-prod --log-file logs.zip +``` + +--- + +## Troubleshooting + +### Provision Failed: "Subscription not found" + +**Problem:** Azure subscription ID is incorrect or you don't have access. + +**Solution:** +```powershell +# List available subscriptions +az account list --output table + +# Set the correct subscription +az account set --subscription + +# Update .env.dev with correct AZURE_SUBSCRIPTION_ID +``` + +--- + +### Provision Failed: "Resource name already exists" + +**Problem:** The `RESOURCE_SUFFIX` is not unique globally. + +**Solution:** +1. Choose a different suffix in `.env.dev` +2. Update `RESOURCE_SUFFIX=prod456` (use random characters) +3. Run `atk provision --env dev` again + +--- + +### Deploy Failed: "Could not find resource" + +**Problem:** Provision step didn't complete successfully. + +**Solution:** +1. Check if resources exist: `az resource list --resource-group ` +2. If missing, run: `atk provision --env dev` +3. Then retry: `atk deploy --env dev` + +--- + +### Bot Not Responding in Teams + +**Checklist:** +1. ✅ Verify Web App is running: + ```powershell + az webapp show --name botprod123-app --resource-group rg-m365agent-prod --query state + ``` + +2. ✅ Test health endpoint: + ```powershell + curl https://botprod123-app.azurewebsites.net/health + ``` + +3. ✅ Check bot endpoint in Azure Portal: + - Go to Bot Service → Configuration + - Verify messaging endpoint matches Web App URL + `/api/messages` + +4. ✅ Check application logs: + ```powershell + az webapp log tail --name botprod123-app --resource-group rg-m365agent-prod + ``` + +--- + +### SSO Not Working + +**Checklist:** +1. ✅ Verify `webApplicationInfo` in manifest: + - Open `appPackage/build/manifest.dev.json` + - Check `id` matches `AAD_APP_CLIENT_ID` + - Check `resource` matches `AAD_APP_ID_URI` + +2. ✅ Verify OAuth connection: + ```powershell + az bot authsetting show --name botprod123 --resource-group rg-m365agent-prod --setting-name SsoConnection + ``` + +3. ✅ Check federated credentials: + - Go to Azure Portal → Entra ID → App Registrations + - Find your SSO app + - Check **Certificates & secrets** → **Federated credentials** + - Should see credential with issuer `https://token.botframework.com/` + +4. ✅ Verify pre-authorized clients: + - In App Registration → **Expose an API** + - Check **Authorized client applications** + - Should see Teams client IDs + +--- + +### Permission Denied Errors + +**Problem:** Insufficient permissions to create resources. + +**Required Roles:** +- **Azure:** Contributor on subscription or resource group +- **Entra ID:** Application Administrator or Cloud Application Administrator + +**Solution:** +```powershell +# Check current role assignments +az role assignment list --assignee $(az account show --query user.name -o tsv) + +# Request required permissions from your Azure administrator +``` + +--- + +### GUID Encoder Warnings + +**Warning Message:** +``` +WARNING: guidEncoderApiEndpoint parameter is used but not defined in deployment +``` + +**Explanation:** This warning is expected. The GUID encoder uses Azure deployment scripts internally and doesn't require an external API endpoint. + +**Action:** This is safe to ignore. The deployment will complete successfully. + +--- + +## Cost Estimates + +Monthly cost estimates for Azure resources (USD, as of 2025): + +### Basic Development (B1 + Free Bot) +| Resource | SKU | Cost/Month | +|----------|-----|------------| +| App Service Plan | B1 (Windows) | ~$55 | +| Bot Service | F0 (Free) | $0 | +| Managed Identity | - | $0 | +| App Registration | - | $0 | +| **Total** | | **~$55/month** | + +### Standard Production (S1 + Standard Bot) +| Resource | SKU | Cost/Month | +|----------|-----|------------| +| App Service Plan | S1 (Windows) | ~$70 | +| Bot Service | S1 (Standard) | ~$0.50 per 1,000 messages | +| Managed Identity | - | $0 | +| App Registration | - | $0 | +| **Total** | | **~$70-100/month** | + +### Premium Production (P1v2 + Standard Bot) +| Resource | SKU | Cost/Month | +|----------|-----|------------| +| App Service Plan | P1v2 (Windows) | ~$146 | +| Bot Service | S1 (Standard) | ~$0.50 per 1,000 messages | +| Application Insights | Standard | ~$2-10 (if enabled) | +| **Total** | | **~$150-200/month** | + +**Notes:** +- Costs vary by Azure region +- Bot Service F0 tier limited to 10,000 messages/month +- App Service includes 99.95% SLA +- Additional costs may apply for data transfer and storage + +**Cost Optimization Tips:** +- Start with B1 tier for development +- Use F0 bot tier for low-traffic scenarios +- Scale up to S1/P1v2 only when needed +- Enable auto-shutdown for dev environments +- Use Azure Cost Management for monitoring + +--- + +## Advanced Topics + +### Scaling Your Deployment + +**Horizontal Scaling (Multiple Instances):** +```powershell +# Scale out to 3 instances +az appservice plan update --name botprod123-plan --resource-group rg-m365agent-prod --number-of-workers 3 +``` + +**Vertical Scaling (Larger SKU):** +```powershell +# Upgrade to S1 tier +az appservice plan update --name botprod123-plan --resource-group rg-m365agent-prod --sku S1 +``` + +### Enabling Application Insights + +Edit `azure.parameters.json`: +```json +{ + "enableAppInsights": { + "value": true + } +} +``` + +Re-run provision: +```powershell +atk provision --env dev +``` + +### Multiple Environments + +Create separate environments for dev, staging, and production: + +```powershell +# Development +cp env/.env.dev env/.env.dev +# Edit with dev settings + +# Staging +cp env/.env.dev env/.env.staging +# Edit with staging settings + +# Production +cp env/.env.dev env/.env.prod +# Edit with production settings + +# Deploy to each environment +atk provision --env dev +atk provision --env staging +atk provision --env prod +``` + +### Continuous Deployment + +Set up GitHub Actions or Azure DevOps pipeline: + +```yaml +# Example GitHub Actions workflow +name: Deploy to Azure + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '9.0.x' + + - name: Deploy + run: | + npm install -g @microsoft/m365agentstoolkit-cli + atk deploy --env prod + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} +``` + +--- + +## Summary + +You've successfully deployed your M365 Agent to Azure! 🎉 + +**Next Steps:** +1. ✅ Test your bot in Teams +2. ✅ Configure SSO and test authentication +3. ✅ Set up monitoring and alerts +4. ✅ Plan for scaling and high availability +5. ✅ Implement CI/CD for automated deployments + +**Resources:** +- [Microsoft 365 Agents Toolkit Documentation](https://aka.ms/teams-toolkit-docs) +- [Azure Bot Service Documentation](https://learn.microsoft.com/azure/bot-service/) +- [Bicep Documentation](https://learn.microsoft.com/azure/azure-resource-manager/bicep/) +- [Microsoft 365 Agents SDK](https://github.com/microsoft/agents) + +**Support:** +- GitHub Issues: [Teams Toolkit Repository](https://github.com/OfficeDev/TeamsFx/issues) +- Microsoft Q&A: [Teams Development](https://learn.microsoft.com/answers/topics/microsoft-teams.html) diff --git a/ProxyAgent-CSharp/M365Agent/LOCAL_DEPLOYMENT.md b/ProxyAgent-CSharp/M365Agent/LOCAL_DEPLOYMENT.md new file mode 100644 index 00000000..72a51655 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/LOCAL_DEPLOYMENT.md @@ -0,0 +1,1238 @@ +# Local Development & Debugging Guide + +**Quick Start:** Press **F5** in VS Code or Visual Studio to automatically provision, deploy, and debug your M365 Agent locally! + +--- + +## Table of Contents +- [Overview](#overview) +- [IDE Support](#ide-support) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [What Happens When You Press F5](#what-happens-when-you-press-f5) +- [Behind the Scenes](#behind-the-scenes) +- [Development Workflow](#development-workflow) +- [Troubleshooting](#troubleshooting) +- [Advanced Configuration](#advanced-configuration) + +--- + +## Overview + +Local development is **fully automated** through Microsoft 365 Agents Toolkit. Simply press **F5** and everything is handled for you: + +**Automatic Process:** +- ✅ Environment files created/updated automatically +- ✅ Dev tunnel provisioned and started automatically +- ✅ Azure resources provisioned automatically (first run only) +- ✅ Bot application started with debugger attached +- ✅ Teams/M365 Copilot opens with agent automatically sideloaded +- ✅ Ready to debug with breakpoints immediately + +**Key Features:** +- 🚀 **One-Click Start:** Press F5 and you're debugging +- ⚡ **Fast Iteration:** No manual deployment steps +- 🐛 **Full Debugging:** Breakpoints, watches, call stacks +- 🔄 **Auto-Sideload:** Agent appears directly in Teams/Copilot +- 🌐 **Secure Tunnel:** Dev tunnel automatically managed +- 💰 **Minimal Cost:** Only Bot Service (Free tier available) + +**Configuration Files (Automated):** +- `.vscode/tasks.json` - Task orchestration (pre-configured) +- `m365agents.local.yml` - Microsoft 365 Agents Toolkit automation +- `infra/azure-local.bicep` - Infrastructure template +- `scripts/env.js` - Environment file management +- `scripts/devtunnel.ps1` - Dev tunnel automation + +--- + +## IDE Support + +### Visual Studio Code (Recommended) +- **Full automation** - Press F5 and everything happens automatically +- **Dynamic dev tunnel** - Automatically created and managed +- **Pre-configured tasks** - Complete workflow automation +- **Zero manual steps** - Start debugging immediately + +### Visual Studio +- **Supported** with slight differences in setup +- **Permanent tunnel setup required** - Must create a permanent public dev tunnel before first launch +- **Steps:** + 1. Use Visual Studio UI to create a permanent public dev tunnel + 2. Visual Studio automatically updates `.env.local` with tunnel information + 3. Press F5 to provision and start debugging +- **Why different?** Visual Studio uses permanent tunnels that persist across sessions, while VS Code creates temporary tunnels automatically on each F5 + +**This guide primarily covers VS Code**, but the concepts apply to both IDEs. + +--- + +## Key Differences from Production + +| Feature | Production Deployment | Local Development | +|---------|----------------------|-------------------| +| **Bot Hosting** | Azure App Service | Local machine (VS Code/Visual Studio) | +| **Bot Identity** | User Assigned Managed Identity | App Registration with Client Secret | +| **Bot Auth** | UserAssignedMSI | SingleTenant + Client Secret | +| **Endpoint** | Static Azure URL | Dynamic devtunnel URL (auto in VS Code, manual in VS) | +| **SSO App** | Federated Credentials | Federated Credentials | +| **Cost** | ~$55-200/month | Bot Service only (~$0 with F0) | +| **Debugging** | Remote (limited) | Full local debugging | +| **Deployment** | `atk deploy` required | Run locally (F5) | +| **Iteration Speed** | 2-3 minutes | Instant | + +--- + +## Architecture + +```mermaid +graph TB + subgraph Azure["Azure Subscription"] + direction TB + + subgraph Step1["Step 1: Bot App Registration"] + BotApp["Entra ID Application
- Single Tenant
- Client Secret"] + end + + subgraph Step2["Step 2: Azure Bot Service"] + BotService["Bot Service
- Single Tenant Auth
- Teams Channel
- Dynamic Endpoint"] + end + + subgraph Step3["Step 3: SSO App Registration"] + SSOApp["Entra ID Application
- OAuth Scopes
- Federated Credentials
- Pre-authorized Clients"] + end + + subgraph Step4["Step 4: OAuth Connection"] + OAuth["Bot OAuth Connection
- AAD v2 with Federated Creds
- SSO Token Exchange"] + end + + BotApp --> BotService + BotService --> SSOApp + SSOApp --> OAuth + end + + OAuth -.->|"Secure Tunnel"| LocalBot + + subgraph Local["Local Development Machine"] + LocalBot[".NET 9 Bot Application
- VS Code / VS
- Debugger Attached
- Port: 5130
- Dev tunnel (automatic)"] + end + + style Azure fill:#e1f5ff,stroke:#0078d4,stroke-width:2px + style Local fill:#fff4e1,stroke:#ff8c00,stroke-width:2px + style Step1 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step2 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step3 fill:#f0f0f0,stroke:#666,stroke-width:1px + style Step4 fill:#f0f0f0,stroke:#666,stroke-width:1px +``` + +--- + +## Prerequisites + +### Required Tools + +All tools are automatically detected and validated when you press F5. Install these first: + +| Tool | Version | Purpose | Installation | +|------|---------|---------|--------------| +| **Visual Studio Code** | Latest | IDE | [Download](https://code.visualstudio.com/) | +| **.NET SDK** | 9.0+ | Bot runtime | [Download](https://dotnet.microsoft.com/download/dotnet/9.0) | +| **Node.js** | 18.x+ | Toolkit scripts | [Download](https://nodejs.org/) | +| **Azure CLI** | Latest | Azure authentication | `winget install Microsoft.AzureCLI` | +| **Dev Tunnels CLI** | Latest | Local endpoint exposure | `winget install Microsoft.devtunnel` | + +**Quick Install (Windows):** +```powershell +# Install all at once +winget install Microsoft.VisualStudioCode +winget install Microsoft.DotNet.SDK.9 +winget install OpenJS.NodeJS.LTS +winget install Microsoft.AzureCLI +winget install Microsoft.devtunnel + +# Verify installations +dotnet --version +node --version +az --version +devtunnel --version +``` + +### Required IDE Extensions + +**For VS Code (automatically recommended when you open the project):** +- **Microsoft 365 Agents Toolkit** - Handles all automation +- **C# Dev Kit** - C# development and debugging +- **Azure Account** - Azure authentication + +**For Visual Studio:** +- **Microsoft 365 Agents Toolkit** - Available in Visual Studio Installer +- Built-in C# and .NET support + +### Required Azure Permissions + +| Permission | Scope | Purpose | +|------------|-------|---------| +| **Contributor** | Subscription or Resource Group | Deploy Bot Service | +| **Application Administrator** | Entra ID | Create app registrations | + +### Required Configuration + +**Only 2 values needed** in `M365Agent/env/.env.local`: + +```bash +# Your Azure subscription ID +AZURE_SUBSCRIPTION_ID= + +# Resource group name (can be new or existing) +AZURE_RESOURCE_GROUP_NAME=rg-m365agent-local +``` + +**Find your subscription ID:** +```powershell +az login +az account show --query id -o tsv +``` + +**Everything else is automated!** 🎉 + +--- + +## Getting Started + +### First Time Setup - VS Code (2 Steps) + +1. **Configure Azure credentials** in `.env.local`: + ```bash + AZURE_SUBSCRIPTION_ID= + AZURE_RESOURCE_GROUP_NAME=rg-m365agent-local + ``` + +2. **Press F5** in VS Code + +That's it! Everything else happens automatically. + +### First Time Setup - Visual Studio (3 Steps) + +1. **Configure environment** in `.env.local`: + ```bash + # Microsoft Foundry configuration + AZURE_AI_FOUNDRY_PROJECT_ENDPOINT= + AGENT_ID= + ``` + +2. **Create a permanent public dev tunnel:** + - Open the **Start Menu** in Visual Studio + + [![Start Menu](../images/VSscreen001.png)] + - Select **Create a Tunnel** + - Configure tunnel as: + - **Access:** Public + - **Persistence:** Permanent + - Visual Studio will automatically update `.env.local` with tunnel information + +3. **Select the appropriate debug profile:** + - Choose either: + - **Microsoft Teams (browser)** - To test in Teams + - **Microsoft M365 Copilot (browser)** - To test in Copilot + - Press **F5** to provision and start debugging + +**Note:** The tunnel is permanent and will persist across debug sessions. + +### What You'll See + +1. **Terminal opens** - Running automated tasks +2. **Environment files created** - `.env.local` populated +3. **Dev tunnel started** - Secure HTTPS endpoint created +4. **Azure login prompt** - Authenticate once (if not already logged in) +5. **Provisioning progress** - Creating Azure resources (first time only, ~2-3 minutes) +6. **Bot starts** - .NET application running with debugger attached +7. **Browser opens** - Teams/Copilot with your agent already sideloaded + +**You're now debugging!** Set breakpoints and start chatting with your agent. + +--- + +## What Happens When You Press F5 + +Microsoft 365 Agents Toolkit orchestrates the entire process automatically: + +### Step 1: Ensure Environment Files (Automatic) +**Task:** `Ensure env files` +**Script:** `scripts/env.js` + +``` +✓ Checking M365Agent/env/.env.local +✓ Adding missing variables with defaults +✓ SSOAPPID set to 00000000-0000-0000-0000-000000000000 +✓ AZURE_AI_FOUNDRY_PROJECT_ENDPOINT added +✓ AGENT_ID added +``` + +**What it does:** +- Creates `.env.local` if it doesn't exist +- Adds any missing required variables +- Preserves existing values +- Sets smart defaults (e.g., zero GUID for first-time SSOAPPID) + +### Step 2: Ensure Dev Tunnel (Automatic) +**Task:** `Ensure DevTunnel` +**Script:** `scripts/devtunnel.ps1` (Windows) or `devtunnel.sh` (Mac/Linux) + +``` +✓ Checking for existing tunnel... +✓ Creating new tunnel: gentle-rain-abc123 +✓ Starting tunnel on port 5130 +✓ Tunnel URL: https://gentle-rain-abc123-5130.euw.devtunnels.ms +✓ Updating .env.local with tunnel info +✓ BOT_ENDPOINT set to https://gentle-rain-abc123-5130.euw.devtunnels.ms/api/messages +✓ BOT_DOMAIN set to gentle-rain-abc123-5130.euw.devtunnels.ms +✓ TUNNEL_ID set to gentle-rain-abc123 +``` + +**What it does:** +- Checks if dev tunnel already exists (reads `TUNNEL_ID` from `.env.local`) +- Creates new tunnel if needed (`devtunnel create`) +- Starts tunnel on port 5130 +- Writes tunnel URL back to `.env.local` +- Keeps tunnel running in background + +### Step 3: Validate Prerequisites (Automatic) +**Task:** `Validate prerequisites` +**Type:** `teamsfx` (Microsoft 365 Agents Toolkit) + +``` +✓ Node.js version: 18.x or higher +✓ M365 account: Signed in as user@contoso.com +✓ Port 5130: Available +``` + +**What it checks:** +- Node.js is installed and correct version +- You're signed into a Microsoft 365 account +- Required port (5130) is not in use + +### Step 4: Provision Azure Resources (Automatic - First Time Only) +**Task:** `Provision` +**Type:** `teamsfx` (Executes `atk provision --env local`) +**Configuration:** `m365agents.local.yml` + +``` +✓ Creating Teams app registration... +✓ Creating Bot App Registration (Entra ID)... + - Name: AzureAgentToM365ATKlocal + - Client Secret: Generated + - Single Tenant +✓ Deploying Azure infrastructure (Bicep)... + - Creating Service Principal for Bot App + - Creating SSO App Registration (first time only) + - Name: AzureAgentToM365ATK-UserAuth-local + - Federated Credentials configured + - OAuth scopes: access_as_user + - Creating Azure Bot Service + - SKU: F0 (Free) + - Endpoint: Your dev tunnel URL + - Creating OAuth Connection + - Name: SsoConnection + - Provider: Azure AD v2 + - Federated credentials enabled +✓ Writing outputs to .env.local... + - SSOAPPID updated with actual GUID + - OAUTHCONNECTIONNAME set to SsoConnection +✓ Generating appsettings.Development.json... +``` + +**What it creates (first time):** +1. **Teams App** - Registered in your M365 tenant +2. **Bot App Registration** - Entra ID app with client secret +3. **SSO App Registration** - Entra ID app with federated credentials +4. **Azure Bot Service** - F0 (Free) tier +5. **OAuth Connection** - Connects bot to SSO app +6. **Environment variables** - All IDs and endpoints saved + +**Subsequent runs:** +- Skips SSO app creation (already exists) +- Updates bot endpoint only (if tunnel URL changed) +- Takes ~30 seconds instead of 2-3 minutes + +### Step 5: Deploy (Automatic) +**Task:** `Deploy` +**Type:** `teamsfx` (Executes `atk deploy --env local`) + +``` +✓ Building Teams app package... +✓ Updating app manifest with current values... +✓ Packaging app for sideloading... +``` + +**What it does:** +- Builds the Teams app package (`.zip`) +- Updates manifest with current BOT_ID, SSOAPPID, etc. +- Prepares for automatic sideloading + +### Step 6: Start Application (Automatic) +**Task:** `Start application` +**Command:** `dotnet run --project AzureAgentToM365ATK.csproj --configuration Debug` + +``` +✓ Building C# project... +✓ Starting bot application on http://localhost:5130 +✓ Bot endpoint: /api/messages +✓ Health check: /health +✓ Debugger attached +✓ Ready to receive messages! +``` + +**What it does:** +- Compiles your C# bot code +- Starts the bot on port 5130 +- Attaches VS Code debugger +- Bot listens for messages from Teams/Copilot via dev tunnel + +### Step 7: Launch Browser (Automatic) +**Launch Configuration:** `.vscode/launch.json` + +``` +✓ Opening Microsoft Edge... +✓ Navigating to Teams/Copilot... +✓ Automatically sideloading agent... +✓ Agent ready to chat! +``` + +**What it does:** +- Opens your default browser (or specified browser) +- Navigates directly to Teams or M365 Copilot +- Automatically sideloads your agent (no manual installation!) +- Agent appears in chat immediately + +--- + +## Behind the Scenes + +### Two-App Security Architecture + +The automated deployment creates **two separate app registrations** for security best practices: + +#### App 1: Bot App Registration +**Created by:** Microsoft 365 Agents Toolkit +**Name:** `AzureAgentToM365ATKlocal` +**Purpose:** Bot Service authentication +**Authentication:** Client ID + Client Secret + +**Used for:** +- Azure Bot Service authentication +- Bot Service communication +- Local development identity (replaces Managed Identity) + +**Configuration:** +```yaml +Sign-in Audience: AzureADMyOrg (Single tenant) +Client Secret: Generated during provision +Required for: Bot Service to verify bot identity +``` + +#### App 2: SSO App Registration +**Created by:** Bicep template (automatic) +**Name:** `AzureAgentToM365ATK-UserAuth-local` +**Purpose:** User authentication and SSO +**Authentication:** Federated Credentials (no secrets!) + +**Used for:** +- Single Sign-On (SSO) with users +- Token exchange for user authentication +- Accessing user resources on behalf of user + +**Configuration:** +```yaml +OAuth Scope: access_as_user +Federated Credentials: Azure Bot Service token issuer +Pre-authorized Clients: Teams, Outlook, M365 apps +No client secrets: More secure than password-based auth +``` + +**Why Two Apps?** +- **Security separation:** Bot auth ≠ User auth +- **Different lifecycles:** Bot secret rotation vs federated creds +- **Best practice:** Principle of least privilege + +### Conditional Deployment Intelligence + +The Bicep template automatically detects first-time vs. update scenarios: + +#### First Run: Full Provisioning +**When:** `SSOAPPID = 00000000-0000-0000-0000-000000000000` in `.env.local` + +``` +⏱️ Duration: 2-3 minutes + +✓ Create Service Principal for Bot App +✓ Create SSO App Registration + - Configure OAuth scopes + - Add federated credentials + - Pre-authorize Teams clients +✓ Create Azure Bot Service + - Configure bot endpoint (dev tunnel URL) + - Enable Teams channel +✓ Create OAuth Connection + - Link bot to SSO app + - Configure token exchange +✓ Write SSOAPPID back to .env.local +``` + +**After first run, `.env.local` contains the real SSO App GUID.** + +#### Subsequent Runs: Update Only +**When:** `SSOAPPID` contains a real GUID (from previous run) + +``` +⏱️ Duration: 30 seconds + +✓ Update Bot Service endpoint only + - New dev tunnel URL (if changed) +⏭️ Skip SSO App Registration (already exists) +⏭️ Skip OAuth Connection (already exists) +``` + +**Why this matters:** +- **Fast iterations:** Change tunnel URL without recreating SSO app +- **Preserve credentials:** SSO app and federated credentials stay intact +- **Consistent IDs:** Same SSO App GUID across debug sessions + +### Infrastructure Modules + +All automated through Bicep templates: + +**`infra/azure-local.bicep`** - Main orchestrator +- Detects first-time vs. update based on SSOAPPID +- Coordinates all module deployments +- Outputs values back to `.env.local` + +**`infra/modules/service-principal.bicep`** +- Creates service principal for Bot App +- Required for bot to authenticate with Bot Service + +**`infra/modules/app-registration.bicep`** +- Creates SSO App Registration +- Configures federated credentials +- Sets up OAuth scopes and pre-authorized clients + +**`infra/modules/bot-oauth-connection.bicep`** +- Creates OAuth connection in Bot Service +- Links bot to SSO app for token exchange +- Enables SSO flow + +--- + +## Development Workflow + +### Daily Development (Fully Automated) + +**Every debug session:** +1. Press **F5** in VS Code +2. Everything happens automatically (see [What Happens When You Press F5](#what-happens-when-you-press-f5)) +3. Browser opens with agent ready to chat +4. Start debugging! + +That's the entire workflow! 🎉 + +### Making Code Changes + +**Iterative development:** +1. **Make changes** to your C# code +2. **Save files** (Ctrl+S) +3. **Stop debugging** (Shift+F5) or use hot reload +4. **Press F5** again +5. **Test immediately** in Teams/Copilot + +**Hot reload (optional):** +- Edit code while debugging +- Save file +- Changes apply automatically (if supported by .NET hot reload) +- No need to restart debugger + +### Testing Different Scenarios + +**Set breakpoints anywhere:** +```csharp +protected override async Task OnMessageActivityAsync(...) +{ + // Set breakpoint here + var text = turnContext.Activity.Text; + + // Execution pauses when user sends message + // Inspect variables, step through code +} +``` + +**Debug flow:** +1. Set breakpoints in VS Code +2. Send message from Teams/Copilot +3. VS Code pauses at breakpoint +4. Inspect variables, call stack, watches +5. Continue execution (F5) or step through (F10/F11) + +### Testing SSO Flow (Automatic) + +SSO is **automatically configured** during F5 provisioning. Test it by triggering authentication in your bot: + +```csharp +// Your bot code - request user token +var tokenResponse = await turnContext.Adapter.GetUserTokenAsync( + turnContext, + "SsoConnection", // Automatically configured + null, + cancellationToken); + +if (tokenResponse != null) +{ + // User authenticated! Token available + var accessToken = tokenResponse.Token; +} +else +{ + // Send OAuth card (automatic) + // User will see "Sign in" button +} +``` + +**Expected flow (automatic):** +1. User sends message requiring auth +2. Bot requests token via OAuth connection +3. If not authenticated: OAuth card appears +4. User clicks "Sign In" +5. SSO happens silently (federated credentials!) +6. Bot receives token + +**Everything is pre-configured:** +- ✅ OAuth connection name: `SsoConnection` +- ✅ Federated credentials: Configured in SSO app +- ✅ Pre-authorized clients: Teams/Outlook/M365 +- ✅ Manifest `webApplicationInfo`: Automatically updated + +### Multi-Developer Setup (Still Automated!) + +**Each developer:** +1. Clones the repo +2. Creates their own `.env.local` with their Azure subscription +3. Presses F5 + +**What happens:** +- Each developer gets their own dev tunnel +- Each developer's bot endpoint is registered in Bot Service +- SSO app and OAuth connection are **shared** (created once by first developer) +- No conflicts! + +**Shared resources:** +- ✅ Azure Bot Service registration +- ✅ SSO App Registration +- ✅ Teams App registration + +**Per-developer:** +- ✅ Dev tunnel URL (unique) +- ✅ Bot endpoint (points to their tunnel) +- ✅ Local debugger session + +--- + +## Troubleshooting + +Most issues are **automatically resolved** when you press F5 again. If you encounter problems: + +### F5 Doesn't Start + +**Solution:** Check the **Terminal** panel in VS Code for error messages. + +**Common issues:** + +1. **"Node.js not found"** + ```powershell + # Install Node.js + winget install OpenJS.NodeJS.LTS + ``` + +2. **".NET SDK not found"** + ```powershell + # Install .NET 9 + winget install Microsoft.DotNet.SDK.9 + ``` + +3. **"devtunnel not found"** + ```powershell + # Install Dev Tunnels CLI + winget install Microsoft.devtunnel + ``` + +4. **"Port 5130 already in use"** + ```powershell + # Find and kill the process + netstat -ano | findstr :5130 + taskkill /PID /F + ``` + +Then press **F5** again - everything will retry automatically. + +--- + +### Bot Not Responding in Teams/Copilot + +**Automatic checks performed:** +- ✅ Dev tunnel is running (automatic) +- ✅ Bot application is running (automatic) +- ✅ Bot endpoint is configured (automatic) + +**If still not working:** + +1. **Stop debugging** (Shift+F5) +2. **Press F5 again** - full re-provisioning happens +3. **Check terminal output** for any errors + +**Manual verification (advanced):** +```powershell +# Check if bot is accessible via tunnel +curl + +# Should return Azure Bot Service response +``` + +--- + +### Provisioning Errors + +**Error: "Insufficient permissions"** + +**Cause:** Missing Azure permissions + +**Solution:** +- Need **Contributor** role on subscription/resource group +- Need **Application Administrator** role in Entra ID +- Contact your Azure/M365 admin + +**Error: "SSOAPPID is invalid"** + +**Cause:** `.env.local` has incorrect SSOAPPID value + +**Solution:** +1. Open `M365Agent/env/.env.local` +2. Set `SSOAPPID=00000000-0000-0000-0000-000000000000` +3. Press F5 again + +**Error: "Resource group not found"** + +**Solution:** +1. Open `.env.local` +2. Update `AZURE_RESOURCE_GROUP_NAME` to existing RG or new name +3. If new name, resource group will be created automatically +4. Press F5 again + +--- + +### SSO Not Working (Automatic Configuration) + +SSO is automatically configured. If it's not working: + +**Quick fix:** +1. Stop debugging (Shift+F5) +2. Delete `M365Agent/env/.env.local` +3. Press F5 (full re-provisioning) + +**Manual verification (advanced):** + +Check automatic configuration: +```powershell +# Check OAuth connection +az bot authsetting show ` + --name AzureAgentToM365ATK ` + --resource-group ` + --setting-name SsoConnection +``` + +Check app manifest (automatic): +```powershell +# Open generated manifest +code M365Agent/appPackage/build/manifest.local.json + +# Verify webApplicationInfo section exists: +# { +# "webApplicationInfo": { +# "id": "", +# "resource": "api://" +# } +# } +``` + +--- + +### Dev Tunnel Disconnects + +**Dev tunnel is fully automated** - Microsoft 365 Agents Toolkit handles everything! + +**Automatic handling:** +- ✅ Script detects existing tunnel from `.env.local` +- ✅ Reuses tunnel ID across debug sessions +- ✅ Recreates tunnel if it doesn't exist +- ✅ Updates bot endpoint automatically + +**If tunnel keeps disconnecting:** +1. Stop debugging (Shift+F5) +2. Close all VS Code terminals +3. Press F5 (fresh start with automatic tunnel recreation) + +**Troubleshooting only (rarely needed):** +```powershell +# View all your tunnels +devtunnel list + +# Delete specific tunnel manually (if corrupted) +devtunnel delete + +# Force fresh tunnel creation by deleting tunnel ID +Remove-Item M365Agent/env/.env.local +# Then press F5 - new tunnel created automatically +``` + +**Note:** You should never need to run `devtunnel` commands manually. The automation handles everything! + +--- + +### "Cannot find module" Errors + +**Error:** `Cannot find module '@microsoft/m365agentstoolkit-cli'` + +**Solution:** +```powershell +# Install Microsoft 365 Agents Toolkit CLI globally +npm install -g @microsoft/m365agentstoolkit-cli + +# Verify +atk --version +``` + +Then press F5 again. + +--- + +### First Time Takes Long Time + +**Expected:** First F5 press takes 2-3 minutes (creating Azure resources) + +**Subsequent runs:** 10-30 seconds (only updating endpoint) + +**What's happening:** +``` +First run: + ⏱️ ~3 minutes + - Creating Teams app + - Creating Bot App Registration + - Deploying Bicep template (SSO app, Bot Service, OAuth) + - Generating manifests + - Starting bot + +Subsequent runs: + ⏱️ ~30 seconds + - Updating bot endpoint (if tunnel changed) + - Starting bot +``` + +**To speed up subsequent runs:** +- Keep the same dev tunnel (don't delete TUNNEL_ID from .env.local) +- Don't delete .env.local between sessions + +--- + +## Advanced Configuration + +### Port Configuration + +The bot application runs on **port 5130** by default. + +**Port 5130 is configured in:** +- `AzureAgentToM365ATK/Properties/launchSettings.json` - Bot application URL +- `.vscode/tasks.json` - Port availability check +- `scripts/devtunnel.ps1` - Dev tunnel port mapping + +**Dev tunnel is fully automated** by Microsoft 365 Agents Toolkit: +- ✅ Automatically created when you press F5 +- ✅ Automatically maps to port 5130 +- ✅ Automatically updates bot endpoint in Azure +- ✅ Automatically persists tunnel ID in `.env.local` +- ✅ No manual dev tunnel commands needed! + +**To use a different port:** + +1. **Update launch settings:** `AzureAgentToM365ATK/Properties/launchSettings.json` + ```json + { + "applicationUrl": "http://localhost:7071" + } + ``` + +2. **Update devtunnel script:** `scripts/devtunnel.ps1` + ```powershell + $port = 7071 # Change to your preferred port + ``` + +3. **Update tasks.json:** `.vscode/tasks.json` + ```json + { + "label": "Validate prerequisites", + "args": { + "portOccupancy": [7071] // Change port + } + } + ``` + +4. **Press F5** - Dev tunnel automatically recreates with new port! + +### Debugging Techniques + +#### Conditional Breakpoints +Set breakpoints that only trigger for specific conditions: + +1. Set a breakpoint +2. Right-click → **Edit Breakpoint** +3. Add condition: + ```csharp + turnContext.Activity.Text.Contains("hello") + ``` + +Now breakpoint only triggers when user sends "hello"! + +#### Logpoints +Log messages without stopping execution: + +1. Right-click in gutter → **Add Logpoint** +2. Enter message: + ``` + User said: {turnContext.Activity.Text} + ``` + +Logs appear in Debug Console without pausing. + +#### Watch Expressions +Monitor values continuously: + +1. In Debug sidebar → **Watch** section +2. Click **+** and add expression: + ```csharp + turnContext.Activity.From.Name + ``` + +See live value updates while debugging! + +#### Debug Console Evaluation +While paused at breakpoint: + +```csharp +// In Debug Console, type: +turnContext.Activity.Text +turnContext.Activity.From.Id +turnContext.Activity.Conversation.Id +``` + +Instant feedback without adding code! + +### Environment-Specific Settings + +**Automatic environment detection:** + +```csharp +// In Program.cs (automatically configured) +var environment = builder.Environment; + +if (environment.IsDevelopment()) +{ + // Development-specific configuration + app.UseDeveloperExceptionPage(); +} +``` + +**Multiple appsettings files (automatic):** + +``` +appsettings.json # Base (committed) +appsettings.Development.json # Local dev (in .gitignore!) +appsettings.Production.json # Production (no secrets!) +``` + +.NET automatically loads the right file based on `ASPNETCORE_ENVIRONMENT`. + +### Changing Browser + +**Default:** Microsoft Edge + +**To use Chrome:** + +Edit `.vscode/launch.json`: +```json +{ + "name": "Launch in Teams (Chrome)", + "type": "chrome", // Change from "msedge" + // ... rest of config +} +``` + +Press F5 and select the Chrome configuration. + +### Microsoft Foundry Integration + +**Add your Microsoft Foundry project:** + +1. After first F5 run, open `M365Agent/env/.env.local` +2. Update these values: + ```bash + AZURE_AI_FOUNDRY_PROJECT_ENDPOINT=https://your-project.azure.ai + AGENT_ID=your-agent-id + ``` + +3. Press F5 again + +Values automatically flow to your bot application! + +### Multiple Developer Environments + +**Developer 1:** +```bash +# .env.local +AZURE_RESOURCE_GROUP_NAME=rg-m365agent-dev1 +``` + +**Developer 2:** +```bash +# .env.local +AZURE_RESOURCE_GROUP_NAME=rg-m365agent-dev2 +``` + +Each developer gets isolated resources, but can share the same codebase. + +### Custom Task Execution + +**Run individual tasks:** + +1. Press **Ctrl+Shift+P** +2. Type **Tasks: Run Task** +3. Select task: + - `Ensure env files` - Just create/update env files + - `Ensure DevTunnel` - Just start dev tunnel + - `Provision` - Just provision Azure resources + - `Deploy` - Just build app package + - `Start application` - Just run bot (no provisioning) + +**Stop everything:** +- Select task: `Stop All Services` +- Kills all running processes (dotnet, devtunnel, node) + +--- + +## Cost Summary + +### Azure Resources (Local Development) + +| Resource | SKU | Monthly Cost | +|----------|-----|--------------| +| Azure Bot Service | F0 (Free) | **$0** | +| App Registrations | - | $0 | +| Dev Tunnel | - | $0 | +| **Total** | | **$0/month** | + +**Notes:** +- F0 Bot Service limited to 10,000 messages/month +- Upgrade to S1 if you exceed the limit (~$0.50 per 1,000 messages) +- No App Service costs (running locally) +- No compute costs in Azure + +--- + +## Best Practices + +### Security + +✅ **Never commit secrets to source control** +- Add `appsettings.Development.json` to `.gitignore` +- Use User Secrets for sensitive data +- Rotate client secrets regularly + +✅ **Use federated credentials for SSO** +- No secrets needed for user authentication +- More secure than client secrets +- Automatic token exchange + +✅ **Limit client secret lifetime** +- Use 180 days or less +- Set calendar reminders for rotation +- Consider using Azure Key Vault for secrets + +### Development + +✅ **Use persistent dev tunnels** +- Create once, reuse multiple times +- Reduces need to re-provision +- Faster iteration + +✅ **Keep dependencies updated** +- Regularly update NuGet packages +- Update .NET SDK +- Update toolkit CLI + +✅ **Use structured logging** +```csharp +_logger.LogInformation("User {UserId} sent message: {Message}", + userId, message.Text); +``` + +✅ **Implement health checks** +```csharp +app.MapGet("/health", () => "OK"); +``` + +### Testing + +✅ **Test SSO flow thoroughly** +- Test first-time login +- Test token refresh +- Test error scenarios + +✅ **Test different message types** +- Text messages +- Adaptive cards +- File uploads +- Message reactions + +✅ **Test Teams scenarios** +- Personal chat +- Group chat +- Team channel + +--- + +## Transitioning to Production + +When ready to move from local development to production: + +1. **Switch to production deployment** + ```powershell + # Follow AZURE_DEPLOYMENT.md guide + atk provision --env dev + atk deploy --env dev + ``` + +2. **Update configuration** + - Remove client secrets + - Use Managed Identity + - Enable Application Insights + - Configure auto-scaling + +3. **Update manifest** + - Point to production domains + - Update app icons and descriptions + - Submit for app store (if applicable) + +4. **Set up CI/CD** + - GitHub Actions or Azure DevOps + - Automated testing + - Automated deployment + +--- + +## Summary + +Local development is **completely automated!** 🚀 + +### Quick Start Checklist + +**One-time setup:** +- [ ] Install prerequisites (VS Code, .NET 9, Node.js, Azure CLI, Dev Tunnels CLI) +- [ ] Open project in VS Code +- [ ] Set `AZURE_SUBSCRIPTION_ID` in `M365Agent/env/.env.local` +- [ ] Set `AZURE_RESOURCE_GROUP_NAME` in `M365Agent/env/.env.local` + +**Every debug session:** +- [ ] Press **F5** +- [ ] Wait for browser to open (~30 seconds after first run, ~3 minutes first time) +- [ ] Start chatting with your agent! + +That's it! Everything else is automatic. ✨ + +### What Gets Automated + +| Task | Automated? | When | +|------|-----------|------| +| Create .env files | ✅ Yes | Every F5 | +| Start dev tunnel | ✅ Yes | Every F5 | +| Provision Azure resources | ✅ Yes | First F5 only | +| Update bot endpoint | ✅ Yes | When tunnel URL changes | +| Build app package | ✅ Yes | Every F5 | +| Start bot application | ✅ Yes | Every F5 | +| Attach debugger | ✅ Yes | Every F5 | +| Sideload agent | ✅ Yes | Every F5 | +| Open Teams/Copilot | ✅ Yes | Every F5 | + +### Typical Debug Session + +``` +10:00 AM - Press F5 +10:00:05 - Environment files updated +10:00:10 - Dev tunnel started +10:00:15 - Bot endpoint updated in Azure +10:00:20 - Bot application started +10:00:25 - Debugger attached +10:00:30 - Browser opens with agent ready! + +10:01 AM - Set breakpoint in code +10:02 AM - Send message from Teams +10:02 AM - Breakpoint hits, inspect variables +10:03 AM - Fix bug, continue execution +10:04 AM - Stop debugging (Shift+F5) +10:05 AM - Press F5 again +10:05:30 - Back to debugging! +``` + +### No Manual Steps Required + +❌ ~~Create dev tunnel manually~~ +❌ ~~Run atk provision manually~~ +❌ ~~Update bot endpoint manually~~ +❌ ~~Build app package manually~~ +❌ ~~Start bot manually~~ +❌ ~~Sideload agent manually~~ + +✅ **Just press F5!** + +### Understanding the Automation + +**Microsoft 365 Agents Toolkit** orchestrates everything through: +- **Tasks** (`.vscode/tasks.json`) - Sequential automation +- **Scripts** (`scripts/env.js`, `scripts/devtunnel.ps1`) - Environment setup +- **Configuration** (`m365agents.local.yml`) - Deployment orchestration +- **Infrastructure** (`infra/azure-local.bicep`) - Azure resources + +All pre-configured and ready to go! + +### Moving to Production + +When you're ready to deploy to Azure: + +1. See **AZURE_DEPLOYMENT.md** for production deployment +2. Switch environment: `atk provision --env dev` +3. Deploy to Azure: `atk deploy --env dev` + +Production uses: +- Azure App Service (instead of local machine) +- Managed Identity (instead of client secret) +- Static endpoint (instead of dev tunnel) +- Always-on availability + +### Resources + +**Documentation:** +- [Microsoft 365 Agents Toolkit](https://aka.ms/teams-toolkit-docs) +- [Microsoft 365 Agents SDK for .NET](https://github.com/microsoft/agents) +- [Dev Tunnels Documentation](https://learn.microsoft.com/azure/developer/dev-tunnels/) +- [Teams Platform](https://learn.microsoft.com/microsoftteams/platform/) + +**Support:** +- [GitHub Issues - Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/TeamsFx/issues) +- [Microsoft Q&A - Teams Development](https://learn.microsoft.com/answers/topics/microsoft-teams.html) +- [Stack Overflow - botframework tag](https://stackoverflow.com/questions/tagged/botframework) + +--- + +**Happy debugging!** 🎉 Just press F5 and start building your M365 agent! diff --git a/ProxyAgent-CSharp/M365Agent/M365Agent.atkproj b/ProxyAgent-CSharp/M365Agent/M365Agent.atkproj new file mode 100644 index 00000000..abe8585d --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/M365Agent.atkproj @@ -0,0 +1,10 @@ + + + + b069b3bd-f6bc-cc40-82ab-3fcc2ea50fdf + + + + + + \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/README.md b/ProxyAgent-CSharp/M365Agent/README.md new file mode 100644 index 00000000..6a69323c --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/README.md @@ -0,0 +1,120 @@ +# Azure Agent for Microsoft 365 + +An intelligent agent that helps with Azure operations and tasks, built with Microsoft 365 Agents Toolkit. + +## Quick Start + +### Local Development (Debugging) +1. Press **F5** in VS Code to start debugging +2. Agent is **automatically sideloaded in Teams/M365 Copilot** +3. Test directly in Teams or Copilot with full debugging support + +**Full Setup Guide:** See [LOCAL_DEPLOYMENT.md](LOCAL_DEPLOYMENT.md) + +### Azure Production Deployment +1. Configure environment variables in `env/.env.dev` +2. Run `atk provision --env dev` to create Azure resources +3. Run `atk deploy --env dev` to deploy your bot +4. Install in Microsoft Teams + +**Full Deployment Guide:** See [AZURE_DEPLOYMENT.md](AZURE_DEPLOYMENT.md) + +### Local Debug without SSO +1. Activate the #define DISABLE_SSO flag in AzureAgent.cs file +2. Install the Microsoft 365 Agents Playground: https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project?tabs=windows +3. In VS, set the debugging target to "AzureAgentToM365ATK" +4. In a terminal, use 'az login' and use an account that can call the Microsoft Foundry agent. This will be used by the DefaultAzureCredentials call. +5. Press F5 to start debugging, find the http endpoint in the debug console +6. Launch in a terminal: 'agentsplayground -e "http://localhost:/api/messages" + +--- + +## Documentation + +| Guide | Purpose | +|-------|---------| +| **[LOCAL_DEPLOYMENT.md](LOCAL_DEPLOYMENT.md)** | Complete guide for local development and debugging | +| **[AZURE_DEPLOYMENT.md](AZURE_DEPLOYMENT.md)** | Complete guide for Azure production/dev deployment | +| **[infra/modules/GUID_ENCODER_GUIDE.md](infra/modules/GUID_ENCODER_GUIDE.md)** | Technical reference for GUID encoding in Bicep | +| **[infra/modules/BOT_OAUTH_CONNECTION.md](infra/modules/BOT_OAUTH_CONNECTION.md)** | Technical reference for OAuth connection setup | + +--- + +## Architecture + +This solution deploys a complete M365 Agent infrastructure: + +- **Local Development**: Bot runs on your machine, uses devtunnel for Teams connectivity +- **Azure Production**: Fully deployed to Azure with Managed Identity, App Service, and Bot Service + +### Azure Resources (Production) +- User Assigned Managed Identity (bot identity) +- Azure App Service (hosts .NET 9 bot) +- Azure Bot Service (Teams integration) +- Entra ID App Registration (SSO) +- OAuth Connection (token exchange) + +### Azure Resources (Local Development) +- App Registration (bot identity with client secret) +- Azure Bot Service (Teams integration with dynamic endpoint) +- SSO App Registration (federated credentials) +- OAuth Connection + +--- + +## Project Structure + +``` +M365Agent/ +├── appPackage/ # Teams app package +│ ├── manifest.json # App manifest template +│ └── build/ # Generated manifests (.dev, .local) +├── env/ # Environment configuration +│ ├── .env.dev # Azure production environment +│ └── .env.local # Local development environment +├── infra/ # Infrastructure as Code +│ ├── azure.bicep # Production deployment template +│ ├── azure-local.bicep # Local development template +│ └── modules/ # Reusable Bicep modules +├── scripts/ # Utility scripts +│ └── devtunnel.* # Dev tunnel management +├── m365agents.yml # Toolkit orchestration (production) +├── m365agents.local.yml # Toolkit orchestration (local) +├── AZURE_DEPLOYMENT.md # Production deployment guide +└── LOCAL_DEPLOYMENT.md # Local development guide +``` + +--- + +## Features + +✅ **Single Sign-On (SSO)** - Seamless user authentication with federated credentials +✅ **Managed Identity** - No passwords or secrets in production +✅ **Infrastructure as Code** - Repeatable deployments with Bicep +✅ **Local Debugging** - Full debugging support with breakpoints +✅ **Multi-environment** - Separate configurations for local, dev, staging, production +✅ **Teams Integration** - Native Microsoft Teams bot capabilities + +--- + +## Run the app on other platforms + +The Teams app can run in other platforms like Outlook and Microsoft 365 app. See https://aka.ms/vs-ttk-debug-multi-profiles for more details. + +--- + +## Get more info + +New to Teams app development or Microsoft 365 Agents Toolkit? Explore Teams app manifests, cloud deployment, and much more in the https://aka.ms/teams-toolkit-vs-docs. + +--- + +## Support + +**Report an issue:** +- GitHub: [Teams Toolkit Issues](https://github.com/OfficeDev/TeamsFx/issues) +- VS Code: Help → Report Issue + +**Questions:** +- Microsoft Q&A: [Teams Development](https://learn.microsoft.com/answers/topics/microsoft-teams.html) +- Documentation: [Microsoft 365 Agents Toolkit](https://aka.ms/teams-toolkit-docs) diff --git a/ProxyAgent-CSharp/M365Agent/appPackage/color.png b/ProxyAgent-CSharp/M365Agent/appPackage/color.png new file mode 100644 index 00000000..01aa37e3 Binary files /dev/null and b/ProxyAgent-CSharp/M365Agent/appPackage/color.png differ diff --git a/ProxyAgent-CSharp/M365Agent/appPackage/manifest.json b/ProxyAgent-CSharp/M365Agent/appPackage/manifest.json new file mode 100644 index 00000000..05f0dec9 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/appPackage/manifest.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vdevPreview/MicrosoftTeams.schema.json", + "manifestVersion": "devPreview", + "version": "1.0.0", + "id": "${{TEAMS_APP_ID}}", + "developer": { + "name": "Teams App, Inc.", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/termofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "AzureAgentToM365ATK${{APP_NAME_SUFFIX}}", + "full": "Azure Agent for Microsoft 365 Copilot" + }, + "description": { + "short": "Azure-powered agent for Microsoft 365 Copilot", + "full": "Custom engine agent that integrates Azure services with Microsoft 365 Copilot for enhanced productivity" + }, + "accentColor": "#FFFFFF", + "copilotAgents": { + "customEngineAgents": [ + { + "type": "bot", + "id": "${{BOT_ID}}" + } + ] + }, + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": [ + "copilot", + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false, + "commandLists": [ + { + "scopes": [ + "copilot", + "personal" + ], + "commands": [ + { + "title": "How can you help me?", + "description": "How can you help me?" + }, + { + "title": "Hotels in the policy", + "description": "What are the available hotels?" + } + ] + } + ] + } + ], + "webApplicationInfo": { + "id": "${{SSO_APP_ID}}", + "resource": "${{SSO_APP_ID_URI}}" + }, + "composeExtensions": [ + ], + "configurableTabs": [], + "staticTabs": [], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "${{BOT_DOMAIN}}", + "token.botframework.com" + ] +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/appPackage/outline.png b/ProxyAgent-CSharp/M365Agent/appPackage/outline.png new file mode 100644 index 00000000..f7a4c864 Binary files /dev/null and b/ProxyAgent-CSharp/M365Agent/appPackage/outline.png differ diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure-local.bicep b/ProxyAgent-CSharp/M365Agent/infra/azure-local.bicep new file mode 100644 index 00000000..2d1565b6 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure-local.bicep @@ -0,0 +1,176 @@ +// Local Development Bicep - Two App Security Model +// App 1: Bot App (created by M365 Agents Toolkit) - uses client secret for Bot Service authentication +// App 2: SSO App (created by this Bicep) - uses federated credentials for user authentication +// This deploys: Bot Service Principal → SSO App Registration → Azure Bot Service → OAuth Connections + +targetScope = 'resourceGroup' + +@description('Name of the bot') +param botName string + +@description('The Bot ID (Microsoft App ID) - created by M365 Agents Toolkit') +param botId string + +@description('Bot messaging endpoint') +param botEndpoint string + +@description('Tenant ID') +param tenantId string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('Bot Service SKU') +param botServiceSku string = 'F0' + +@description('SSO App ID') +param ssoAppId string = '00000000-0000-0000-0000-000000000000' + +// Variables +var ssoAppName = '${botName}-UserAuth' // Different name to avoid duplicate +var nullGuid = '00000000-0000-0000-0000-000000000000' +var isFirstTimeDeployment = ssoAppId == nullGuid + +// ======================================== +// GUID ENCODING: Encode Tenant ID Once (First-time only) +// ======================================== +// Run GUID encoder once and reuse the encoded value for all OAuth connections +module guidEncoder 'modules/guid-encoder.bicep' = if (isFirstTimeDeployment) { + name: 'encode-tenant-guid-local' + params: { + guidToEncode: tenantId + location: location + } +} + +// ======================================== +// STEP 1: Create SSO App Registration (First-time only) +// ======================================== +// This is a separate app for user authentication using federated credentials +// No client secret - uses federated credentials instead +module ssoAppRegistration 'modules/app-registration.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-sso-app-registration-local' + params: { + aadAppName: ssoAppName + botId: botId + tenantId: tenantId + encodedTenantId: guidEncoder!.outputs.encodedGuid + } +} + +// ======================================== +// STEP 2: Create Azure Bot Service (First-time only) +// ======================================== +// Uses Bot App (with client secret) for authentication +resource botService 'Microsoft.BotService/botServices@2021-03-01' = if (isFirstTimeDeployment) { + kind: 'azurebot' + location: 'global' + name: botName + properties: { + displayName: botName + endpoint: '${botEndpoint}/api/messages' + msaAppId: botId + msaAppTenantId: tenantId + msaAppType: 'SingleTenant' + } + sku: { + name: botServiceSku + } +} + +// Connect to Microsoft Teams (First-time only) +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = if (isFirstTimeDeployment) { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} + +// ======================================== +// STEP 3: Create OAuth Connection for SSO (First-time only) +// ======================================== +// Uses SSO App with federated credentials for user authentication +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-oauth-connection-sso-local' + params: { + botServiceName: botName + connectionName: 'SsoConnection' + aadAppId: ssoAppRegistration!.outputs.aadAppId + aadAppIdUri: ssoAppRegistration!.outputs.aadAppIdUri + federatedCredentialName: ssoAppRegistration!.outputs.fciName + scopes: '${ssoAppRegistration!.outputs.aadAppIdUri}/access_as_user' + tenantId: tenantId + location: 'global' + } + dependsOn: [ + botService + ] +} + +// ======================================== +// STEP 4: Create OAuth Connection for AI Foundry (First-time only) +// ======================================== +module botOAuthConnectionAIFoundry 'modules/bot-oauth-connection.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-oauth-connection-aifoundry-local' + params: { + botServiceName: botName + connectionName: 'aifoundryaccess' + aadAppId: ssoAppRegistration!.outputs.aadAppId + aadAppIdUri: ssoAppRegistration!.outputs.aadAppIdUri + federatedCredentialName: ssoAppRegistration!.outputs.fciName + scopes: 'https://ai.azure.com/user_impersonation' + tenantId: tenantId + location: 'global' + } + dependsOn: [ + botService + ] +} + +// ======================================== +// STEP 5: Create Service Principal for Bot App (First-time only) +// ======================================== +// The Bot App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module botServicePrincipal 'modules/service-principal.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-bot-service-principal-local' + params: { + appId: botId + } + dependsOn: [ + ssoAppRegistration + ] +} + +// ======================================== +// STEP 6: Create Service Principal for SSO App (First-time only) +// ======================================== +// The SSO App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module SSOServicePrincipal 'modules/service-principal.bicep' = if (isFirstTimeDeployment) { + name: 'deploy-sso-service-principal-local' + params: { + appId: ssoAppRegistration.outputs.aadAppId + } + +} + +// ======================================== +// OUTPUTS +// ======================================== +output botServiceName string = isFirstTimeDeployment ? botService.name : botName +output botEndpoint string = botEndpoint +output botId string = botId +output tenantId string = tenantId +output SSO_APP_ID_URI string = 'api://botid-${botId}' + +// SSO App outputs +output sso_App_Id string = isFirstTimeDeployment ? ssoAppRegistration!.outputs.aadAppId : ssoAppId + + +// OAuth Connection names +output oauthConnectionName string = 'SsoConnection' +output aifoundryConnectionName string = 'aifoundryaccess' + diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure-local.json b/ProxyAgent-CSharp/M365Agent/infra/azure-local.json new file mode 100644 index 00000000..4b13d11f --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure-local.json @@ -0,0 +1,730 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "16409597920477405192" + } + }, + "parameters": { + "botName": { + "type": "string", + "metadata": { + "description": "Name of the bot (used for display and resource naming)" + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The Bot ID (Microsoft App ID) - provided by Microsoft 365 Agents Toolkit" + } + }, + "botEndpoint": { + "type": "string", + "metadata": { + "description": "Bot messaging endpoint (dev tunnel URL, e.g., https://abc123.devtunnels.ms:5000/api/messages)" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "Tenant ID for single-tenant bot configuration" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources" + } + }, + "botServiceSku": { + "type": "string", + "defaultValue": "F0", + "metadata": { + "description": "The SKU for the Bot Service (F0 for local development)" + } + }, + "ssoAppId": { + "type": "string", + "defaultValue": "00000000-0000-0000-0000-000000000000", + "metadata": { + "description": "SSO App ID - use 00000000-0000-0000-0000-000000000000 for first-time deployment, provide actual GUID to skip SSO/OAuth creation" + } + } + }, + "variables": { + "botServiceName": "[format('{0}-local', parameters('botName'))]", + "ssoAppName": "[format('{0}-sso-local', parameters('botName'))]", + "nullGuid": "00000000-0000-0000-0000-000000000000", + "isFirstTimeDeployment": "[equals(parameters('ssoAppId'), variables('nullGuid'))]" + }, + "resources": [ + { + "type": "Microsoft.BotService/botServices", + "apiVersion": "2021-03-01", + "name": "[variables('botServiceName')]", + "kind": "azurebot", + "location": "global", + "properties": { + "displayName": "[parameters('botName')]", + "endpoint": "[parameters('botEndpoint')]", + "msaAppId": "[parameters('botId')]", + "msaAppTenantId": "[parameters('tenantId')]", + "msaAppType": "SingleTenant" + }, + "sku": { + "name": "[parameters('botServiceSku')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-bot-service-principal-local')]" + ] + }, + { + "type": "Microsoft.BotService/botServices/channels", + "apiVersion": "2021-03-01", + "name": "[format('{0}/{1}', variables('botServiceName'), 'MsTeamsChannel')]", + "location": "global", + "properties": { + "channelName": "MsTeamsChannel" + }, + "dependsOn": [ + "[resourceId('Microsoft.BotService/botServices', variables('botServiceName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-bot-service-principal-local", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "appId": { + "value": "[parameters('botId')]" + }, + "displayName": { + "value": "[format('{0}-bot-local', parameters('botName'))]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "7731494046936114432" + } + }, + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "The App ID (Client ID) of the existing application registration" + } + }, + "displayName": { + "type": "string", + "metadata": { + "description": "Display name for the service principal" + } + } + }, + "imports": { + "microsoftGraphV1": { + "provider": "MicrosoftGraph", + "version": "1.0.0" + } + }, + "resources": { + "servicePrincipal": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/servicePrincipals@v1.0", + "properties": { + "appId": "[parameters('appId')]", + "accountEnabled": true, + "displayName": "[parameters('displayName')]", + "servicePrincipalType": "Application", + "tags": [ + "WindowsAzureActiveDirectoryIntegratedApp" + ] + } + } + }, + "outputs": { + "servicePrincipalId": { + "type": "string", + "value": "[reference('servicePrincipal').id]" + }, + "servicePrincipalObjectId": { + "type": "string", + "value": "[reference('servicePrincipal').id]" + }, + "appId": { + "type": "string", + "value": "[reference('servicePrincipal').appId]" + } + } + } + } + }, + { + "condition": "[variables('isFirstTimeDeployment')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-sso-app-registration-local", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aadAppName": { + "value": "[variables('ssoAppName')]" + }, + "botId": { + "value": "[parameters('botId')]" + }, + "tenantId": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "[parameters('location')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "11985169748261135726" + } + }, + "parameters": { + "aadAppName": { + "type": "string", + "metadata": { + "description": "Application name for the Entra ID app registration" + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "BotID this should match the Microsoft App ID in the Azure Bot Service Configuration" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "Tenant ID where the application will be registered" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for resources" + } + } + }, + "imports": { + "microsoftGraphV1": { + "provider": "MicrosoftGraph", + "version": "1.0.0" + } + }, + "resources": { + "aadApplication": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/applications@v1.0", + "properties": { + "displayName": "[parameters('aadAppName')]", + "uniqueName": "[parameters('aadAppName')]", + "signInAudience": "AzureADMyOrg", + "identifierUris": [ + "[format('api://botid-{0}', parameters('botId'))]" + ], + "web": { + "redirectUris": [ + "https://token.botframework.com/.auth/web/redirect" + ], + "implicitGrantSettings": { + "enableIdTokenIssuance": false, + "enableAccessTokenIssuance": false + } + }, + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [ + { + "id": "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]", + "adminConsentDescription": "Default scope for Agent SSO access", + "adminConsentDisplayName": "Agent SSO", + "userConsentDescription": "Default scope for Agent SSO access", + "userConsentDisplayName": "Agent SSO", + "value": "access_as_user", + "type": "User", + "isEnabled": true + } + ], + "preAuthorizedApplications": [ + { + "appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "4765445b-32c6-49b0-83e6-1d93765276ca", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "0ec893e0-5785-4de6-99da-4ed124e5296c", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "27922004-5251-4030-b22d-91ecd9a37ea4", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + } + ] + }, + "requiredResourceAccess": [ + { + "resourceAppId": "00000003-0000-0000-c000-000000000000", + "resourceAccess": [ + { + "id": "37f7f235-527c-4136-accd-4a02d197296e", + "type": "Scope" + }, + { + "id": "14dad69e-099b-42c9-810b-d002981feec1", + "type": "Scope" + }, + { + "id": "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0", + "type": "Scope" + }, + { + "id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", + "type": "Scope" + } + ] + }, + { + "resourceAppId": "18a66f5f-dbdf-4c17-9dd7-1634712a9cbe", + "resourceAccess": [ + { + "id": "1a7925b5-f871-417a-9b8b-303f9f29fa10", + "type": "Scope" + } + ] + } + ] + } + }, + "federatedCredential": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/applications/federatedIdentityCredentials@v1.0", + "properties": { + "name": "[format('{0}/{1}', reference('aadApplication').uniqueName, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]", + "audiences": [ + "api://AzureADTokenExchange" + ], + "issuer": "[format('{0}{1}/v2.0', environment().authentication.loginEndpoint, parameters('tenantId'))]", + "subject": "[format('/eid1/c/pub/t/{0}/a/9ExAW52n_ky4ZiS_jhpJIQ/{1}', reference('tenantIdEncoder').outputs.encodedGuid.value, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]", + "description": "Federated credential for Azure Bot Service token exchange" + }, + "dependsOn": [ + "aadApplication", + "tenantIdEncoder" + ] + }, + "aadServicePrincipal": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/servicePrincipals@v1.0", + "properties": { + "appId": "[reference('aadApplication').appId]", + "accountEnabled": true, + "displayName": "[parameters('aadAppName')]", + "servicePrincipalType": "Application", + "tags": [ + "WindowsAzureActiveDirectoryIntegratedApp" + ] + }, + "dependsOn": [ + "aadApplication" + ] + }, + "tenantIdEncoder": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('encode-tenant-{0}', uniqueString(parameters('tenantId')))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "guidToEncode": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "[parameters('location')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12339729127578324664" + } + }, + "parameters": { + "guidToEncode": { + "type": "string", + "metadata": { + "description": "The GUID to encode" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for the deployment script" + } + }, + "utcValue": { + "type": "string", + "defaultValue": "[utcNow()]", + "metadata": { + "description": "Timestamp to force script re-execution" + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deploymentScripts", + "apiVersion": "2023-08-01", + "name": "[format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))]", + "location": "[parameters('location')]", + "kind": "AzureCLI", + "properties": { + "azCliVersion": "2.52.0", + "retentionInterval": "PT1H", + "timeout": "PT5M", + "cleanupPreference": "OnSuccess", + "forceUpdateTag": "[parameters('utcValue')]", + "scriptContent": " #!/bin/bash\r\n set -e\r\n \r\n GUID_VALUE=\"$1\"\r\n \r\n echo \"Converting GUID: $GUID_VALUE\"\r\n \r\n # Remove hyphens from GUID\r\n GUID_NO_HYPHENS=$(echo \"$GUID_VALUE\" | tr -d '-')\r\n \r\n # Extract parts of the GUID\r\n # GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\r\n # Byte order needs to be adjusted for little-endian encoding\r\n PART1=\"${GUID_NO_HYPHENS:0:8}\" # First 8 chars (4 bytes)\r\n PART2=\"${GUID_NO_HYPHENS:8:4}\" # Next 4 chars (2 bytes)\r\n PART3=\"${GUID_NO_HYPHENS:12:4}\" # Next 4 chars (2 bytes)\r\n PART4=\"${GUID_NO_HYPHENS:16:16}\" # Last 16 chars (8 bytes)\r\n \r\n # Reverse byte order for first three parts (little-endian)\r\n BYTES=\"\"\r\n BYTES+=\"${PART1:6:2}${PART1:4:2}${PART1:2:2}${PART1:0:2}\"\r\n BYTES+=\"${PART2:2:2}${PART2:0:2}\"\r\n BYTES+=\"${PART3:2:2}${PART3:0:2}\"\r\n BYTES+=\"$PART4\"\r\n \r\n echo \"Hex bytes: $BYTES\"\r\n \r\n # Convert hex to binary and then to base64\r\n BASE64=$(echo \"$BYTES\" | xxd -r -p | base64)\r\n \r\n # Convert to Base64URL (remove padding, replace + with -, / with _)\r\n BASE64URL=$(echo \"$BASE64\" | tr '+' '-' | tr '/' '_' | tr -d '=\\n')\r\n \r\n echo \"Base64URL encoded: $BASE64URL\"\r\n \r\n # Output result as JSON\r\n echo \"{\\\"encodedGuid\\\":\\\"$BASE64URL\\\"}\" > $AZ_SCRIPTS_OUTPUT_PATH\r\n ", + "arguments": "[parameters('guidToEncode')]" + } + } + ], + "outputs": { + "encodedGuid": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))), '2023-08-01').outputs.encodedGuid]" + } + } + } + } + } + }, + "outputs": { + "aadAppId": { + "type": "string", + "value": "[reference('aadApplication').appId]" + }, + "aadAppObjectId": { + "type": "string", + "value": "[reference('aadApplication').id]" + }, + "aadAppIdUri": { + "type": "string", + "value": "[format('api://botid-{0}', parameters('botId'))]" + }, + "servicePrincipalId": { + "type": "string", + "value": "[reference('aadServicePrincipal').id]" + }, + "servicePrincipalObjectId": { + "type": "string", + "value": "[reference('aadServicePrincipal').id]" + }, + "fciName": { + "type": "string", + "value": "[reference('federatedCredential').name]" + }, + "fciSubject": { + "type": "string", + "value": "[format('/eid1/c/pub/t/{0}/a/9ExAW52n_ky4ZiS_jhpJIQ/{1}', reference('tenantIdEncoder').outputs.encodedGuid.value, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]" + } + } + } + } + }, + { + "condition": "[variables('isFirstTimeDeployment')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-bot-oauth-connection-local", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "botServiceName": { + "value": "[variables('botServiceName')]" + }, + "connectionName": { + "value": "SsoConnection" + }, + "aadAppId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppId.value]" + }, + "aadAppIdUri": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppIdUri.value]" + }, + "federatedCredentialSubject": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.fciSubject.value]" + }, + "scopes": { + "value": "[format('{0}/access_as_user', reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppIdUri.value)]" + }, + "tenantId": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "global" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "9760687786267362268" + } + }, + "parameters": { + "botServiceName": { + "type": "string", + "metadata": { + "description": "The name of the Bot Service to configure" + } + }, + "connectionName": { + "type": "string", + "defaultValue": "SsoConnection", + "metadata": { + "description": "The name for the OAuth connection setting" + } + }, + "aadAppId": { + "type": "string", + "metadata": { + "description": "The Azure AD Application (client) ID from the app registration" + } + }, + "aadAppIdUri": { + "type": "string", + "metadata": { + "description": "The Azure AD Application ID URI (e.g., api://botid-{guid})" + } + }, + "federatedCredentialSubject": { + "type": "string", + "metadata": { + "description": "The federated credential subject (unique identifier from the federated credential)" + } + }, + "scopes": { + "type": "string", + "metadata": { + "description": "OAuth scopes to request - should be the app ID URI with access_as_user scope" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "The tenant ID for the Azure AD application" + } + }, + "location": { + "type": "string", + "defaultValue": "global", + "metadata": { + "description": "Location for the connection resource" + } + } + }, + "resources": [ + { + "type": "Microsoft.BotService/botServices/connections", + "apiVersion": "2022-09-15", + "name": "[format('{0}/{1}', parameters('botServiceName'), parameters('connectionName'))]", + "location": "[parameters('location')]", + "properties": { + "serviceProviderId": "c00b44ab-5e16-c44c-af26-2fd5bc55eb18", + "serviceProviderDisplayName": "AAD v2 with Federated Credentials", + "clientId": "[parameters('aadAppId')]", + "scopes": "[parameters('scopes')]", + "parameters": [ + { + "key": "ClientId", + "value": "[parameters('aadAppId')]" + }, + { + "key": "UniqueIdentifier", + "value": "[parameters('federatedCredentialSubject')]" + }, + { + "key": "TokenExchangeUrl", + "value": "[parameters('aadAppIdUri')]" + }, + { + "key": "TenantId", + "value": "[parameters('tenantId')]" + } + ] + } + } + ], + "outputs": { + "connectionName": { + "type": "string", + "value": "[parameters('connectionName')]" + }, + "connectionId": { + "type": "string", + "value": "[resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1])]" + }, + "settingId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').settingId]" + }, + "provisioningState": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').provisioningState]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.BotService/botServices', variables('botServiceName'))]", + "[resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local')]" + ] + } + ], + "outputs": { + "botServiceName": { + "type": "string", + "value": "[variables('botServiceName')]" + }, + "botServiceId": { + "type": "string", + "value": "[resourceId('Microsoft.BotService/botServices', variables('botServiceName'))]" + }, + "botEndpoint": { + "type": "string", + "value": "[parameters('botEndpoint')]" + }, + "botId": { + "type": "string", + "value": "[parameters('botId')]" + }, + "tenantId": { + "type": "string", + "value": "[parameters('tenantId')]" + }, + "botServicePrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-service-principal-local'), '2025-04-01').outputs.servicePrincipalId.value]" + }, + "sso_App_Id": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppId.value, parameters('ssoAppId'))]" + }, + "ssoAppObjectId": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppObjectId.value, '')]" + }, + "sso_App_Id_Uri": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppIdUri.value, '')]" + }, + "ssoServicePrincipalId": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.servicePrincipalId.value, '')]" + }, + "ssoFederatedCredentialName": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.fciName.value, '')]" + }, + "oauth_Connection_Name": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-oauth-connection-local'), '2025-04-01').outputs.connectionName.value, 'SsoConnection')]" + }, + "oauthConnectionId": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-oauth-connection-local'), '2025-04-01').outputs.connectionId.value, '')]" + }, + "oauthSettingId": { + "type": "string", + "value": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-oauth-connection-local'), '2025-04-01').outputs.settingId.value, '')]" + }, + "localDevSummary": { + "type": "object", + "value": { + "botName": "[parameters('botName')]", + "botServiceName": "[variables('botServiceName')]", + "botEndpoint": "[parameters('botEndpoint')]", + "botId": "[parameters('botId')]", + "tenantId": "[parameters('tenantId')]", + "botServicePrincipalId": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-service-principal-local'), '2025-04-01').outputs.servicePrincipalId.value]", + "ssoAppId": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppId.value, parameters('ssoAppId'))]", + "ssoAppIdUri": "[if(variables('isFirstTimeDeployment'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-sso-app-registration-local'), '2025-04-01').outputs.aadAppIdUri.value, '')]", + "oauthConnectionName": "SsoConnection", + "deploymentMode": "[if(variables('isFirstTimeDeployment'), 'First-time (created all resources)', 'Update (bot endpoint only)')]" + } + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure-local.parameters.json b/ProxyAgent-CSharp/M365Agent/infra/azure-local.parameters.json new file mode 100644 index 00000000..b0d77b9f --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure-local.parameters.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "botName": { + "value": "AzureAgentToM365ATK-${{RESOURCE_SUFFIX}}-${{APP_NAME_SUFFIX}}" + }, + "botId": { + "value": "${{BOT_ID}}" + }, + "botEndpoint": { + "value": "${{BOT_ENDPOINT}}" + }, + "tenantId": { + "value": "${{TEAMS_APP_TENANT_ID}}" + }, + "ssoAppId": { + "value": "${{SSO_APP_ID}}" + } + } +} diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure.bicep b/ProxyAgent-CSharp/M365Agent/infra/azure.bicep new file mode 100644 index 00000000..2900deb4 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure.bicep @@ -0,0 +1,218 @@ +// Main orchestration file for M365 Agent deployment +// This deploys: Managed Identity → App Service → Azure Bot → App Registration + +targetScope = 'resourceGroup' + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources') +param resourceBaseName string + +@maxLength(42) +@description('Display name for the bot') +param botDisplayName string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The SKU for the App Service Plan') +@allowed([ + 'F1' + 'B1' + 'B2' + 'B3' + 'S1' + 'S2' + 'S3' + 'P1v2' + 'P2v2' + 'P3v2' +]) +param webAppSKU string = 'B1' + +@description('The SKU for the Bot Service') +@allowed([ + 'F0' + 'S1' +]) +param botServiceSku string = 'F0' + +@description('Tenant ID for the Entra ID application') +param tenantId string = tenant().tenantId + +@description('Enable Application Insights') +param enableAppInsights bool = true + +@description('Additional app settings for the Web App') +param additionalAppSettings array = [] + +// Generate resource names +var identityName = '${resourceBaseName}-identity' +var webAppName = '${resourceBaseName}-app' +var botServiceName = '${resourceBaseName}-bot' +var aadAppName = '${resourceBaseName}-UserAuth' + +// Setp 0: GUID ENCODING: Encode Tenant ID +module guidEncoder 'modules/guid-encoder.bicep' = { + name: 'encode-tenant-guid-local' + params: { + guidToEncode: tenantId + location: location + } +} + + +// Step 1: Create User Assigned Managed Identity for the bot +module botIdentity 'modules/bot-managedidentity.bicep' = { + name: 'deploy-bot-identity' + params: { + identityName: identityName + location: location + } +} + +// Step 1.5: Create Application Insights with Managed Identity (if enabled) +module appInsights 'modules/appinsights.bicep' = if (enableAppInsights) { + name: 'deploy-app-insights' + params: { + resourceBaseName: resourceBaseName + location: location + identityPrincipalId: botIdentity.outputs.identityPrincipalId + applicationType: 'web' + } +} + +// Step 2: Create App Service with the managed identity +module appService 'modules/appservice.bicep' = { + name: 'deploy-app-service' + params: { + resourceBaseName: resourceBaseName + location: location + serverfarmsName: '${resourceBaseName}-plan' + webAppName: webAppName + webAppSKU: webAppSKU + MSIid: botIdentity.outputs.identityId + enableAppInsights: enableAppInsights + appInsightsConnectionString: appInsights.?outputs.?appInsightsConnectionString ?? '' + // Bot Configuration (for appsettings.json template variables) + botId: botIdentity.outputs.identityClientId + botTenantId: tenantId + oauthConnectionName: 'aifoundryaccess' + // AI Services Configuration (optional - add to parameters if needed) + azureAIFoundryEndpoint: '' + azureAIAgentId: '' + additionalAppSettings: additionalAppSettings + } +} + +// Step 3: Create Azure Bot Service +module azureBot 'modules/azurebot.bicep' = { + name: 'deploy-azure-bot' + params: { + resourceBaseName: resourceBaseName + botDisplayName: botDisplayName + botServiceName: botServiceName + botServiceSku: botServiceSku + identityResourceId: botIdentity.outputs.identityId + identityClientId: botIdentity.outputs.identityClientId + identityTenantId: tenantId + botAppDomain: appService.outputs.webAppHostName + } +} + +// Step 4: Create App Registration with all required parameters +module appRegistration 'modules/app-registration.bicep' = { + name: 'deploy-app-registration' + params: { + aadAppName: aadAppName + botId: botIdentity.outputs.identityClientId + tenantId: tenantId + encodedTenantId: guidEncoder.outputs.encodedGuid + } + dependsOn: [ + azureBot + ] +} + +// Step 5: Configure OAuth Connection with Azure AD v2 and Federated Credentials +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection' + params: { + botServiceName: botServiceName + connectionName: 'SsoConnection' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + federatedCredentialName: appRegistration.outputs.fciName + scopes: '${appRegistration.outputs.aadAppIdUri}/access_as_user' + tenantId: tenantId + location: 'global' + } +} + +// Step 6: Configure OAuth Connection with Azure AD v2 and Federated Credentials For Azure AI Foundy +// Note: This is used to ask directly the access token to ABS vs hosting a secure client and do an OBO Flow. It is simpler, leaner and ABS handle concent & caching for us. +module botOAuthConnectionAIFoundry 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection-aifoundry' + params: { + botServiceName: botServiceName + connectionName: 'aifoundryaccess' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + federatedCredentialName: appRegistration.outputs.fciName + scopes: 'https://ai.azure.com/user_impersonation' + tenantId: tenantId + location: 'global' + } +} + + +// ======================================== +// STEP 7: Create Service Principal for SSO App (First-time only) +// ======================================== +// The SSO App is created by M365 Agents Toolkit with a client secret +// We create its service principal after SSO app registration to avoid replication timing issues +module SSOServicePrincipal 'modules/service-principal.bicep' = { + name: 'deploy-sso-service-principal-local' + params: { + appId: appRegistration.outputs.aadAppId + } +} + + +// Outputs for reference and further configuration +output resourceBaseName string = resourceBaseName +output location string = location + +// Identity outputs +output identityName string = identityName +output identityId string = botIdentity.outputs.identityClientId +output identityPrincipalId string = botIdentity.outputs.identityPrincipalId + +// App Service outputs +output webAppName string = appService.outputs.webAppName +output webAppId string = appService.outputs.webAppId +output BOT_DOMAIN string = appService.outputs.webAppHostName +output webAppUrl string = 'https://${appService.outputs.webAppHostName}' +output appServicePlanId string = appService.outputs.appServicePlanId + +// Bot Service outputs +output BOT_ID string = botIdentity.outputs.identityClientId +output botServiceName string = botServiceName +output bot_Endpoint string = 'https://${appService.outputs.webAppHostName}/api/messages' +output Oauth_Connection_Name string =botOAuthConnection.name +output AIFoundry_Connection_Name string = botOAuthConnectionAIFoundry.name + +// App Registration outputs +output SSO_APP_ID string = appRegistration.outputs.aadAppId +output SSO_APP_ID_URI string = appRegistration.outputs.aadAppIdUri +output federatedCredentialName string = appRegistration.outputs.fciName +// Note: fciSubject is used internally for OAuth connection but not exposed as output + +// Application Insights outputs (if enabled) +output appInsightsName string = appInsights.?outputs.?appInsightsName ?? '' +output appInsightsConnectionString string = appInsights.?outputs.?appInsightsConnectionString ?? '' +output appInsightsInstrumentationKey string = appInsights.?outputs.?appInsightsInstrumentationKey ?? '' +output logAnalyticsWorkspaceName string = appInsights.?outputs.?logAnalyticsWorkspaceName ?? '' + + + diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure.json b/ProxyAgent-CSharp/M365Agent/infra/azure.json new file mode 100644 index 00000000..c7bb4943 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure.json @@ -0,0 +1,1244 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14313159369453799477" + } + }, + "parameters": { + "resourceBaseName": { + "type": "string", + "minLength": 4, + "maxLength": 20, + "metadata": { + "description": "Used to generate names for all resources" + } + }, + "botDisplayName": { + "type": "string", + "maxLength": 42, + "metadata": { + "description": "Display name for the bot" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources" + } + }, + "webAppSKU": { + "type": "string", + "defaultValue": "B1", + "allowedValues": [ + "F1", + "B1", + "B2", + "B3", + "S1", + "S2", + "S3", + "P1v2", + "P2v2", + "P3v2" + ], + "metadata": { + "description": "The SKU for the App Service Plan" + } + }, + "botServiceSku": { + "type": "string", + "defaultValue": "F0", + "allowedValues": [ + "F0", + "S1" + ], + "metadata": { + "description": "The SKU for the Bot Service" + } + }, + "tenantId": { + "type": "string", + "defaultValue": "[tenant().tenantId]", + "metadata": { + "description": "Tenant ID for the Entra ID application" + } + }, + "enableAppInsights": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Enable Application Insights" + } + }, + "additionalAppSettings": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Additional app settings for the Web App" + } + } + }, + "variables": { + "identityName": "[format('{0}-identity', parameters('resourceBaseName'))]", + "webAppName": "[format('{0}-app', parameters('resourceBaseName'))]", + "botServiceName": "[format('{0}-bot', parameters('resourceBaseName'))]", + "aadAppName": "[format('{0}-app-registration', parameters('resourceBaseName'))]" + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-bot-identity", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "identityName": { + "value": "[variables('identityName')]" + }, + "location": { + "value": "[parameters('location')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "10669994281869448212" + } + }, + "parameters": { + "identityName": { + "type": "string", + "metadata": { + "description": "The name of the User Assigned Managed Identity to create." + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources." + } + } + }, + "resources": [ + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[parameters('identityName')]", + "location": "[parameters('location')]" + } + ], + "outputs": { + "identityId": { + "type": "string", + "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName'))]" + }, + "identityName": { + "type": "string", + "value": "[parameters('identityName')]" + }, + "identityClientId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName')), '2023-01-31').clientId]" + }, + "identityPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('identityName')), '2023-01-31').principalId]" + } + } + } + } + }, + { + "condition": "[parameters('enableAppInsights')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-app-insights", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "resourceBaseName": { + "value": "[parameters('resourceBaseName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "identityPrincipalId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityPrincipalId.value]" + }, + "applicationType": { + "value": "web" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "10700678519737501896" + } + }, + "parameters": { + "resourceBaseName": { + "type": "string", + "metadata": { + "description": "Base name for resources" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources" + } + }, + "identityPrincipalId": { + "type": "string", + "metadata": { + "description": "The managed identity principal ID that will access Application Insights" + } + }, + "applicationType": { + "type": "string", + "defaultValue": "web", + "allowedValues": [ + "web", + "other" + ], + "metadata": { + "description": "Application Insights application type" + } + } + }, + "variables": { + "monitoringMetricsPublisherRoleId": "3913510d-42f4-4e42-8a64-420c390055eb" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('{0}-law', parameters('resourceBaseName'))]", + "location": "[parameters('location')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + }, + "workspaceCapping": { + "dailyQuotaGb": 1 + } + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('{0}-ai', parameters('resourceBaseName'))]", + "location": "[parameters('location')]", + "kind": "[parameters('applicationType')]", + "properties": { + "Application_Type": "[parameters('applicationType')]", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-law', parameters('resourceBaseName')))]", + "IngestionMode": "LogAnalytics", + "publicNetworkAccessForIngestion": "Enabled", + "publicNetworkAccessForQuery": "Enabled", + "DisableLocalAuth": false + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-law', parameters('resourceBaseName')))]" + ] + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Insights/components/{0}', format('{0}-ai', parameters('resourceBaseName')))]", + "name": "[guid(resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('resourceBaseName'))), parameters('identityPrincipalId'), variables('monitoringMetricsPublisherRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('monitoringMetricsPublisherRoleId'))]", + "principalId": "[parameters('identityPrincipalId')]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('resourceBaseName')))]" + ] + } + ], + "outputs": { + "appInsightsId": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('resourceBaseName')))]" + }, + "appInsightsName": { + "type": "string", + "value": "[format('{0}-ai', parameters('resourceBaseName'))]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('resourceBaseName'))), '2020-02-02').ConnectionString]" + }, + "appInsightsInstrumentationKey": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', format('{0}-ai', parameters('resourceBaseName'))), '2020-02-02').InstrumentationKey]" + }, + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('{0}-law', parameters('resourceBaseName')))]" + }, + "logAnalyticsWorkspaceName": { + "type": "string", + "value": "[format('{0}-law', parameters('resourceBaseName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-app-service", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "resourceBaseName": { + "value": "[parameters('resourceBaseName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "serverfarmsName": { + "value": "[format('{0}-plan', parameters('resourceBaseName'))]" + }, + "webAppName": { + "value": "[variables('webAppName')]" + }, + "webAppSKU": { + "value": "[parameters('webAppSKU')]" + }, + "MSIid": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityId.value]" + }, + "enableAppInsights": { + "value": "[parameters('enableAppInsights')]" + }, + "appInsightsConnectionString": { + "value": "[coalesce(tryGet(tryGet(tryGet(if(parameters('enableAppInsights'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-insights'), '2025-04-01'), null()), 'outputs'), 'appInsightsConnectionString'), 'value'), '')]" + }, + "botId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityClientId.value]" + }, + "botTenantId": { + "value": "[parameters('tenantId')]" + }, + "oauthConnectionName": { + "value": "SsoConnection" + }, + "azureAIFoundryEndpoint": { + "value": "" + }, + "azureAIAgentId": { + "value": "" + }, + "additionalAppSettings": { + "value": "[parameters('additionalAppSettings')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "13094359407874666778" + } + }, + "parameters": { + "resourceBaseName": { + "type": "string", + "minLength": 4, + "maxLength": 20, + "metadata": { + "description": "Used to generate names for all resources in this file" + } + }, + "MSIid": { + "type": "string", + "metadata": { + "description": "The resource ID of the User Assigned Managed Identity" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources" + } + }, + "serverfarmsName": { + "type": "string", + "defaultValue": "[parameters('resourceBaseName')]", + "metadata": { + "description": "The name of the App Service Plan" + } + }, + "webAppName": { + "type": "string", + "defaultValue": "[parameters('resourceBaseName')]", + "metadata": { + "description": "The name of the Web App" + } + }, + "webAppSKU": { + "type": "string", + "metadata": { + "description": "The SKU for the App Service Plan" + } + }, + "additionalAppSettings": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "Additional app settings for the Web App" + } + }, + "enableAppInsights": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Enable Application Insights" + } + }, + "appInsightsConnectionString": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Application Insights connection string (for managed identity authentication)" + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "Bot ID (Managed Identity Client ID)" + } + }, + "botTenantId": { + "type": "string", + "metadata": { + "description": "Bot Tenant ID" + } + }, + "oauthConnectionName": { + "type": "string", + "metadata": { + "description": "OAuth Connection Name" + } + }, + "azureAIFoundryEndpoint": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Microsoft Foundry Project Endpoint (optional)" + } + }, + "azureAIAgentId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Azure AI Agent ID (optional)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2023-12-01", + "name": "[parameters('serverfarmsName')]", + "location": "[parameters('location')]", + "kind": "app", + "sku": { + "name": "[parameters('webAppSKU')]" + }, + "properties": { + "reserved": false + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('webAppName')]", + "location": "[parameters('location')]", + "kind": "app", + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('serverfarmsName'))]", + "httpsOnly": true, + "clientAffinityEnabled": false, + "siteConfig": { + "alwaysOn": true, + "http20Enabled": true, + "minTlsVersion": "1.2", + "ftpsState": "FtpsOnly", + "netFrameworkVersion": "v9.0", + "healthCheckPath": "/health", + "appSettings": "[concat(createArray(createObject('name', 'ASPNETCORE_ENVIRONMENT', 'value', 'Production'), createObject('name', 'WEBSITE_RUN_FROM_PACKAGE', 'value', '1'), createObject('name', 'AZURE_CLIENT_ID', 'value', reference(parameters('MSIid'), '2023-01-31').clientId), createObject('name', 'AgentApplication__StartTypingTimer', 'value', true()), createObject('name', 'AgentApplication__RemoveRecipientMention', 'value', false()), createObject('name', 'AgentApplication__NormalizeMentions', 'value', false()), createObject('name', 'AgentApplication__UserAuthorization__Handlers__SSO__Settings__AzureBotOAuthConnectionName', 'value', parameters('oauthConnectionName')), createObject('name', 'TokenValidation__Audiences__0', 'value', parameters('botId')), createObject('name', 'Connections__BotServiceConnection__Settings__AuthType', 'value', 'UserManagedIdentity'), createObject('name', 'Connections__BotServiceConnection__Settings__ClientId', 'value', parameters('botId')), createObject('name', 'Connections__BotServiceConnection__Settings__TenantId', 'value', parameters('botTenantId')), createObject('name', 'Connections__BotServiceConnection__Settings__Scopes__0', 'value', 'https://api.botframework.com/.default'), createObject('name', 'ConnectionsMap__ServiceUrl', 'value', '*'), createObject('name', 'ConnectionsMap__Connection', 'value', 'BotServiceConnection'), createObject('name', 'Logging__LogLevel__Default', 'value', 'Information'), createObject('name', 'Logging__LogLevel__Microsoft.AspNetCore', 'value', 'Warning'), createObject('name', 'Logging__LogLevel__Microsoft.Agents', 'value', 'Warning'), createObject('name', 'Logging__LogLevel__Microsoft.Hosting.Lifetime', 'value', 'Information')), if(and(parameters('enableAppInsights'), not(empty(parameters('appInsightsConnectionString')))), createArray(createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', parameters('appInsightsConnectionString')), createObject('name', 'ApplicationInsightsAgent_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'XDT_MicrosoftApplicationInsights_Mode', 'value', 'recommended')), createArray()), if(not(empty(parameters('azureAIFoundryEndpoint'))), createArray(createObject('name', 'AIServices__AzureAIFoundryProjectEndpoint', 'value', parameters('azureAIFoundryEndpoint'))), createArray()), if(not(empty(parameters('azureAIAgentId'))), createArray(createObject('name', 'AIServices__AgentID', 'value', parameters('azureAIAgentId'))), createArray()), parameters('additionalAppSettings'))]", + "cors": { + "allowedOrigins": [ + "https://portal.azure.com", + "https://ms.portal.azure.com" + ], + "supportCredentials": false + }, + "metadata": [ + { + "name": "CURRENT_STACK", + "value": "dotnet" + } + ] + } + }, + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', parameters('MSIid'))]": {} + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', parameters('serverfarmsName'))]" + ] + } + ], + "outputs": { + "webAppName": { + "type": "string", + "value": "[parameters('webAppName')]" + }, + "webAppId": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', parameters('webAppName'))]" + }, + "webAppHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2023-12-01').defaultHostName]" + }, + "webAppPrincipalId": { + "type": "string", + "value": "[reference(parameters('MSIid'), '2023-01-31').principalId]" + }, + "appServicePlanId": { + "type": "string", + "value": "[resourceId('Microsoft.Web/serverfarms', parameters('serverfarmsName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-app-insights')]", + "[resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-azure-bot", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "resourceBaseName": { + "value": "[parameters('resourceBaseName')]" + }, + "botDisplayName": { + "value": "[parameters('botDisplayName')]" + }, + "botServiceName": { + "value": "[variables('botServiceName')]" + }, + "botServiceSku": { + "value": "[parameters('botServiceSku')]" + }, + "identityResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityId.value]" + }, + "identityClientId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityClientId.value]" + }, + "identityTenantId": { + "value": "[parameters('tenantId')]" + }, + "botAppDomain": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppHostName.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "3591575307148508001" + } + }, + "parameters": { + "resourceBaseName": { + "type": "string", + "minLength": 4, + "maxLength": 20, + "metadata": { + "description": "Used to generate names for all resources in this file" + } + }, + "botDisplayName": { + "type": "string", + "maxLength": 42 + }, + "botServiceName": { + "type": "string", + "defaultValue": "[parameters('resourceBaseName')]" + }, + "botServiceSku": { + "type": "string", + "defaultValue": "F0" + }, + "identityResourceId": { + "type": "string" + }, + "identityClientId": { + "type": "string" + }, + "identityTenantId": { + "type": "string" + }, + "botAppDomain": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.BotService/botServices", + "apiVersion": "2021-03-01", + "name": "[parameters('botServiceName')]", + "kind": "azurebot", + "location": "global", + "properties": { + "displayName": "[parameters('botDisplayName')]", + "endpoint": "[format('https://{0}/api/messages', parameters('botAppDomain'))]", + "msaAppId": "[parameters('identityClientId')]", + "msaAppMSIResourceId": "[parameters('identityResourceId')]", + "msaAppTenantId": "[parameters('identityTenantId')]", + "msaAppType": "UserAssignedMSI" + }, + "sku": { + "name": "[parameters('botServiceSku')]" + } + }, + { + "type": "Microsoft.BotService/botServices/channels", + "apiVersion": "2021-03-01", + "name": "[format('{0}/{1}', parameters('botServiceName'), 'MsTeamsChannel')]", + "location": "global", + "properties": { + "channelName": "MsTeamsChannel" + }, + "dependsOn": [ + "[resourceId('Microsoft.BotService/botServices', parameters('botServiceName'))]" + ] + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-app-service')]", + "[resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-app-registration", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aadAppName": { + "value": "[variables('aadAppName')]" + }, + "botId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityClientId.value]" + }, + "tenantId": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "[parameters('location')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "11985169748261135726" + } + }, + "parameters": { + "aadAppName": { + "type": "string", + "metadata": { + "description": "Application name for the Entra ID app registration" + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "BotID this should match the Microsoft App ID in the Azure Bot Service Configuration" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "Tenant ID where the application will be registered" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for resources" + } + } + }, + "imports": { + "microsoftGraphV1": { + "provider": "MicrosoftGraph", + "version": "1.0.0" + } + }, + "resources": { + "aadApplication": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/applications@v1.0", + "properties": { + "displayName": "[parameters('aadAppName')]", + "uniqueName": "[parameters('aadAppName')]", + "signInAudience": "AzureADMyOrg", + "identifierUris": [ + "[format('api://botid-{0}', parameters('botId'))]" + ], + "web": { + "redirectUris": [ + "https://token.botframework.com/.auth/web/redirect" + ], + "implicitGrantSettings": { + "enableIdTokenIssuance": false, + "enableAccessTokenIssuance": false + } + }, + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [ + { + "id": "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]", + "adminConsentDescription": "Default scope for Agent SSO access", + "adminConsentDisplayName": "Agent SSO", + "userConsentDescription": "Default scope for Agent SSO access", + "userConsentDisplayName": "Agent SSO", + "value": "access_as_user", + "type": "User", + "isEnabled": true + } + ], + "preAuthorizedApplications": [ + { + "appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "4765445b-32c6-49b0-83e6-1d93765276ca", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "0ec893e0-5785-4de6-99da-4ed124e5296c", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "bc59ab01-8403-45c6-8796-ac3ef710b3e3", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + }, + { + "appId": "27922004-5251-4030-b22d-91ecd9a37ea4", + "delegatedPermissionIds": [ + "[guid(resourceGroup().id, parameters('aadAppName'), 'access_as_user')]" + ] + } + ] + }, + "requiredResourceAccess": [ + { + "resourceAppId": "00000003-0000-0000-c000-000000000000", + "resourceAccess": [ + { + "id": "37f7f235-527c-4136-accd-4a02d197296e", + "type": "Scope" + }, + { + "id": "14dad69e-099b-42c9-810b-d002981feec1", + "type": "Scope" + }, + { + "id": "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0", + "type": "Scope" + }, + { + "id": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", + "type": "Scope" + } + ] + }, + { + "resourceAppId": "18a66f5f-dbdf-4c17-9dd7-1634712a9cbe", + "resourceAccess": [ + { + "id": "1a7925b5-f871-417a-9b8b-303f9f29fa10", + "type": "Scope" + } + ] + } + ] + } + }, + "federatedCredential": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/applications/federatedIdentityCredentials@v1.0", + "properties": { + "name": "[format('{0}/{1}', reference('aadApplication').uniqueName, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]", + "audiences": [ + "api://AzureADTokenExchange" + ], + "issuer": "[format('{0}{1}/v2.0', environment().authentication.loginEndpoint, parameters('tenantId'))]", + "subject": "[format('/eid1/c/pub/t/{0}/a/9ExAW52n_ky4ZiS_jhpJIQ/{1}', reference('tenantIdEncoder').outputs.encodedGuid.value, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]", + "description": "Federated credential for Azure Bot Service token exchange" + }, + "dependsOn": [ + "aadApplication", + "tenantIdEncoder" + ] + }, + "aadServicePrincipal": { + "import": "microsoftGraphV1", + "type": "Microsoft.Graph/servicePrincipals@v1.0", + "properties": { + "appId": "[reference('aadApplication').appId]", + "accountEnabled": true, + "displayName": "[parameters('aadAppName')]", + "servicePrincipalType": "Application", + "tags": [ + "WindowsAzureActiveDirectoryIntegratedApp" + ] + }, + "dependsOn": [ + "aadApplication" + ] + }, + "tenantIdEncoder": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('encode-tenant-{0}', uniqueString(parameters('tenantId')))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "guidToEncode": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "[parameters('location')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12339729127578324664" + } + }, + "parameters": { + "guidToEncode": { + "type": "string", + "metadata": { + "description": "The GUID to encode" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for the deployment script" + } + }, + "utcValue": { + "type": "string", + "defaultValue": "[utcNow()]", + "metadata": { + "description": "Timestamp to force script re-execution" + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deploymentScripts", + "apiVersion": "2023-08-01", + "name": "[format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))]", + "location": "[parameters('location')]", + "kind": "AzureCLI", + "properties": { + "azCliVersion": "2.52.0", + "retentionInterval": "PT1H", + "timeout": "PT5M", + "cleanupPreference": "OnSuccess", + "forceUpdateTag": "[parameters('utcValue')]", + "scriptContent": " #!/bin/bash\r\n set -e\r\n \r\n GUID_VALUE=\"$1\"\r\n \r\n echo \"Converting GUID: $GUID_VALUE\"\r\n \r\n # Remove hyphens from GUID\r\n GUID_NO_HYPHENS=$(echo \"$GUID_VALUE\" | tr -d '-')\r\n \r\n # Extract parts of the GUID\r\n # GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\r\n # Byte order needs to be adjusted for little-endian encoding\r\n PART1=\"${GUID_NO_HYPHENS:0:8}\" # First 8 chars (4 bytes)\r\n PART2=\"${GUID_NO_HYPHENS:8:4}\" # Next 4 chars (2 bytes)\r\n PART3=\"${GUID_NO_HYPHENS:12:4}\" # Next 4 chars (2 bytes)\r\n PART4=\"${GUID_NO_HYPHENS:16:16}\" # Last 16 chars (8 bytes)\r\n \r\n # Reverse byte order for first three parts (little-endian)\r\n BYTES=\"\"\r\n BYTES+=\"${PART1:6:2}${PART1:4:2}${PART1:2:2}${PART1:0:2}\"\r\n BYTES+=\"${PART2:2:2}${PART2:0:2}\"\r\n BYTES+=\"${PART3:2:2}${PART3:0:2}\"\r\n BYTES+=\"$PART4\"\r\n \r\n echo \"Hex bytes: $BYTES\"\r\n \r\n # Convert hex to binary and then to base64\r\n BASE64=$(echo \"$BYTES\" | xxd -r -p | base64)\r\n \r\n # Convert to Base64URL (remove padding, replace + with -, / with _)\r\n BASE64URL=$(echo \"$BASE64\" | tr '+' '-' | tr '/' '_' | tr -d '=\\n')\r\n \r\n echo \"Base64URL encoded: $BASE64URL\"\r\n \r\n # Output result as JSON\r\n echo \"{\\\"encodedGuid\\\":\\\"$BASE64URL\\\"}\" > $AZ_SCRIPTS_OUTPUT_PATH\r\n ", + "arguments": "[parameters('guidToEncode')]" + } + } + ], + "outputs": { + "encodedGuid": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))), '2023-08-01').outputs.encodedGuid]" + } + } + } + } + } + }, + "outputs": { + "aadAppId": { + "type": "string", + "value": "[reference('aadApplication').appId]" + }, + "aadAppObjectId": { + "type": "string", + "value": "[reference('aadApplication').id]" + }, + "aadAppIdUri": { + "type": "string", + "value": "[format('api://botid-{0}', parameters('botId'))]" + }, + "servicePrincipalId": { + "type": "string", + "value": "[reference('aadServicePrincipal').id]" + }, + "servicePrincipalObjectId": { + "type": "string", + "value": "[reference('aadServicePrincipal').id]" + }, + "fciName": { + "type": "string", + "value": "[reference('federatedCredential').name]" + }, + "fciSubject": { + "type": "string", + "value": "[format('/eid1/c/pub/t/{0}/a/9ExAW52n_ky4ZiS_jhpJIQ/{1}', reference('tenantIdEncoder').outputs.encodedGuid.value, guid(resourceGroup().id, parameters('aadAppName'), 'BotServiceOauthConnection'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-azure-bot')]", + "[resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "deploy-bot-oauth-connection", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "botServiceName": { + "value": "[variables('botServiceName')]" + }, + "connectionName": { + "value": "SsoConnection" + }, + "aadAppId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.aadAppId.value]" + }, + "aadAppIdUri": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.aadAppIdUri.value]" + }, + "federatedCredentialSubject": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.fciName.value]" + }, + "scopes": { + "value": "[format('{0}/access_as_user', reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.aadAppIdUri.value)]" + }, + "tenantId": { + "value": "[parameters('tenantId')]" + }, + "location": { + "value": "global" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "9760687786267362268" + } + }, + "parameters": { + "botServiceName": { + "type": "string", + "metadata": { + "description": "The name of the Bot Service to configure" + } + }, + "connectionName": { + "type": "string", + "defaultValue": "SsoConnection", + "metadata": { + "description": "The name for the OAuth connection setting" + } + }, + "aadAppId": { + "type": "string", + "metadata": { + "description": "The Azure AD Application (client) ID from the app registration" + } + }, + "aadAppIdUri": { + "type": "string", + "metadata": { + "description": "The Azure AD Application ID URI (e.g., api://botid-{guid})" + } + }, + "federatedCredentialSubject": { + "type": "string", + "metadata": { + "description": "The federated credential subject (unique identifier from the federated credential)" + } + }, + "scopes": { + "type": "string", + "metadata": { + "description": "OAuth scopes to request - should be the app ID URI with access_as_user scope" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "The tenant ID for the Azure AD application" + } + }, + "location": { + "type": "string", + "defaultValue": "global", + "metadata": { + "description": "Location for the connection resource" + } + } + }, + "resources": [ + { + "type": "Microsoft.BotService/botServices/connections", + "apiVersion": "2022-09-15", + "name": "[format('{0}/{1}', parameters('botServiceName'), parameters('connectionName'))]", + "location": "[parameters('location')]", + "properties": { + "serviceProviderId": "c00b44ab-5e16-c44c-af26-2fd5bc55eb18", + "serviceProviderDisplayName": "AAD v2 with Federated Credentials", + "clientId": "[parameters('aadAppId')]", + "scopes": "[parameters('scopes')]", + "parameters": [ + { + "key": "ClientId", + "value": "[parameters('aadAppId')]" + }, + { + "key": "UniqueIdentifier", + "value": "[parameters('federatedCredentialSubject')]" + }, + { + "key": "TokenExchangeUrl", + "value": "[parameters('aadAppIdUri')]" + }, + { + "key": "TenantId", + "value": "[parameters('tenantId')]" + } + ] + } + } + ], + "outputs": { + "connectionName": { + "type": "string", + "value": "[parameters('connectionName')]" + }, + "connectionId": { + "type": "string", + "value": "[resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1])]" + }, + "settingId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').settingId]" + }, + "provisioningState": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').provisioningState]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'deploy-app-registration')]" + ] + } + ], + "outputs": { + "resourceBaseName": { + "type": "string", + "value": "[parameters('resourceBaseName')]" + }, + "location": { + "type": "string", + "value": "[parameters('location')]" + }, + "identityName": { + "type": "string", + "value": "[variables('identityName')]" + }, + "identityId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityClientId.value]" + }, + "identityPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityPrincipalId.value]" + }, + "webAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppName.value]" + }, + "webAppId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppId.value]" + }, + "webAppHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppHostName.value]" + }, + "webAppUrl": { + "type": "string", + "value": "[format('https://{0}', reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppHostName.value)]" + }, + "appServicePlanId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.appServicePlanId.value]" + }, + "BOT_ID": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-bot-identity'), '2025-04-01').outputs.identityClientId.value]" + }, + "botServiceName": { + "type": "string", + "value": "[variables('botServiceName')]" + }, + "botEndpoint": { + "type": "string", + "value": "[format('https://{0}/api/messages', reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-service'), '2025-04-01').outputs.webAppHostName.value)]" + }, + "AAD_APP_CLIENT_ID": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.aadAppId.value]" + }, + "AAD_APP_ID_URI": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.aadAppIdUri.value]" + }, + "federatedCredentialName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-registration'), '2025-04-01').outputs.fciName.value]" + }, + "appInsightsName": { + "type": "string", + "value": "[coalesce(tryGet(tryGet(tryGet(if(parameters('enableAppInsights'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-insights'), '2025-04-01'), null()), 'outputs'), 'appInsightsName'), 'value'), '')]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[coalesce(tryGet(tryGet(tryGet(if(parameters('enableAppInsights'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-insights'), '2025-04-01'), null()), 'outputs'), 'appInsightsConnectionString'), 'value'), '')]" + }, + "appInsightsInstrumentationKey": { + "type": "string", + "value": "[coalesce(tryGet(tryGet(tryGet(if(parameters('enableAppInsights'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-insights'), '2025-04-01'), null()), 'outputs'), 'appInsightsInstrumentationKey'), 'value'), '')]" + }, + "logAnalyticsWorkspaceName": { + "type": "string", + "value": "[coalesce(tryGet(tryGet(tryGet(if(parameters('enableAppInsights'), reference(resourceId('Microsoft.Resources/deployments', 'deploy-app-insights'), '2025-04-01'), null()), 'outputs'), 'logAnalyticsWorkspaceName'), 'value'), '')]" + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/infra/azure.parameters.json b/ProxyAgent-CSharp/M365Agent/infra/azure.parameters.json new file mode 100644 index 00000000..085f6158 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/azure.parameters.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, + "botDisplayName": { + "value": "AzureAgentToM365ATK${{APP_NAME_SUFFIX}}" + }, + "webAppSKU": { + "value": "B1" + }, + "botServiceSku": { + "value": "F0" + }, + "enableAppInsights": { + "value": true + } + } + } \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/infra/bicepconfig.json b/ProxyAgent-CSharp/M365Agent/infra/bicepconfig.json new file mode 100644 index 00000000..cd15f3f3 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "extensions": { + "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0" + } +} diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/BOT_OAUTH_CONNECTION.md b/ProxyAgent-CSharp/M365Agent/infra/modules/BOT_OAUTH_CONNECTION.md new file mode 100644 index 00000000..8ddaf3e6 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/BOT_OAUTH_CONNECTION.md @@ -0,0 +1,237 @@ +# Bot OAuth Connection Configuration + +## Overview +The `bot-oauth-connection.bicep` module configures Azure AD v2 OAuth connection with federated credentials for your M365 Agent bot. This enables Single Sign-On (SSO) in Microsoft Teams. + +## SSO Flow for Agents in Teams & M365 Copilot + +```mermaid +--- +config: + theme: default +--- +sequenceDiagram + participant User as Agent User + participant Teams as M365 Copilot + participant Bot as Azure Bot Service + participant BF as Azure Bot Service
Token Service + participant Store as Azure Bot Service
Token Store + participant AAD as Microsoft Entra ID + User ->> Teams: 1. Send message to Agent + Teams ->> Bot: Forward message + Bot ->> BF: 2. Request sign-in link + BF ->> Bot: Return sign-in link + Bot ->> Teams: 3. Send OAuth card + Teams ->> Teams: Check if SSO enabled + alt SSO enabled + Teams ->> Bot: 4. Send token exchange request + Bot ->> BF: Forward token exchange + BF ->> AAD: Exchange token + alt First time user + AAD ->> Teams: 5. Request consent + Teams ->> User: Display consent dialog + User ->> Teams: Grant consent + Teams ->> AAD: Consent granted + AAD ->> BF: Return access token + else Returning user + AAD ->> BF: Return access token + end + BF ->> Store: 6. Store token + BF ->> Bot: Token available + Bot ->> Teams: Process request (authenticated) + else SSO disabled or consent fails + Teams ->> User: Display sign-in button + User ->> Teams: Click sign-in + Teams ->> AAD: Redirect to sign-in page + User ->> AAD: Sign in & grant access + AAD ->> BF: Return access token + BF ->> Store: Store token + BF ->> Bot: Token available + Bot ->> Teams: Process request (authenticated) + end + Teams ->> User: Display Agent response + +``` + +**Key Points:** +- **Token Caching**: Azure Bot Service stores tokens for returning users +- **OAuth Card**: Agent receive an OAuth card as a mean to deliver the Authentication Request +- **SSO Experience**: First-time users see a consent dialog (unless admin consent granted before), returning users sign in silently +- **Fallback**: If SSO fails, users see traditional sign-in flow +- **Token Exchange**: Uses federated credentials for secure token exchange no client secrets to configure and manage + +## Module: bot-oauth-connection.bicep + +### Purpose +Creates an OAuth connection setting on the Azure Bot Service that: +- Uses Azure Active Directory v2 as the identity provider +- Leverages federated credentials (no client secret required) +- Enables SSO for seamless user authentication in Teams + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `botServiceName` | string | Yes | - | Name of the Bot Service to configure | +| `connectionName` | string | No | `'SsoConnection'` | Name for the OAuth connection | +| `aadAppId` | string | Yes | - | Azure AD Application (client) ID | +| `aadAppIdUri` | string | Yes | - | Azure AD Application ID URI (e.g., `api://botid-{guid}`) | +| `scopes` | string | No | `'openid profile offline_access'` | Space-separated OAuth scopes | +| `tenantId` | string | Yes | - | Azure AD tenant ID | +| `location` | string | No | `'global'` | Resource location (always 'global' for bot connections) | +| `additionalParameters` | array | No | `[]` | Additional service provider parameters | + +### Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `connectionName` | string | Full name of the connection (format: `botServiceName/connectionName`) | +| `connectionId` | string | Resource ID of the connection | +| `settingId` | string | Setting ID assigned by the Bot Service | +| `provisioningState` | string | Provisioning state of the connection | + +### Key Features + +#### Azure AD v2 Service Provider +- **Service Provider ID**: `30dd229c-58e3-4a48-bdfd-91ec48eb906c` +- **Provider**: Azure Active Directory v2 +- **Authentication**: OAuth 2.0 with OpenID Connect + +#### Federated Credentials +- **No Client Secret Required**: Uses federated identity credentials created in the app registration +- **Secure**: Token exchange happens through Azure AD without storing secrets +- **Modern**: Leverages managed identity and federated credentials + +#### Default Scopes +- `openid`: OpenID Connect authentication +- `profile`: User profile information +- `offline_access`: Refresh token support + +#### Token Exchange +- Configured with `tokenExchangeUrl` pointing to the app ID URI +- Enables seamless SSO in Teams without additional user prompts + +## Integration in azure.bicep + +The OAuth connection is deployed as **Step 5** in the orchestration: + +```bicep +// Step 5: Configure OAuth Connection with Azure AD v2 and Federated Credentials +module botOAuthConnection 'modules/bot-oauth-connection.bicep' = { + name: 'deploy-bot-oauth-connection' + params: { + botServiceName: botServiceName + connectionName: 'SsoConnection' + aadAppId: appRegistration.outputs.aadAppId + aadAppIdUri: appRegistration.outputs.aadAppIdUri + scopes: 'openid profile offline_access' + tenantId: tenantId + location: 'global' + } +} +``` + +### Dependencies +- **Requires**: App Registration module must complete first (provides `aadAppId` and `aadAppIdUri`) +- **Uses**: Bot Service created in Step 3 +- **Implicit Dependency**: Bicep automatically handles dependency through output references + +## Deployment Flow + +1. **Managed Identity Created** → Bot identity established +2. **App Service Deployed** → Web app with managed identity +3. **Bot Service Created** → Bot registered with Teams channel +4. **App Registration Created** → Entra ID app with federated credentials +5. **OAuth Connection Configured** → SSO enabled with AAD v2 ✨ + +## Usage in Bot Code + +Once deployed, your bot can use this connection for SSO: + +```csharp +// Reference the connection name in your bot +var connectionName = "SsoConnection"; // Must match the connectionName parameter + +// Use in Microsoft 365 Agents SDK +var tokenResponse = await adapter.GetUserTokenAsync( + turnContext, + connectionName, + magicCode: null, + cancellationToken); +``` + +## Customization + +### Additional Scopes +To request additional Microsoft Graph permissions: + +```bicep +scopes: 'openid profile offline_access User.Read Mail.Read' +``` + +### Custom Parameters +Add service provider-specific parameters: + +```bicep +additionalParameters: [ + { + key: 'customParam' + value: 'customValue' + } +] +``` + +## Verification + +After deployment, verify the connection: + +1. **Azure Portal**: + - Navigate to Bot Service → Settings → OAuth Connection Settings + - Verify "SsoConnection" appears with status "Success" + +2. **Test Connection**: + - Click "Test Connection" in Azure Portal + - Sign in with a test user + - Verify successful authentication + +3. **Bot Code**: + - Test SSO flow in Teams + - Verify token acquisition succeeds + +## Troubleshooting + +### Connection Not Visible +- Ensure app registration completed successfully +- Verify federated credential was created +- Check bot service name matches + +### Token Exchange Fails +- Verify `aadAppIdUri` matches app registration (`api://botid-{guid}`) +- Ensure federated credential subject is correct +- Check tenant ID matches + +### SSO Prompt Still Appears +- Verify pre-authorized applications in app registration +- Check scopes are correctly configured +- Ensure Teams app manifest uses correct app ID + +## Security Notes + +✅ **No Client Secrets**: Uses federated credentials for enhanced security +✅ **Managed Identity**: Bot uses managed identity for Azure resources +✅ **Token Exchange**: Secure token exchange through AAD +✅ **Scoped Permissions**: Only requests necessary scopes + +## Next Steps + +After OAuth connection is configured: +1. Update Teams app manifest with correct app IDs +2. Configure bot code to use the connection +3. Test SSO flow in Teams +4. Add additional Graph API permissions as needed + +## Resources + +- [Azure Bot Service OAuth Documentation](https://docs.microsoft.com/azure/bot-service/bot-builder-authentication) +- [Azure AD v2 Token Exchange](https://docs.microsoft.com/azure/bot-service/bot-builder-authentication-sso) +- [Teams SSO for Bots](https://docs.microsoft.com/microsoftteams/platform/bots/how-to/authentication/auth-aad-sso-bots) diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/GUID_ENCODER_GUIDE.md b/ProxyAgent-CSharp/M365Agent/infra/modules/GUID_ENCODER_GUIDE.md new file mode 100644 index 00000000..dafab8d1 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/GUID_ENCODER_GUIDE.md @@ -0,0 +1,174 @@ +# GUID Encoder Integration - Deployment Guide + +## Overview +Your Bicep infrastructure now includes a self-contained GUID encoder that converts GUIDs to Base64URL format during deployment. This eliminates the need for external API calls and ensures proper binary encoding of GUIDs. + +## What Was Updated + +### 1. New Module: `guid-encoder.bicep` +- **Location**: `M365Agent/infra/modules/guid-encoder.bicep` +- **Purpose**: Converts GUIDs to Base64URL encoded format using Azure deployment scripts +- **Method**: Direct binary conversion (no external API needed) +- **Implementation**: Bash script with proper little-endian byte ordering + +### 2. Updated Module: `app-registration.bicep` +- **New Parameters**: + - `location`: Resource location for deployment scripts + - `encodedTenantId`: Optional pre-encoded tenant ID (skips encoding script) + - `encodedAppId`: Optional pre-encoded app ID (skips encoding script) + +- **Removed Parameters**: + - `guidEncoderApiEndpoint`: No longer needed - encoding is self-contained + +- **New Logic**: + - Runs deployment script to encode Tenant ID if not pre-provided + - Runs deployment script to encode Application ID if not pre-provided + - Uses encoded values to construct federated credential subject + +## How It Works + +1. **Deployment starts** → Creates Entra ID Application +2. **Script 1**: Converts Tenant ID GUID to binary bytes → Base64URL encoding +3. **Script 2**: Converts App ID GUID to binary bytes → Base64URL encoding +4. **Constructs** federated credential subject: `/eid1/c/pub/t/{encodedTenantId}/a/{encodedAppId}/{uniqueId}` +5. **Creates** federated identity credential with proper subject + +### Binary Encoding Process +The script performs the same conversion as the C# `Guid.ToByteArray()` method: +- Removes hyphens from GUID string +- Converts to hexadecimal bytes with little-endian ordering +- Encodes bytes as Base64 +- Converts to Base64URL format (URL-safe: replaces `+` with `-`, `/` with `_`, removes `=`) + +## Deployment Options + +### Option 1: Automatic (Uses Deployment Scripts - Recommended) +```powershell +az deployment group create ` + --resource-group "rg-m365agent-dev" ` + --template-file M365Agent/infra/azure.bicep ` + --parameters resourceBaseName="m365agent" ` + botDisplayName="M365 Agent" ` + tenantId="671740f0-0ce9-4b51-bae5-4096de8b66d3" +``` + +### Option 2: Pre-calculated (Skips Scripts - Faster) +```powershell +# Pre-calculate values using PowerShell +$guid = [Guid]::Parse("671740f0-0ce9-4b51-bae5-4096de8b66d3") +$bytes = $guid.ToByteArray() +$base64 = [Convert]::ToBase64String($bytes) +$encodedTenantId = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') + +# Deploy with pre-calculated values +az deployment group create ` + --resource-group "rg-m365agent-dev" ` + --template-file M365Agent/infra/azure.bicep ` + --parameters resourceBaseName="m365agent" ` + encodedTenantId=$encodedTenantId +``` + +## Deployment Script Details + +The `guid-encoder.bicep` module uses Azure Deployment Scripts: +- **Type**: Azure CLI (Bash) script +- **Runtime**: Azure CLI 2.52.0 +- **Retention**: 1 hour (auto-cleanup) +- **Timeout**: 5 minutes +- **Cost**: Minimal (uses Azure Container Instances briefly) +- **Encoding Method**: Proper binary conversion matching C# `Guid.ToByteArray()` + +## Important Notes + +### Performance +- **First deployment**: ~3-5 minutes (includes deployment script overhead) +- **Subsequent deployments**: Same duration (deployment scripts recreate each time) +- **Pre-calculated values**: Instant (no deployment script needed) + +### Advantages of Self-Contained Approach +✅ **No external dependencies** - Everything runs in Azure +✅ **Reliable** - No external API to fail or throttle +✅ **Secure** - GUIDs never leave your Azure environment +✅ **Proper encoding** - Binary conversion matches C# behavior +✅ **Cost-effective** - No need to maintain separate API service + +### Cost +- Deployment scripts create temporary Azure resources: + - Storage account (for script logs) + - Container instance (to run the script) +- Cost is minimal (~$0.01-0.02 per deployment) +- Resources are auto-deleted after 1 hour + +### Warnings (Can be ignored) +- `use-stable-resource-identifiers`: Using `utcNow()` is intentional to force script re-execution +- `no-unused-params`: The `fciSubject` parameter is kept for backward compatibility + +## Testing + +### Validate Bicep files +```powershell +# Validate guid-encoder module +az bicep build --file M365Agent/infra/modules/guid-encoder.bicep + +# Validate app-registration module +az bicep build --file M365Agent/infra/modules/app-registration.bicep + +# Validate main orchestration +az bicep build --file M365Agent/infra/azure.bicep +``` + +### What-if deployment +```powershell +az deployment group what-if ` + --resource-group "rg-m365agent-dev" ` + --template-file M365Agent/infra/azure.bicep ` + --parameters resourceBaseName="m365agent" +``` + +## Troubleshooting + +### Deployment script fails +- Check deployment script logs in Azure Portal +- Verify Azure CLI version 2.52.0 is available +- Ensure your subscription allows deployment scripts +- Check that `xxd` command is available (included in Azure CLI container) + +### Pre-calculate values to skip scripts +If deployment scripts are unavailable or failing: +```powershell +# PowerShell +$guid = [Guid]::Parse("671740f0-0ce9-4b51-bae5-4096de8b66d3") +$bytes = $guid.ToByteArray() +$base64 = [Convert]::ToBase64String($bytes) +$encodedTenantId = $base64.Replace('+', '-').Replace('/', '_').TrimEnd('=') + +# Deploy with pre-calculated value +az deployment group create ... --parameters encodedTenantId=$encodedTenantId +``` + +## Architecture + +``` +azure.bicep + └─> app-registration.bicep + ├─> guid-encoder.bicep (tenantId) → Bash Script → Base64URL + ├─> guid-encoder.bicep (appId) → Bash Script → Base64URL + └─> federatedCredential (uses encoded values) +``` + +## Next Steps + +1. ✅ Test API endpoint availability +2. ✅ Validate Bicep files compile +3. ✅ Run what-if deployment +4. ✅ Deploy to test resource group +5. ✅ Verify federated credential is created correctly +6. ✅ Test bot authentication + +## Support + +If you encounter issues: +1. Check deployment logs in Azure Portal +2. Verify API is accessible and returning correct values +3. Try pre-calculating values to isolate API issues +4. Review deployment script execution logs diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/app-registration.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/app-registration.bicep new file mode 100644 index 00000000..5e4c9157 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/app-registration.bicep @@ -0,0 +1,184 @@ +// Application Registration Module +// Required Role: Application Administrator or Cloud Application Administrator +// Deploys: Entra ID app registration, service principal, OAuth settings + +extension microsoftGraphV1 + +@description('Application name for the Entra ID app registration') +param aadAppName string + +@description('BotID this should match the Microsoft App ID in the Azure Bot Service Configuration') +param botId string + +@description('Tenant ID where the application will be registered') +param tenantId string + +@description('Pre-encoded tenant ID in Base64URL format (from guid-encoder module)') +param encodedTenantId string + +// Microsoft Entra ID Application Registration +// Note: identifierUris cannot be set on initial creation with appId reference +// Note: Retdirect URIs might vary based on your bot configuration +// Note: List of redirect URL : https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/azure-bot-user-authorization-federated-credentials#create-the-microsoft-entra-id-identity-provider +resource aadApplication 'Microsoft.Graph/applications@v1.0' = { + displayName: aadAppName + uniqueName: aadAppName + signInAudience: 'AzureADMyOrg' + identifierUris: [ + 'api://botid-${botId}' + ] + web: { + redirectUris: [ + 'https://token.botframework.com/.auth/web/redirect' + ] + implicitGrantSettings: { + enableIdTokenIssuance: false + enableAccessTokenIssuance: false + } + } + + api: { + requestedAccessTokenVersion: 2 + oauth2PermissionScopes: [ + { + id: guid(aadAppName, 'access_as_user') + adminConsentDescription: 'Default scope for Agent SSO access' + adminConsentDisplayName: 'Agent SSO' + userConsentDescription: 'Default scope for Agent SSO access' + userConsentDisplayName: 'Agent SSO' + value: 'access_as_user' + type: 'User' + isEnabled: true + } + ] + preAuthorizedApplications: [ + { + // Teams web client + appId: '1fec8e78-bce4-4aaf-ab1b-5451cc387264' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Teams desktop client + appId: '5e3ce6c0-2b1f-4285-8d4b-75ee78787346' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 web application + appId: '4765445b-32c6-49b0-83e6-1d93765276ca' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 desktop application + appId: '0ec893e0-5785-4de6-99da-4ed124e5296c' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Microsoft 365 mobile application Outlook desktop application + appId: 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Outlook web application + appId: 'bc59ab01-8403-45c6-8796-ac3ef710b3e3' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + { + // Outlook mobile application + appId: '27922004-5251-4030-b22d-91ecd9a37ea4' + delegatedPermissionIds: [ + guid(aadAppName, 'access_as_user') + ] + } + + ] + } + + requiredResourceAccess: [ + { + // OpenID permissions & offline_access + resourceAppId: '00000003-0000-0000-c000-000000000000' + resourceAccess: [ + { + // openid + id: '37f7f235-527c-4136-accd-4a02d197296e' + type: 'Scope' + } + { + // profile + id: '14dad69e-099b-42c9-810b-d002981feec1' + type: 'Scope' + } + { + // email + id: '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' + type: 'Scope' + } + { + // offline_access + id: '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' + type: 'Scope' + } + ] + } + { + // Azure Machine Learning Services + // Required for Microsoft Foundry agent SSO + resourceAppId: '18a66f5f-dbdf-4c17-9dd7-1634712a9cbe' + resourceAccess: [ + { + // user_impersonation + id: '1a7925b5-f871-417a-9b8b-303f9f29fa10' + type: 'Scope' + } + ] + } + ] +} + +// Construct federated credential subject using pre-encoded tenant ID +// appId encode value is the Bot Service one. it is hardcoded on purpose. +var myfciSubject ='/eid1/c/pub/t/${encodedTenantId}/a/9ExAW52n_ky4ZiS_jhpJIQ/${guid(aadAppName, 'BotServiceOauthConnection')}' + +// Federated Identity Credential for Bot Service token exchange +// This must be a separate resource as it's a child resource type +resource federatedCredential 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = { + name: '${aadApplication.uniqueName}/${guid(aadAppName, 'BotServiceOauthConnection')}' + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: '${environment().authentication.loginEndpoint}${tenantId}/v2.0' + subject: myfciSubject + description: 'Federated credential for Bot Service token exchange' +} + +// Service Principal for the application +resource aadServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: aadApplication.appId + accountEnabled: true + displayName: aadAppName + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs for other modules +output aadAppId string = aadApplication.appId +output aadAppObjectId string = aadApplication.id +output aadAppIdUri string = 'api://botid-${botId}' +output servicePrincipalId string = aadServicePrincipal.id +output servicePrincipalObjectId string = aadServicePrincipal.id +output fciName string = federatedCredential.name +output fciSubject string = myfciSubject diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/app-update-sso.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/app-update-sso.bicep new file mode 100644 index 00000000..8fb0dbf5 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/app-update-sso.bicep @@ -0,0 +1,182 @@ +// Application Update Module for SSO +// Updates an existing App Registration with SSO configuration +// This module adds: redirect URIs, API scopes, pre-authorized apps, federated credentials + +extension microsoftGraphV1 + +@description('The App ID (Client ID) of the existing application registration') +param botId string + +@description('The Object ID of the existing application registration') +param botObjectId string + +@description('Tenant ID where the application is registered') +param tenantId string + +@description('Location for resources') +param location string = resourceGroup().location + +// Reference the existing application by Object ID +resource existingApp 'Microsoft.Graph/applications@v1.0' existing = { + uniqueName: botObjectId +} + +// Update the application with SSO configuration +resource updatedApplication 'Microsoft.Graph/applications@v1.0' = { + uniqueName: existingApp.uniqueName + displayName: existingApp.displayName + signInAudience: 'AzureADMyOrg' + identifierUris: [ + 'api://botid-${botId}' + ] + web: { + redirectUris: [ + 'https://token.botframework.com/.auth/web/redirect' + ] + implicitGrantSettings: { + enableIdTokenIssuance: false + enableAccessTokenIssuance: false + } + } + + api: { + requestedAccessTokenVersion: 2 + oauth2PermissionScopes: [ + { + id: guid(botId, 'access_as_user') + adminConsentDescription: 'Default scope for Agent SSO access' + adminConsentDisplayName: 'Agent SSO' + userConsentDescription: 'Default scope for Agent SSO access' + userConsentDisplayName: 'Agent SSO' + value: 'access_as_user' + type: 'User' + isEnabled: true + } + ] + preAuthorizedApplications: [ + { + // Teams web client + appId: '1fec8e78-bce4-4aaf-ab1b-5451cc387264' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Teams desktop client + appId: '5e3ce6c0-2b1f-4285-8d4b-75ee78787346' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Microsoft 365 web application + appId: '4765445b-32c6-49b0-83e6-1d93765276ca' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Microsoft 365 desktop application + appId: '0ec893e0-5785-4de6-99da-4ed124e5296c' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Microsoft 365 mobile application Outlook desktop application + appId: 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Outlook web application + appId: 'bc59ab01-8403-45c6-8796-ac3ef710b3e3' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + { + // Outlook mobile application + appId: '27922004-5251-4030-b22d-91ecd9a37ea4' + delegatedPermissionIds: [ + guid(botId, 'access_as_user') + ] + } + ] + } + + requiredResourceAccess: [ + { + // OpenID permissions & offline_access + resourceAppId: '00000003-0000-0000-c000-000000000000' + resourceAccess: [ + { + // openid + id: '37f7f235-527c-4136-accd-4a02d197296e' + type: 'Scope' + } + { + // profile + id: '14dad69e-099b-42c9-810b-d002981feec1' + type: 'Scope' + } + { + // email + id: '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' + type: 'Scope' + } + { + // offline_access + id: '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' + type: 'Scope' + } + ] + } + { + // Azure Machine Learning Services + // Required for Microsoft Foundry agent SSO + resourceAppId: '18a66f5f-dbdf-4c17-9dd7-1634712a9cbe' + resourceAccess: [ + { + // user_impersonation + id: '1a7925b5-f871-417a-9b8b-303f9f29fa10' + type: 'Scope' + } + ] + } + ] +} + +// Call API to encode tenant ID +module tenantIdEncoder 'guid-encoder.bicep' = { + name: 'encode-tenant-${uniqueString(tenantId)}' + params: { + guidToEncode: tenantId + location: location + } +} + +var calculatedEncodedTenantId = tenantIdEncoder.outputs.encodedGuid + +// Construct federated credential subject +var myfciSubject = '/eid1/c/pub/t/${calculatedEncodedTenantId}/a/9ExAW52n_ky4ZiS_jhpJIQ/${guid(botId, 'BotServiceOauthConnection')}' + +// Add Federated Identity Credential for Azure Bot Service token exchange +resource federatedCredential 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = { + name: '${updatedApplication.uniqueName}/${guid(botId, 'BotServiceOauthConnection')}' + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: '${environment().authentication.loginEndpoint}${tenantId}/v2.0' + subject: myfciSubject + description: 'Federated credential for Azure Bot Service token exchange' +} + +// Outputs +output aadAppId string = botId +output aadAppObjectId string = botObjectId +output aadAppIdUri string = 'api://botid-${botId}' +output servicePrincipalId string = existingApp.id +output fciName string = federatedCredential.name +output fciSubject string = myfciSubject diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/appinsights.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/appinsights.bicep new file mode 100644 index 00000000..525f7931 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/appinsights.bicep @@ -0,0 +1,73 @@ +// Application Insights Module with Managed Identity Support +// This module deploys Log Analytics Workspace and Application Insights +// Configured to use managed identity authentication (no instrumentation key needed) + +@description('Base name for resources') +param resourceBaseName string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The managed identity principal ID that will access Application Insights') +param identityPrincipalId string + +@description('Application Insights application type') +@allowed([ + 'web' + 'other' +]) +param applicationType string = 'web' + +// Log Analytics Workspace (required for Application Insights) +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: '${resourceBaseName}-law' + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + workspaceCapping: { + dailyQuotaGb: 1 // Limit to 1GB per day to control costs + } + } +} + +// Application Insights +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: '${resourceBaseName}-ai' + location: location + kind: applicationType + properties: { + Application_Type: applicationType + WorkspaceResourceId: logAnalyticsWorkspace.id + IngestionMode: 'LogAnalytics' + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + DisableLocalAuth: false // Set to true to enforce managed identity only (more secure) + } +} + +// Grant Managed Identity "Monitoring Metrics Publisher" role on Application Insights +// This allows the bot to publish telemetry without using instrumentation key +var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' // Built-in role ID +resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(appInsights.id, identityPrincipalId, monitoringMetricsPublisherRoleId) + scope: appInsights + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisherRoleId) + principalId: identityPrincipalId + principalType: 'ServicePrincipal' + } +} + +// Outputs +output appInsightsId string = appInsights.id +output appInsightsName string = appInsights.name +output appInsightsConnectionString string = appInsights.properties.ConnectionString +output appInsightsInstrumentationKey string = appInsights.properties.InstrumentationKey +output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id +output logAnalyticsWorkspaceName string = logAnalyticsWorkspace.name diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/appservice.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/appservice.bicep new file mode 100644 index 00000000..9b20b139 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/appservice.bicep @@ -0,0 +1,208 @@ +// Azure App Service Module for .NET 9 Application +// This module deploys an App Service Plan and App Service with managed identity + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@description('The resource ID of the User Assigned Managed Identity') +param MSIid string + +@description('Location for all resources') +param location string = resourceGroup().location + +@description('The name of the App Service Plan') +param serverfarmsName string = resourceBaseName + +@description('The name of the Web App') +param webAppName string = resourceBaseName + +@description('The SKU for the App Service Plan') +param webAppSKU string + +@description('Additional app settings for the Web App') +param additionalAppSettings array = [] + +@description('Enable Application Insights') +param enableAppInsights bool = true + +@description('Application Insights connection string (for managed identity authentication)') +param appInsightsConnectionString string = '' + +// Bot Configuration (for appsettings.json template variables) +@description('Bot ID (Managed Identity Client ID)') +param botId string + +@description('Bot Tenant ID') +param botTenantId string + +@description('OAuth Connection Name') +param oauthConnectionName string + +// AI Services Configuration (optional) +@description('Microsoft Foundry Project Endpoint (optional)') +param azureAIFoundryEndpoint string = '' + +@description('Azure AI Agent ID (optional)') +param azureAIAgentId string = '' + +// App Service Plan - Compute resources for your Web App +resource serverfarm 'Microsoft.Web/serverfarms@2023-12-01' = { + name: serverfarmsName + location: location + kind: 'app' + sku: { + name: webAppSKU + } + properties: { + reserved: false // false = Windows, true = Linux + } +} + +// Web App that hosts your .NET 9 agent +resource webApp 'Microsoft.Web/sites@2023-12-01' = { + name: webAppName + location: location + kind: 'app' + properties: { + serverFarmId: serverfarm.id + httpsOnly: true + clientAffinityEnabled: false + siteConfig: { + alwaysOn: true + http20Enabled: true + minTlsVersion: '1.2' + ftpsState: 'FtpsOnly' + netFrameworkVersion: 'v9.0' + healthCheckPath: '/health' + appSettings: concat([ + { + name: 'ASPNETCORE_ENVIRONMENT' + value: 'Production' + } + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + { + name: 'AZURE_CLIENT_ID' + value: reference(MSIid, '2023-01-31').clientId + } + //AgentApplication Settings and authorization + { + name: 'AgentApplication__StartTypingTimer' + value: true + } + { + name: 'AgentApplication__RemoveRecipientMention' + value: false + } + { + name: 'AgentApplication__NormalizeMentions' + value: false + } + { + name: 'AgentApplication__UserAuthorization__Handlers__SSO__Settings__AzureBotOAuthConnectionName' + value: oauthConnectionName + } + //TokenValidation + { + name: 'TokenValidation__Audiences__0' + value: botId + } + // Bot Configuration (matches appsettings.json template variables) + { + name:'Connections__BotServiceConnection__Settings__AuthType' + value: 'UserManagedIdentity' + } + { + name: 'Connections__BotServiceConnection__Settings__ClientId' + value: botId + } + { + name: 'Connections__BotServiceConnection__Settings__TenantId' + value: botTenantId + } + { + name: 'Connections__BotServiceConnection__Settings__Scopes__0' + value: 'https://api.botframework.com/.default' + } + //ConnectionsMap + { + name:'ConnectionsMap__ServiceUrl' + value: '*' + } + { + name:'ConnectionsMap__Connection' + value: 'BotServiceConnection' + } + //Logging + { + name: 'Logging__LogLevel__Default' + value:'Information' + } + { + name: 'Logging__LogLevel__Microsoft.AspNetCore' + value:'Warning' + } + { + name: 'Logging__LogLevel__Microsoft.Agents' + value: 'Warning' + } + { + name: 'Logging__LogLevel__Microsoft.Hosting.Lifetime' + value: 'Information' + } + // AI Services Configuration (optional) + { + name: 'AIServices__AzureAIFoundryProjectEndpoint' + value: azureAIFoundryEndpoint + } + { + name: 'AIServices__AgentID' + value: azureAIAgentId + } + ], enableAppInsights && !empty(appInsightsConnectionString) ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsightsConnectionString + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~3' + } + { + name: 'XDT_MicrosoftApplicationInsights_Mode' + value: 'recommended' + } + ] : [], additionalAppSettings) + cors: { + allowedOrigins: [ + 'https://portal.azure.com' + 'https://ms.portal.azure.com' + ] + supportCredentials: false + } + metadata: [ + { + name: 'CURRENT_STACK' + value: 'dotnet' + } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${MSIid}': {} + } + } +} + +// Outputs for use in other modules +output webAppName string = webApp.name +output webAppId string = webApp.id +output webAppHostName string = webApp.properties.defaultHostName +output webAppPrincipalId string = reference(MSIid, '2023-01-31').principalId +output appServicePlanId string = serverfarm.id diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot-local.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot-local.bicep new file mode 100644 index 00000000..15d2e36c --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot-local.bicep @@ -0,0 +1,55 @@ +// Azure Bot Service Module for Local Development +// Registers a bot with Single Tenant + Client Secret authentication +// Used for local development (without managed identity) + +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' + +@description('The bot application (client) ID from the bot app registration') +param botAppId string + +@description('The tenant ID for the bot application') +param botAppTenantId string + +@description('The bot messaging endpoint (e.g., https://abc123-5000.usw2.devtunnels.ms/api/messages)') +param botEndpoint string + +// Register your web service as a bot with Azure Bot Service (Single Tenant mode) +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: botEndpoint + msaAppId: botAppId + msaAppTenantId: botAppTenantId + msaAppType: 'SingleTenant' // Using Single Tenant authentication + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} + +// Outputs +output botServiceName string = botService.name +output botServiceId string = botService.id +output botEndpoint string = botEndpoint diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot.bicep new file mode 100644 index 00000000..0cff1189 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/azurebot.bicep @@ -0,0 +1,42 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' +param identityResourceId string +param identityClientId string +param identityTenantId string +param botAppDomain string + +// Register your web service as a bot with Azure Bot Service +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: 'https://${botAppDomain}/api/messages' + msaAppId: identityClientId + msaAppMSIResourceId: identityResourceId + msaAppTenantId:identityTenantId + msaAppType:'UserAssignedMSI' + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/bot-app-registration.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-app-registration.bicep new file mode 100644 index 00000000..0e692b57 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-app-registration.bicep @@ -0,0 +1,51 @@ +// Bot App Registration Module for Local Development +// Creates an Entra ID app registration for bot authentication +// Used for local development with Single Tenant authentication +// Note: Client secret must be created manually in Azure Portal after deployment + +extension microsoftGraphV1 + +@description('Application name for the bot Entra ID app registration') +param appName string + +@description('Tenant ID where the application will be registered') +param tenantId string + +// Bot Application Registration (Single Tenant) +resource botApplication 'Microsoft.Graph/applications@v1.0' = { + displayName: appName + uniqueName: appName + signInAudience: 'AzureADMyOrg' // Single tenant + + // Bot-specific configuration + web: { + redirectUris: [] + implicitGrantSettings: { + enableIdTokenIssuance: false + enableAccessTokenIssuance: false + } + } + + // Required for bot authentication + requiredResourceAccess: [] +} + +// Service Principal for the bot application +resource botServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: botApplication.appId + accountEnabled: true + displayName: appName + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs +output appId string = botApplication.appId +output objectId string = botApplication.id +output servicePrincipalId string = botServicePrincipal.id +output tenantId string = tenantId + +// Note: Client secret must be created manually after deployment +// Navigate to Azure Portal → App Registrations → {appName} → Certificates & secrets → New client secret diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/bot-managedidentity.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-managedidentity.bicep new file mode 100644 index 00000000..10c17f6f --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-managedidentity.bicep @@ -0,0 +1,16 @@ +@description('The name of the User Assigned Managed Identity to create.') +param identityName string +@description('Location for all resources.') +param location string = resourceGroup().location + + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + location: location + name: identityName +} + +// Outputs for use in other modules +output identityId string = identity.id +output identityName string = identity.name +output identityClientId string = identity.properties.clientId +output identityPrincipalId string = identity.properties.principalId diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.bicep new file mode 100644 index 00000000..44371fc5 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.bicep @@ -0,0 +1,65 @@ +// Bot OAuth Connection Module +// Configures Azure AD v2 OAuth connection with Federated Credentials for SSO +// This enables single sign-on (SSO) for the bot in Teams + +@description('The name of the Bot Service to configure') +param botServiceName string + +@description('The name for the OAuth connection setting') +param connectionName string = 'SsoConnection' + +@description('The Azure AD Application (client) ID from the app registration') +param aadAppId string + +@description('The Azure AD Application ID URI (e.g., api://botid-{guid})') +param aadAppIdUri string + +@description('The federated credential name (unique identifier from the federated credential)') +param federatedCredentialName string + +@description('OAuth scopes to request - should be the app ID URI with access_as_user scope') +param scopes string + +@description('The tenant ID for the Azure AD application') +param tenantId string + +@description('Location for the connection resource') +param location string = 'global' + +// Azure AD v2 OAuth Connection for Bot Service with Federated Credentials +// This enables SSO using federated credentials (no client secret needed) +// Uses the access_as_user scope defined in the app registration +resource botOAuthConnection 'Microsoft.BotService/botServices/connections@2022-09-15' = { + name: '${botServiceName}/${connectionName}' + location: location + properties: { + serviceProviderId: 'c00b44ab-5e16-c44c-af26-2fd5bc55eb18' // AAD v2 with Federated Credentials + serviceProviderDisplayName: 'AAD v2 with Federated Credentials' + clientId: aadAppId + scopes: scopes + parameters: [ + { + key: 'ClientId' + value: aadAppId + } + { + key: 'UniqueIdentifier' + value: federatedCredentialName + } + { + key: 'TokenExchangeUrl' + value: aadAppIdUri + } + { + key: 'TenantId' + value: tenantId + } + ] + } +} + +// Outputs +output connectionName string = connectionName +output connectionId string = botOAuthConnection.id +output settingId string = botOAuthConnection.properties.settingId +output provisioningState string = botOAuthConnection.properties.provisioningState diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.json b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.json new file mode 100644 index 00000000..6a820c56 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/bot-oauth-connection.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1874979584501871220" + } + }, + "parameters": { + "botServiceName": { + "type": "string", + "metadata": { + "description": "The name of the Bot Service to configure" + } + }, + "connectionName": { + "type": "string", + "defaultValue": "SsoConnection", + "metadata": { + "description": "The name for the OAuth connection setting" + } + }, + "aadAppId": { + "type": "string", + "metadata": { + "description": "The Azure AD Application (client) ID from the app registration" + } + }, + "aadAppIdUri": { + "type": "string", + "metadata": { + "description": "The Azure AD Application ID URI (e.g., api://botid-{guid})" + } + }, + "federatedCredentialSubject": { + "type": "string", + "metadata": { + "description": "The federated credential subject (unique identifier from the federated credential)" + } + }, + "scopes": { + "type": "string", + "metadata": { + "description": "OAuth scopes to request - should be the app ID URI with access_as_user scope" + } + }, + "tenantId": { + "type": "string", + "metadata": { + "description": "The tenant ID for the Azure AD application" + } + }, + "location": { + "type": "string", + "defaultValue": "global", + "metadata": { + "description": "Location for the connection resource" + } + } + }, + "resources": [ + { + "type": "Microsoft.BotService/botServices/connections", + "apiVersion": "2022-09-15", + "name": "[format('{0}/{1}', parameters('botServiceName'), parameters('connectionName'))]", + "location": "[parameters('location')]", + "properties": { + "serviceProviderId": "c00b44ab-5e16-c44c-af26-2fd5bc55eb18", + "serviceProviderDisplayName": "AAD v2 with Federated Credentials", + "clientId": "[parameters('aadAppId')]", + "scopes": "[parameters('scopes')]", + "parameters": [ + { + "key": "ClientId", + "value": "[parameters('aadAppId')]" + }, + { + "key": "UniqueIdentifier", + "value": "[parameters('federatedCredentialSubject')]" + }, + { + "key": "TokenExchangeUrl", + "value": "[parameters('aadAppIdUri')]" + }, + { + "key": "TenantId", + "value": "[parameters('tenantId')]" + }, + { + "key": "LoginUri", + "value": "[environment().authentication.loginEndpoint]" + } + ] + } + } + ], + "outputs": { + "connectionName": { + "type": "string", + "value": "[format('{0}/{1}', parameters('botServiceName'), parameters('connectionName'))]" + }, + "connectionId": { + "type": "string", + "value": "[resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1])]" + }, + "settingId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').settingId]" + }, + "provisioningState": { + "type": "string", + "value": "[reference(resourceId('Microsoft.BotService/botServices/connections', split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[0], split(format('{0}/{1}', parameters('botServiceName'), parameters('connectionName')), '/')[1]), '2022-09-15').provisioningState]" + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.bicep new file mode 100644 index 00000000..460654bb --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.bicep @@ -0,0 +1,66 @@ +// GUID Encoder Module +// Converts GUID to Base64URL encoded format using deployment script + +@description('The GUID to encode') +param guidToEncode string + +@description('Location for the deployment script') +param location string = resourceGroup().location + +@description('Timestamp to force script re-execution') +param utcValue string = utcNow() + +resource guidEncoderScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: 'guid-encoder-${uniqueString(guidToEncode, utcValue)}' + location: location + kind: 'AzureCLI' + properties: { + azCliVersion: '2.52.0' + retentionInterval: 'PT1H' + timeout: 'PT5M' + cleanupPreference: 'OnSuccess' + forceUpdateTag: utcValue + scriptContent: ''' + #!/bin/bash + set -e + + GUID_VALUE="$1" + + echo "Converting GUID: $GUID_VALUE" + + # Remove hyphens from GUID + GUID_NO_HYPHENS=$(echo "$GUID_VALUE" | tr -d '-') + + # Extract parts of the GUID + # GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + # Byte order needs to be adjusted for little-endian encoding + PART1="${GUID_NO_HYPHENS:0:8}" # First 8 chars (4 bytes) + PART2="${GUID_NO_HYPHENS:8:4}" # Next 4 chars (2 bytes) + PART3="${GUID_NO_HYPHENS:12:4}" # Next 4 chars (2 bytes) + PART4="${GUID_NO_HYPHENS:16:16}" # Last 16 chars (8 bytes) + + # Reverse byte order for first three parts (little-endian) + BYTES="" + BYTES+="${PART1:6:2}${PART1:4:2}${PART1:2:2}${PART1:0:2}" + BYTES+="${PART2:2:2}${PART2:0:2}" + BYTES+="${PART3:2:2}${PART3:0:2}" + BYTES+="$PART4" + + echo "Hex bytes: $BYTES" + + # Convert hex to binary and then to base64 + BASE64=$(echo "$BYTES" | xxd -r -p | base64) + + # Convert to Base64URL (remove padding, replace + with -, / with _) + BASE64URL=$(echo "$BASE64" | tr '+' '-' | tr '/' '_' | tr -d '=\n') + + echo "Base64URL encoded: $BASE64URL" + + # Output result as JSON + echo "{\"encodedGuid\":\"$BASE64URL\"}" > $AZ_SCRIPTS_OUTPUT_PATH + ''' + arguments: guidToEncode + } +} + +output encodedGuid string = guidEncoderScript.properties.outputs.encodedGuid diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.json b/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.json new file mode 100644 index 00000000..2bbd26af --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/guid-encoder.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12339729127578324664" + } + }, + "parameters": { + "guidToEncode": { + "type": "string", + "metadata": { + "description": "The GUID to encode" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for the deployment script" + } + }, + "utcValue": { + "type": "string", + "defaultValue": "[utcNow()]", + "metadata": { + "description": "Timestamp to force script re-execution" + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deploymentScripts", + "apiVersion": "2023-08-01", + "name": "[format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))]", + "location": "[parameters('location')]", + "kind": "AzureCLI", + "properties": { + "azCliVersion": "2.52.0", + "retentionInterval": "PT1H", + "timeout": "PT5M", + "cleanupPreference": "OnSuccess", + "forceUpdateTag": "[parameters('utcValue')]", + "scriptContent": " #!/bin/bash\r\n set -e\r\n \r\n GUID_VALUE=\"$1\"\r\n \r\n echo \"Converting GUID: $GUID_VALUE\"\r\n \r\n # Remove hyphens from GUID\r\n GUID_NO_HYPHENS=$(echo \"$GUID_VALUE\" | tr -d '-')\r\n \r\n # Extract parts of the GUID\r\n # GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\r\n # Byte order needs to be adjusted for little-endian encoding\r\n PART1=\"${GUID_NO_HYPHENS:0:8}\" # First 8 chars (4 bytes)\r\n PART2=\"${GUID_NO_HYPHENS:8:4}\" # Next 4 chars (2 bytes)\r\n PART3=\"${GUID_NO_HYPHENS:12:4}\" # Next 4 chars (2 bytes)\r\n PART4=\"${GUID_NO_HYPHENS:16:16}\" # Last 16 chars (8 bytes)\r\n \r\n # Reverse byte order for first three parts (little-endian)\r\n BYTES=\"\"\r\n BYTES+=\"${PART1:6:2}${PART1:4:2}${PART1:2:2}${PART1:0:2}\"\r\n BYTES+=\"${PART2:2:2}${PART2:0:2}\"\r\n BYTES+=\"${PART3:2:2}${PART3:0:2}\"\r\n BYTES+=\"$PART4\"\r\n \r\n echo \"Hex bytes: $BYTES\"\r\n \r\n # Convert hex to binary and then to base64\r\n BASE64=$(echo \"$BYTES\" | xxd -r -p | base64)\r\n \r\n # Convert to Base64URL (remove padding, replace + with -, / with _)\r\n BASE64URL=$(echo \"$BASE64\" | tr '+' '-' | tr '/' '_' | tr -d '=\\n')\r\n \r\n echo \"Base64URL encoded: $BASE64URL\"\r\n \r\n # Output result as JSON\r\n echo \"{\\\"encodedGuid\\\":\\\"$BASE64URL\\\"}\" > $AZ_SCRIPTS_OUTPUT_PATH\r\n ", + "arguments": "[parameters('guidToEncode')]" + } + } + ], + "outputs": { + "encodedGuid": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', format('guid-encoder-{0}', uniqueString(parameters('guidToEncode'), parameters('utcValue')))), '2023-08-01').outputs.encodedGuid]" + } + } +} \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/service-principal.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/service-principal.bicep new file mode 100644 index 00000000..ee2013a7 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/service-principal.bicep @@ -0,0 +1,24 @@ +// Service Principal Creation Module +// Creates a service principal for an existing App Registration +// This is required for the Bot Service to work with SingleTenant authentication + +extension microsoftGraphV1 + +@description('The App ID (Client ID) of the existing application registration') +param appId string + +// Create Service Principal for the existing application +// Note: Display name will automatically match the App Registration +resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: appId + accountEnabled: true + servicePrincipalType: 'Application' + tags: [ + 'WindowsAzureActiveDirectoryIntegratedApp' + ] +} + +// Outputs +output servicePrincipalId string = servicePrincipal.id +output servicePrincipalObjectId string = servicePrincipal.id +output appId string = servicePrincipal.appId diff --git a/ProxyAgent-CSharp/M365Agent/infra/modules/update-bot-endpoint.bicep b/ProxyAgent-CSharp/M365Agent/infra/modules/update-bot-endpoint.bicep new file mode 100644 index 00000000..f001cebc --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/infra/modules/update-bot-endpoint.bicep @@ -0,0 +1,42 @@ +// Module to update an existing Azure Bot Service endpoint +// This is used when the bot already exists and only the endpoint needs to be updated + +@description('The name of the existing bot service') +param botServiceName string + +@description('The new bot messaging endpoint (dev tunnel URL)') +param botEndpoint string + +@description('The bot application (client) ID') +param botAppId string + +@description('The tenant ID for the bot application') +param botAppTenantId string + +@description('The bot display name') +param botDisplayName string + +@description('The SKU for the Bot Service') +param botServiceSku string + +// Reference the existing bot service and update its properties +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: botEndpoint // This is the key property we're updating + msaAppId: botAppId + msaAppTenantId: botAppTenantId + msaAppType: 'SingleTenant' + } + sku: { + name: botServiceSku + } +} + +// Outputs +output botServiceName string = botService.name +output botServiceId string = botService.id +output botEndpoint string = botEndpoint diff --git a/ProxyAgent-CSharp/M365Agent/m365agents.local.yml b/ProxyAgent-CSharp/M365Agent/m365agents.local.yml new file mode 100644 index 00000000..93e935f2 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/m365agents.local.yml @@ -0,0 +1,125 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.8/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.8 + +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: AzureAgentToM365ATK-${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + + # Create or reuse an existing Microsoft Entra application for bot. + - uses: aadApp/create + with: + # The Microsoft Entra application's display name + name: AzureAgentToM365ATK-${{RESOURCE_SUFFIX}}-${{APP_NAME_SUFFIX}}-Bot + generateClientSecret: true + signInAudience: AzureADMyOrg + writeToEnvironmentFile: + # The Microsoft Entra application's client id created for bot. + clientId: BOT_ID + # The Microsoft Entra application's client secret created for bot. + clientSecret: SECRET_BOT_PASSWORD + # The Microsoft Entra application's object id created for bot. + objectId: BOT_OBJECT_ID + + # Deploy Azure infrastructure for local development (Bot Service + OAuth Connection) + # This creates: SSO App Registration + Azure Bot Service + OAuth Connection + - uses: arm/deploy + with: + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure-local.bicep + parameters: ./infra/azure-local.parameters.json + deploymentName: Deploy-local-bot-infrastructure + bicepCliVersion: v0.38.33 + # Note: arm/deploy outputs are automatically captured in ARM_OUTPUTS variable + # We extract them and write to environment file in the next step + + + # Generate runtime appsettings to JSON file + - uses: file/createOrUpdateJsonFile + with: + target: ../AzureAgentToM365ATK/appsettings.Development.json + content: + TokenValidation: + Audiences: + ClientId: ${{BOT_ID}} + Connections: + BotServiceConnection: + Settings: + AuthType: "ClientSecret" + TenantId: ${{TEAMS_APP_TENANT_ID}} + ClientId: ${{BOT_ID}} + ClientSecret: ${{SECRET_BOT_PASSWORD}} + Scopes: [ + "https://api.botframework.com/.default" + ] + AgentApplication: + StartTypingTimer: true + RemoveRecipientMention: false + NormalizeMentions: false + UserAuthorization: + AutoSignIn: false + Handlers: + SSO: + Settings: + AzureBotOAuthConnectionName: ${{AIFOUNDRYCONNECTIONNAME}} + ConnectionsMap: + - ServiceUrl: "*" + Connection: "BotServiceConnection" + Logging: + LogLevel: + Default: "Information" + Microsoft.AspNetCore: "Warning" + Microsoft.Agents: "Warning" + Microsoft.Hosting.Lifetime: "Information" + AIServices: + AzureAIFoundryProjectEndpoint: ${{AZURE_AI_FOUNDRY_PROJECT_ENDPOINT}} + AgentID: ${{AGENT_ID}} + + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + # Apply the Teams app manifest to an existing Teams app in + # Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID diff --git a/ProxyAgent-CSharp/M365Agent/m365agents.yml b/ProxyAgent-CSharp/M365Agent/m365agents.yml new file mode 100644 index 00000000..4906eca1 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/m365agents.yml @@ -0,0 +1,99 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.8/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +# +# PRODUCTION DEPLOYMENT - Uses Managed Identity + Azure App Service +# Deploys: Managed Identity → App Service → Azure Bot → App Registration → OAuth Connection + +version: v1.8 + +environmentFolderPath: ./env + +# Triggered when 'atk provision' is executed +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: AzureAgentToM365ATK${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + # Deploy Azure infrastructure using azure.bicep + # This deploys all 5 steps: Managed Identity, App Service, Bot Service, App Registration, OAuth Connection + - uses: arm/deploy + with: + # AZURE_SUBSCRIPTION_ID is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select a subscription. + subscriptionId: ${{AZURE_SUBSCRIPTION_ID}} + # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable, + # if its value is empty, TeamsFx will prompt you to select or create one + # resource group. + resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}} + templates: + - path: ./infra/azure.bicep + parameters: ./infra/azure.parameters.json + deploymentName: Create-resources-for-bot + # Omit bicepCliVersion to use system Bicep CLI (az bicep version: 0.38.33) + bicepCliVersion: v0.38.33 + + # Validate using manifest schema + - uses: teamsApp/validateManifest + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appPackage/manifest.json + outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + outputFolder: ./appPackage/build + + # Validate app package using validation rules + - uses: teamsApp/validateAppPackage + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Apply the Teams app manifest to an existing Teams app in Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + + # Extend to M365 Copilot + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + +# Triggered when 'atk deploy' is executed +deploy: + # Build the .NET application + - uses: cli/runDotnetCommand + with: + args: publish --configuration Release AzureAgentToM365ATK.csproj + workingDirectory: ../AzureAgentToM365ATK + + # Deploy your application to Azure App Service using the zip deploy feature. + # For additional details, refer to https://aka.ms/zip-deploy-to-app-services. + - uses: azureAppService/zipDeploy + with: + # Deploy base folder + artifactFolder: bin/Release/net9.0/publish + # The resource id of the cloud resource to be deployed to. + # This key will be generated by arm/deploy action automatically. + # You can replace it with your existing Azure Resource id + # or add it to your environment variable file. + resourceId: ${{WEBAPPID}} + workingDirectory: ../AzureAgentToM365ATK +projectId: a5ce45d0-c7f9-4964-828d-957cfa33b952 diff --git a/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.ps1 b/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.ps1 new file mode 100644 index 00000000..afd37c1c --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.ps1 @@ -0,0 +1,58 @@ +$exe = where.exe devtunnel.exe +if ("" -eq $exe) { + Write-Host "Dev Tunnels CLI not found. Please install: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started" + exit +} + +$tunnelId = "" +$envFile = ".\env\.env.local" +$envFileContent = Get-Content $envFile +$envFileContent | ForEach-Object { + if ($_ -like "TUNNEL_ID=*") { + $tunnelId = $_.Split("=")[1].Trim() + } +} + +if ($tunnelId -eq "") { + Write-Host "No TUNNEL_ID found. Creating tunnel..." + + Write-Host "Logging in to Dev Tunnels..." + devtunnel user login > $null + + Write-Host "Creating tunnel..." + $tunnel = devtunnel.exe create + $tunnelId = $tunnel -split '\r?\n' | Select-String 'Tunnel ID' | ForEach-Object { ($_ -split ':')[1].Trim() } + + Write-Host "Creating port and access..." + $port = 5130 + devtunnel port create $tunnelId -p $port > $null + devtunnel access create $tunnelId -p $port -a > $null + + Write-Host "Updating env\.env.local..." + + $hostname = $tunnelId.split('.')[0] + $cluster = $tunnelId.split('.')[1] + + $domain = "$hostname-$port.$cluster.devtunnels.ms" + $endpoint = "https://$domain" + + $envFileContent | ForEach-Object { + $line = $_ + if ($line -like "BOT_ENDPOINT=*") { + $line = "BOT_ENDPOINT=$endpoint" + } + if ($line -like "BOT_DOMAIN=*") { + $line = "BOT_DOMAIN=$domain" + } + if ($line -like "TUNNEL_ID=*") { + $line = "TUNNEL_ID=$tunnelId" + } + $line + } | Set-Content $envFile + + Write-Host "TUNNEL_ID: $tunnelId" + Write-Host "BOT_ENDPOINT: $endpoint" + Write-Host "BOT_DOMAIN: $domain" +} + +devtunnel.exe host $tunnelId \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.sh b/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.sh new file mode 100644 index 00000000..fb1f891d --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/scripts/devtunnel.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +exe=$(which devtunnel) +if [ $? -ne 0 ]; then + echo "Dev Tunnels CLI not found. Please install: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started" + exit 1 +fi + +tunnelId="" +envFile="env/.env.local" + +while IFS= read -r line; do + if [[ $line == TUNNEL_ID=* ]]; then + tunnelId="${line#*=}" + fi +done <"$envFile" + +if [ -z "$tunnelId" ]; then + echo "No TUNNEL_ID found. Creating tunnel..." + + echo "Logging in to Dev Tunnels..." + devtunnel user login >/dev/null + + echo "Creating tunnel..." + tunnel=$(devtunnel create) + tunnelId=$(echo "$tunnel" | grep 'Tunnel ID' | cut -d ':' -f2 | xargs) + + echo "Creating port and access..." + port=5130 + devtunnel port create $tunnelId -p $port + devtunnel access create $tunnelId -p $port -a + + echo "Updating env/.env.local..." + + hostname=$(echo $tunnelId | cut -d '.' -f1) + cluster=$(echo $tunnelId | cut -d '.' -f2) + + domain="$hostname-$port.$cluster.devtunnels.ms" + endpoint="https://$domain" + + # read file into an array + lines=() + while IFS= read -r line; do + lines+=("$line") + done <"$envFile" + + # update lines + for i in "${!lines[@]}"; do + if [[ ${lines[i]} == BOT_ENDPOINT=* ]]; then + lines[i]="BOT_ENDPOINT=$endpoint" + fi + if [[ ${lines[i]} == BOT_DOMAIN=* ]]; then + lines[i]="BOT_DOMAIN=$domain" + fi + if [[ ${lines[i]} == TUNNEL_ID=* ]]; then + lines[i]="TUNNEL_ID=$tunnelId" + fi + done + + # write array to file + printf "%s\n" "${lines[@]}" >"$envFile" + + echo "TUNNEL_ID: $tunnelId" + echo "BOT_ENDPOINT: $endpoint" + echo "BOT_DOMAIN: $domain" +fi + +devtunnel host $tunnelId \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/scripts/env.js b/ProxyAgent-CSharp/M365Agent/scripts/env.js new file mode 100644 index 00000000..6cbfd675 --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/scripts/env.js @@ -0,0 +1,52 @@ +const fs = require("fs"); +const path = require("path"); + +console.log("Ensuring env files exist..."); + +const envPath = path.join(__dirname, "..", "env"); +const envs = [ + { + name: ".env.local", + requiredVars: ["TEAMSFX_ENV", "TUNNEL_ID", "BOT_ENDPOINT", "BOT_DOMAIN", "SSO_APP_ID", "AZURE_AI_FOUNDRY_PROJECT_ENDPOINT", "AGENT_ID"], + content: `TEAMSFX_ENV=local\nAPP_NAME={{connectorName}}\nTUNNEL_ID=\nBOT_ENDPOINT=\nBOT_DOMAIN=\nSSO_APP_ID=00000000-0000-0000-0000-000000000000\nAZURE_AI_FOUNDRY_PROJECT_ENDPOINT=\nAGENT_ID=`, + } +]; + +envs.forEach((env) => { + const envFilePath = path.join(envPath, env.name); + + if (!fs.existsSync(envFilePath)) { + // Create new file + fs.mkdirSync(envPath, { recursive: true }); + fs.writeFileSync(envFilePath, env.content); + console.log(`Created ${env.name}`); + } else { + // Check and add missing variables to existing file + let content = fs.readFileSync(envFilePath, "utf8"); + let modified = false; + + env.requiredVars.forEach((varName) => { + const regex = new RegExp(`^${varName}=(.*)$`, "m"); + const match = content.match(regex); + + if (!match) { + // Variable doesn't exist, add it with default value + const defaultValue = varName === "SSO_APP_ID" ? "00000000-0000-0000-0000-000000000000" : ""; + content += `\n${varName}=${defaultValue}`; + modified = true; + console.log(`Added ${varName} to ${env.name}`); + } else if (varName === "SSO_APP_ID" && match[1].trim() === "") { + // SSO_APP_ID exists but is empty, set default GUID + content = content.replace(regex, `${varName}=00000000-0000-0000-0000-000000000000`); + modified = true; + console.log(`Set default GUID for ${varName} in ${env.name}`); + } + }); + + if (modified) { + fs.writeFileSync(envFilePath, content); + } + } +}); + +console.log("Done!"); \ No newline at end of file diff --git a/ProxyAgent-CSharp/M365Agent/scripts/guid-encoder.js b/ProxyAgent-CSharp/M365Agent/scripts/guid-encoder.js new file mode 100644 index 00000000..70cff80e --- /dev/null +++ b/ProxyAgent-CSharp/M365Agent/scripts/guid-encoder.js @@ -0,0 +1,132 @@ +/** + * GUID Encoder - Converts GUID to Base64URL encoded format + * + * This script converts a GUID (UUID) to Base64URL encoding using little-endian + * byte order for the first three parts (matching .NET GUID structure). + * + * Usage: + * node guid-encoder.js + * + * Example: + * node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + */ + +const crypto = require('crypto'); + +/** + * Converts a GUID string to Base64URL encoded format + * @param {string} guid - The GUID to encode (with or without hyphens) + * @returns {string} Base64URL encoded GUID + */ +function encodeGuidToBase64Url(guid) { + // Remove hyphens and convert to lowercase + const guidNoDashes = guid.replace(/-/g, '').toLowerCase(); + + // Validate GUID format (32 hex characters) + if (!/^[0-9a-f]{32}$/i.test(guidNoDashes)) { + throw new Error(`Invalid GUID format: ${guid}`); + } + + // Extract parts of the GUID + // GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + // Byte order needs to be adjusted for little-endian encoding + const part1 = guidNoDashes.substring(0, 8); // First 8 chars (4 bytes) + const part2 = guidNoDashes.substring(8, 12); // Next 4 chars (2 bytes) + const part3 = guidNoDashes.substring(12, 16); // Next 4 chars (2 bytes) + const part4 = guidNoDashes.substring(16, 32); // Last 16 chars (8 bytes) + + // Reverse byte order for first three parts (little-endian) + // This matches how .NET stores GUIDs in memory + let hexBytes = ''; + + // Part 1: Reverse 4 bytes (8 hex chars) + hexBytes += part1.substring(6, 8) + part1.substring(4, 6) + + part1.substring(2, 4) + part1.substring(0, 2); + + // Part 2: Reverse 2 bytes (4 hex chars) + hexBytes += part2.substring(2, 4) + part2.substring(0, 2); + + // Part 3: Reverse 2 bytes (4 hex chars) + hexBytes += part3.substring(2, 4) + part3.substring(0, 2); + + // Part 4: Keep original order (big-endian) + hexBytes += part4; + + // Convert hex string to Buffer + const buffer = Buffer.from(hexBytes, 'hex'); + + // Convert to base64 + const base64 = buffer.toString('base64'); + + // Convert to Base64URL format: + // - Replace + with - + // - Replace / with _ + // - Remove padding (=) + const base64Url = base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + return base64Url; +} + +/** + * Main execution + */ +function main() { + // Get GUID from command line arguments + const guid = process.argv[2]; + const quietMode = process.argv.includes('--quiet') || process.argv.includes('-q'); + + if (!guid) { + console.error('Error: GUID argument is required'); + console.error(''); + console.error('Usage: node guid-encoder.js [--quiet]'); + console.error(''); + console.error('Example:'); + console.error(' node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890"'); + console.error(' node guid-encoder.js "a1b2c3d4-e5f6-7890-abcd-ef1234567890" --quiet'); + process.exit(1); + } + + try { + // Suppress debug output in quiet mode + if (!quietMode) { + console.log(`Converting GUID: ${guid}`); + } + + const encoded = encodeGuidToBase64Url(guid); + + if (quietMode) { + // Quiet mode: Only output EncodedTenantID=xxxx + console.log(`EncodedTenantID=${encoded}`); + } else { + // Verbose mode: Output detailed information + const result = { + guid: guid, + encodedGuid: encoded + }; + + console.log(''); + console.log('Result:'); + console.log(JSON.stringify(result, null, 2)); + + console.log(''); + console.log('Encoded GUID:'); + console.log(encoded); + } + + return encoded; + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// Export for use as module +module.exports = { encodeGuidToBase64Url }; + +// Run if executed directly +if (require.main === module) { + main(); +} diff --git a/ProxyAgent-CSharp/ProxyAgent-CSharp.code-workspace b/ProxyAgent-CSharp/ProxyAgent-CSharp.code-workspace new file mode 100644 index 00000000..8065d393 --- /dev/null +++ b/ProxyAgent-CSharp/ProxyAgent-CSharp.code-workspace @@ -0,0 +1,41 @@ +{ + "folders": [ + { + "name": "M365 Agent (ATK Project)", + "path": "M365Agent" + }, + { + "name": "Agent C# Codebase", + "path": "AzureAgentToM365ATK" + }, + { + "name": "Root", + "path": "." + } + ], + "settings": { + "debug.onTaskErrors": "debugAnyway", + "json.schemas": [ + { + "fileMatch": [ + "**/m365agents*.yml" + ], + "url": "https://aka.ms/teams-toolkit/v1.8/yaml.schema.json" + }, + { + "fileMatch": [ + "**/appPackage/manifest.json" + ], + "url": "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json" + } + ], + "files.exclude": { + "**/bin": true, + "**/obj": true + }, + "files.watcherExclude": { + "**/bin": true, + "**/obj": true + } + } +} diff --git a/ProxyAgent-CSharp/README.md b/ProxyAgent-CSharp/README.md new file mode 100644 index 00000000..f32bafa4 --- /dev/null +++ b/ProxyAgent-CSharp/README.md @@ -0,0 +1,748 @@ +# Microsoft Foundry Agent for Microsoft 365 + +> **Making Microsoft Foundry Agents available in Microsoft 365 Copilot and Teams using the Microsoft 365 Agents Toolkit.** + +This solution demonstrates how to integrate a Microsoft Foundry agent with Microsoft Teams and Microsoft 365 Copilot, providing a seamless experience for users to interact with powerful AI capabilities directly within their productivity tools. + +[![Video Tutorial](https://img.youtube.com/vi/U9Yv2vjKYbI/0.jpg)](https://www.youtube.com/watch?v=U9Yv2vjKYbI) + +## This sample illustrates +- How to connect an AI Foundry Agent to M365 Copilot +- How to setup use the Agent SDK with managed Identity so you no longer maintain secrets +- How to setup and use SSO in M365 Copilot & Teams and pass the user token AI Foundry using Agent SDK +- How to Configure SSO with Federated Credentials so your SSO flow does not have any secerts (Single Tenant Only) + +--- + +## 🔄 Architecture Flow + +```mermaid +sequenceDiagram + %% Groups + box "User" + participant U as Copilot User + end + + box "Microsoft 365" + participant M as Microsoft 365 Copilot + end + + box "Custom Engine Agent" + participant B as Azure Bot Service + participant P as Proxy Agent (Agents SDK) + end + + box "Microsoft Foundry" + participant A as AI Agent Backend + end + + %% Flow + U->>M: User prompt (e.g., "Create a report") + M->>B: Activity (Message) + B->>P: POST /api/messages (JWT) + P->>A: POST /process { prompt } + A-->>P: { content } + P-->>B: sendActivity(content) + B-->>M: Response + M-->>U: Display result +``` + +This proxy pattern allows you to: +- ✅ Connect existing AI agents to Microsoft 365 Copilot +- ✅ Maintain your AI logic in Microsoft Foundry +- ✅ Provide seamless user experience in Teams and Copilot with SSO +- ✅ Handle authentication and message routing automatically + +--- + +## 🚀 Quick Start + +Choose your deployment approach: + +### Local Development (Debugging) +Perfect for development and testing with breakpoints and hot reload. + +> **Note:** This solution currently supports **VS Code only**. Visual Studio support is not yet available. + +**See:** [M365Agent/LOCAL_DEPLOYMENT.md](M365Agent/LOCAL_DEPLOYMENT.md) + +```powershell +# Press F5 in VS Code +# Agent is automatically sideloaded in Teams/M365 Copilot for testing +``` + +### Azure Production Deployment +Deploy your agent to Azure for production or dev environments. + +**See:** [M365Agent/AZURE_DEPLOYMENT.md](M365Agent/AZURE_DEPLOYMENT.md) + +**Using Microsoft 365 Agents Toolkit in VS Code:** +1. Open the **Microsoft 365 Agents Toolkit** extension panel +2. Select **Lifecycle** section +3. Click **Provision** to create Azure resources +4. Click **Deploy** to publish your bot application + +**Alternatively, using CLI:** +```powershell +cd M365Agent +atk provision --env dev +atk deploy --env dev +``` + +--- + +## 📋 Prerequisites + +### Required Tools +- **.NET SDK 9.0** - [Download](https://dotnet.microsoft.com/download/dotnet/9.0) +- **Azure CLI** - [Install Guide](https://learn.microsoft.com/cli/azure/install-azure-cli) +- **Microsoft 365 Agents Toolkit CLI** - [Install Guide](https://aka.ms/m365agentstoolkit-cli) +- **Visual Studio Code** with C# Dev Kit extension + +> **Important:** This solution currently supports **VS Code only**. Visual Studio support is planned for future releases. + +### Required Services +- **Microsoft Foundry Project** with a configured agent +- **Microsoft 365 tenant** with Teams or Copilot access +- **Azure subscription** with appropriate permissions + +--- + +## 🏗️ Solution Architecture + +This solution consists of two main components: + +### 1. Bot Application (`AzureAgentToM365ATK/`) +.NET 9 bot application that serves as a proxy between Microsoft 365 and Microsoft Foundry. + +**Key Features:** +- Connects to Microsoft Foundry Agent Service +- Handles user authentication and SSO +- Manages conversation threads and message routing +- Built on Microsoft 365 Agents SDK + +### 2. M365 Agents Toolkit Project (`M365Agent/`) +Infrastructure and configuration for Microsoft 365 integration. + +**Includes:** +- Bicep templates for Azure infrastructure deployment +- Teams app manifest configuration +- Environment configuration files +- Automated provisioning and deployment workflows + +``` +ProxyAgent/ +├── AzureAgentToM365ATK/ # C# Bot Application (.NET 9) +│ ├── Program.cs # Bot setup and configuration +│ ├── Agents/ +│ │ └── AzureAgent.cs # Microsoft Foundry integration +│ ├── appsettings.json # Configuration +│ └── appsettings.Development.json # Local dev settings +│ +├── M365Agent/ # Microsoft 365 Agents Toolkit Project +│ ├── appPackage/ # Teams app package +│ │ ├── manifest.json # App manifest template +│ │ └── build/ # Generated manifests +│ ├── infra/ # Infrastructure as Code +│ │ ├── azure.bicep # Production deployment +│ │ ├── azure-local.bicep # Local development +│ │ └── modules/ # Reusable Bicep modules +│ ├── env/ # Environment variables +│ │ ├── .env.dev # Azure environment +│ │ └── .env.local # Local environment +│ ├── m365agents.yml # Production orchestration +│ ├── m365agents.local.yml # Local orchestration +│ ├── AZURE_DEPLOYMENT.md # 📘 Azure deployment guide +│ └── LOCAL_DEPLOYMENT.md # 📘 Local development guide +│ +├── images/ # Screenshots and diagrams +└── README.md # This file +``` + +--- + +## 📚 Documentation + +### Deployment Guides + +| Guide | Purpose | When to Use | +|-------|---------|-------------| +| **[LOCAL_DEPLOYMENT.md](M365Agent/LOCAL_DEPLOYMENT.md)** | Complete local development setup with debugging | Development, testing, and debugging with breakpoints | +| **[AZURE_DEPLOYMENT.md](M365Agent/AZURE_DEPLOYMENT.md)** | Complete Azure production deployment | Production, staging, or shared dev environments | + +### Technical References + +| Document | Purpose | +|----------|---------| +| **[GUID_ENCODER_GUIDE.md](M365Agent/infra/modules/GUID_ENCODER_GUIDE.md)** | GUID encoding for federated credentials | +| **[BOT_OAUTH_CONNECTION.md](M365Agent/infra/modules/BOT_OAUTH_CONNECTION.md)** | OAuth connection configuration | + +--- + +## ⚙️ Configuration + +### Microsoft Foundry Setup + +1. **Create an Agent in Microsoft Foundry Portal:** + - Configure the model (GPT-4, GPT-4 Turbo, etc.) + - Set instructions and personality + - Add tools and capabilities (Code Interpreter, Functions, etc.) + - Note the Agent ID (starts with `asst_...`) + +2. **Get Connection Details:** + - Project Endpoint URL + - Agent ID + + ![Microsoft Foundry Portal](images/screen000b.jpg) + +3. **Update Configuration:** + + Edit `AzureAgentToM365ATK/appsettings.json`: + ```json + { + "AzureAIFoundryProjectEndpoint": "https://your-project.cognitiveservices.azure.com/", + "AgentID": "asst_..." + } + ``` + +### Authentication for Bot Service + +The bot uses **Azure Managed Identity** (production) or **Single Tenant + Client Secret** (local development) to secure Azure Bot Service connection. + +**Local Development:** +```json +{ + "MicrosoftAppType": "SingleTenant", + "MicrosoftAppId": "", + "MicrosoftAppPassword": "", + "MicrosoftAppTenantId": "" +} +``` + +**Production (Managed Identity):** +```json +{ + "MicrosoftAppType": "UserAssignedMSI", + "MicrosoftAppId": "", + "MicrosoftAppTenantId": "" +} +``` + +--- + +## 🎯 Usage Scenarios + +### In Microsoft Teams + +![Teams Integration](images/screen008.jpg) + +1. Install the app in Teams (via app package upload) +2. Start a chat with the bot +3. Ask questions or give commands +4. The bot routes requests to your Microsoft Foundry agent +5. Get AI-powered responses with context awareness + +### In Microsoft 365 Copilot + +![M365 Copilot Integration](images/screen009.jpg) + +1. Access via https://m365copilot.com/ +2. Find your agent in the left sidebar +3. Click "Open with Copilot" +4. Use natural language to interact with your Microsoft Foundry agent +5. Seamless integration with other M365 services + +--- + +## 🔧 Development Workflow + +### Local Development Cycle + +1. **Run Bot Locally** (Press F5 in VS Code) + - Agent is automatically sideloaded in Teams/M365 Copilot + - Set breakpoints in your code + - Test directly in Teams or Copilot + - Iterate quickly without deployment + +2. **Debug and Test** + - Full end-to-end testing in real Teams/Copilot environment + - Live debugging with breakpoints + - Hot reload for rapid development + +### Deployment to Azure + +1. **Configure Environment** + ```bash + # Edit M365Agent/env/.env.dev + AZURE_SUBSCRIPTION_ID= + RESOURCE_SUFFIX=prod123 + ``` + +2. **Provision and Deploy using Microsoft 365 Agents Toolkit:** + + **In VS Code:** + - Open the **Microsoft 365 Agents Toolkit** extension panel + - Under **Lifecycle**, click **Provision** to create Azure resources + - Then click **Deploy** to publish your bot application + + **Or using CLI:** + ```powershell + cd M365Agent + atk provision --env dev + atk deploy --env dev + ``` + +3. **Install in Teams/Copilot** + - Upload app package from `M365Agent/appPackage/build/` + - Test in production environment + +--- + +## 🌟 Features + +### ✅ Single Sign-On (SSO) +- Seamless authentication with federated credentials +- No additional login prompts for users +- Secure token exchange + +### ✅ Managed Identity (Production) +- No passwords or secrets to manage +- Automatic credential rotation +- Enhanced security posture + +### ✅ Infrastructure as Code +- Repeatable deployments with Bicep +- Version-controlled infrastructure +- Easy environment replication + +### ✅ Full Debugging Support +- Set breakpoints in VS Code +- Hot reload for rapid iteration +- Automatic sideloading in Teams/M365 Copilot +- Real-time testing in production environment + +### ✅ Multi-Environment Support +- Separate configurations for local, dev, staging, production +- Environment-specific settings +- Isolated deployments + +--- + +## 💰 Cost Estimates + +### Local Development +- **Azure Bot Service (F0):** Free (up to 10,000 messages/month) +- **No App Service costs** (running locally) +- **Total:** ~$0/month + +### Azure Production (Basic) +- **App Service Plan (B1):** ~$13/month +- **Bot Service (F0):** Free +- **Managed Identity:** Free +- **Total:** ~$13/month + +### Azure Production (Standard) +- **App Service Plan (S1):** ~$70/month +- **Bot Service (S1):** ~$0.50 per 1,000 messages +- **Application Insights:** ~$2-10/month (if enabled) +- **Total:** ~$70-100/month + +**See detailed cost breakdown in:** [AZURE_DEPLOYMENT.md](M365Agent/AZURE_DEPLOYMENT.md#cost-estimates) + +--- + +## 🔍 Troubleshooting + +### Bot Not Responding +- ✅ Check dev tunnel is running (local) or App Service is started (Azure) +- ✅ Verify bot endpoint in Azure Bot Service configuration +- ✅ Check application logs for errors +- ✅ Verify Microsoft Foundry agent is accessible + +### SSO Not Working +- ✅ Check `webApplicationInfo` in app manifest +- ✅ Verify federated credentials in Entra ID app registration +- ✅ Check pre-authorized clients include Teams client IDs +- ✅ Review OAuth connection configuration + +### Deployment Failures +- ✅ Verify Azure CLI login and subscription access +- ✅ Check required permissions (Contributor + Application Administrator) +- ✅ Review Bicep deployment errors in Azure Portal +- ✅ Ensure resource names are unique + +**Full troubleshooting guides:** +- [Local Development Troubleshooting](M365Agent/LOCAL_DEPLOYMENT.md#troubleshooting) +- [Azure Deployment Troubleshooting](M365Agent/AZURE_DEPLOYMENT.md#troubleshooting) + +--- + +## 📖 Additional Resources + +### Microsoft 365 Agents Toolkit +- [Microsoft 365 Agents Toolkit Documentation](https://aka.ms/teams-toolkit-docs) +- [Microsoft 365 Agents Toolkit GitHub](https://github.com/OfficeDev/TeamsFx) +- [Teams App Development Guide](https://learn.microsoft.com/microsoftteams/platform/) + +### Microsoft Foundry +- [Announcing Developer Essentials for Agents and Apps in Microsoft Foundry](https://devblogs.microsoft.com/foundry/announcing-developer-essentials-for-agents-and-apps-in-azure-ai-foundry/) +- [Microsoft Foundry Agent Service (General Availability)](https://techcommunity.microsoft.com/blog/azure-ai-services-blog/announcing-general-availability-of-azure-ai-foundry-agent-service/4414352) +- [Microsoft Foundry Documentation](https://learn.microsoft.com/azure/ai-services/) + +### Microsoft 365 Agents SDK & Azure Bot Service +- [Microsoft 365 Agents SDK](https://github.com/microsoft/agents) +- [Azure Bot Service Documentation](https://learn.microsoft.com/azure/bot-service/) + + +### Tutorials & Labs +- [Build your own agent with the M365 Agents SDK and Semantic Kernel](https://microsoft.github.io/copilot-camp/pages/custom-engine/agents-sdk/) +- [Video Tutorial: Microsoft Foundry Agent in M365 Copilot](https://www.youtube.com/watch?v=U9Yv2vjKYbI) + +--- + +## 🎓 Tutorial: Creating a Stock Agent in Microsoft Foundry + +This tutorial shows you how to create the same Stock Agent demonstrated in the video and screenshots above. + +### Overview + +The Stock Agent retrieves historical stock market data using an external API and displays it in a conversational format. It demonstrates: +- ✅ OpenAPI tool integration +- ✅ API key authentication +- ✅ Multi-agent orchestration +- ✅ Code Interpreter for date calculations + +--- + +### Step 1: Create the Main Stocks Agent + +Create a new Agent in Microsoft Foundry Portal with the following details: + +- **Name:** `Stocks Agent` +- **Deployment:** GPT-4o, GPT-4.1, or GPT-4 Turbo +- **Instructions:** + ``` + You are an agent to search for a specific stock value using the function 'getTimeSeries'. + Show the data in a table except if there is a unique value returned. + end_date MUST be strictly superior to start_date, never send the same value for the 2 parameters. + ``` +- **Agent Description:** `Retrieve the value of a stock at a specific time` + +--- + +### Step 2: Create API Connection for Authentication + +The Stock API requires authentication via API key. We'll create a secure connection to manage this. + +1. **Get an API Key:** + - Visit [Twelve Data](https://support.twelvedata.com/en/articles/5335783-trial) + - Register for a free API key **OR** use the demo key `demo` (limited to AAPL stock only) + +2. **Create Connection in Microsoft Foundry:** + - Go to **Management Center** → **New connection** + - Choose **Custom keys** at the end of the selection page + - Create key-value pair: + - **Name:** `apikey` + - **Value:** Your API key or `demo` + - ✅ Check **"is secret"** + - **Connection Name:** `StockAPI` + - Click **Save** + +--- + +### Step 3: Add OpenAPI Tool to the Agent + +Go back to your **Stocks Agent** in the Microsoft Foundry project portal. + +1. Click **Add an Action** → **OpenAPI 3.0 specified tool** + +2. **Configure the tool:** + - **Name:** `StocksAPI` + - **Description:** `API for retrieving historical time series data for financial instruments with optional filters like start_date, end_date, and outputsize.` + - **Authentication method:** Select **Connection** → Choose **StockAPI** + +3. **Copy/paste the following OpenAPI specification:** +```json +{ + "openapi": "3.0.3", + "info": { + "title": "Twelve Data Time Series API", + "description": "API for retrieving historical time series data for financial instruments with optional filters like `start_date`, `end_date`, and `outputsize`.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.twelvedata.com" + } + ], + "paths": { + "/time_series": { + "get": { + "summary": "Retrieve historical time series data", + "description": "Retrieves historical time series data for a specified financial instrument. The `start_date` and `end_date` parameters can be used to define boundaries for the data. The maximum number of data points in one request is 5000.", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "description": "The symbol of the financial instrument (e.g., AAPL for Apple Inc.).", + "schema": { + "type": "string" + } + }, + { + "name": "interval", + "in": "query", + "required": true, + "description": "The time interval between data points (e.g., 1day, 1min).", + "schema": { + "type": "string" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "description": "The start date of the time series data in ISO format (YYYY-MM-DD). Must be greater than `outputsize` if used alone. Must be absolutely strictly inferior to `end_date`", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "description": "The end date of the time series data in ISO format (YYYY-MM-DD). Defines the upper limit of the data range. Must be absolutely strictly superior to `start_date`", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "outputsize", + "in": "query", + "required": false, + "description": "The number of data points to return. Defaults to 30 if not specified. Maximum value is 5000.", + "schema": { + "type": "integer" + } + }, + { + "name": "apikey", + "in": "query", + "required": true, + "description": "Your API key for authentication.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A successful response with the time series data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "description": "Metadata about the request and time series." + }, + "values": { + "type": "array", + "description": "The list of time series data points.", + "items": { + "type": "object", + "properties": { + "datetime": { + "type": "string", + "format": "date-time", + "description": "The timestamp of the data point." + }, + "open": { + "type": "number", + "description": "The opening price." + }, + "high": { + "type": "number", + "description": "The highest price." + }, + "low": { + "type": "number", + "description": "The lowest price." + }, + "close": { + "type": "number", + "description": "The closing price." + }, + "volume": { + "type": "integer", + "description": "The traded volume." + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad request due to invalid parameters." + }, + "401": { + "description": "Unauthorized, invalid API key." + }, + "500": { + "description": "Internal server error." + } + }, + "operationId": "getTimeSeries" + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "query", + "name": "apikey" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + } + ] +} +``` +4. Click **Next** → **Create Tool** + +--- + +### Step 4: Test the Agent (First Attempt) + +Go to the **Playground** and test your agent: + +``` +You: "What was the MSFT stock value in the last 2 weeks?" +``` + +![Agent using the wrong date](images/screen010.jpg) + +**Problem:** The agent doesn't know today's date, so the time period is incorrect! + +--- + +### Step 5: Create Date Helper Agent + +To fix this, we'll create a helper agent that can determine the current date using Code Interpreter. + +1. **Create a new Agent:** + - **Name:** `Get Today Date` + - **Deployment:** GPT-4o, GPT-4.1, or GPT-4 Turbo + - **Instructions:** + ``` + Using the code interpreter feature, please find the current today date and + returns its value to be used by another agent + ``` + - **Agent Description:** `Returns the current date` + +2. **Add Code Interpreter:** + - Click **Add an Action** → **Code Interpreter** + - Use default parameters + +--- + +### Step 6: Connect Helper Agent to Stocks Agent + +1. Go back to your **Stocks Agent** +2. Click **Add a Connect agent** +3. **Select the agent:** `Get Today Date` +4. **Unique name:** `GetTodayDate` +5. **Steps to activate the agent:** + ``` + Use this agent when you need to know the current today's date + ``` +6. Click **Add** + +--- + +### Step 7: Test the Complete Solution + +Go back to the **Playground** and ask the same question again: + +``` +You: "What was the MSFT stock value in the last 2 weeks?" +``` + +![Agent using the proper current date](images/screen012.jpg) + +**Success!** The agent now correctly: +1. ✅ Calls the "Get Today Date" agent to determine the current date +2. ✅ Calculates the date range (last 2 weeks) +3. ✅ Calls the Stock API with correct parameters +4. ✅ Displays results in a formatted table + +--- + +### Debugging Agent Execution + +Click **Threads logs** in the Playground to see the execution flow: + +![Debugging the thread actions selection](images/screen011.jpg) + +This shows you: +- Which agents were invoked +- What tools were called +- The order of execution +- Parameters passed between agents + +--- + +### Next Steps + +Now that you have a working Stock Agent, you can: +- **Integrate with this solution** by updating `appsettings.json` with your agent details +- **Test in Teams** using the local deployment guide +- **Deploy to Azure** for production use +- **Extend functionality** by adding more tools or connected agents + +**See:** [LOCAL_DEPLOYMENT.md](M365Agent/LOCAL_DEPLOYMENT.md) or [AZURE_DEPLOYMENT.md](M365Agent/AZURE_DEPLOYMENT.md) + +--- + +## Known issues +- Local Debug fails to open the solution directly in the browser. You'll need to navigate to the solution manually. +- Agent Toolkit Step ExtendToM365 fails from time to time. If it happens that means that the sideloading of your packaged failed and you should do it manually with the package that was automatically provisionned for you. + + +## 👥 Contributors + +This project was built with contributions from: + +- **[@ericsche](https://github.com/ericsche)** - Project Lead & Development +- **[@DavidRoussel](https://github.com/DavidRoussel)** - Co-Author & Technical Contributions +- **[@MattB-msft](https://github.com/MattB-msft)** - Co-Author & Guidance +- **[@garrytrinder](https://github.com/garrytrinder)** - ATK Guidance & Review + +Special thanks to everyone who contributed to making this solution possible! + +--- + +## 📝 Version History + +|Date| Author| Comments| +|---|---|---| +|Nov 13, 2025| ericsche | V1 Release built with David Rousset| + +--- + +## 📄 License + +This project is licensed under the terms specified in the [LICENSE](LICENSE) file. + + diff --git a/ProxyAgent-CSharp/assets/sampleDemo.gif b/ProxyAgent-CSharp/assets/sampleDemo.gif new file mode 100644 index 00000000..53dc1633 Binary files /dev/null and b/ProxyAgent-CSharp/assets/sampleDemo.gif differ diff --git a/ProxyAgent-CSharp/assets/thumbnail.jpg b/ProxyAgent-CSharp/assets/thumbnail.jpg new file mode 100644 index 00000000..c1b5ed61 Binary files /dev/null and b/ProxyAgent-CSharp/assets/thumbnail.jpg differ diff --git a/ProxyAgent-CSharp/images/VSscreen001.png b/ProxyAgent-CSharp/images/VSscreen001.png new file mode 100644 index 00000000..a9346ba3 Binary files /dev/null and b/ProxyAgent-CSharp/images/VSscreen001.png differ diff --git a/ProxyAgent-CSharp/images/screen000.jpg b/ProxyAgent-CSharp/images/screen000.jpg new file mode 100644 index 00000000..d3b78cd3 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen000.jpg differ diff --git a/ProxyAgent-CSharp/images/screen000b.jpg b/ProxyAgent-CSharp/images/screen000b.jpg new file mode 100644 index 00000000..7b24b86c Binary files /dev/null and b/ProxyAgent-CSharp/images/screen000b.jpg differ diff --git a/ProxyAgent-CSharp/images/screen000c.jpg b/ProxyAgent-CSharp/images/screen000c.jpg new file mode 100644 index 00000000..2e54441e Binary files /dev/null and b/ProxyAgent-CSharp/images/screen000c.jpg differ diff --git a/ProxyAgent-CSharp/images/screen001.jpg b/ProxyAgent-CSharp/images/screen001.jpg new file mode 100644 index 00000000..29799634 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen001.jpg differ diff --git a/ProxyAgent-CSharp/images/screen002.jpg b/ProxyAgent-CSharp/images/screen002.jpg new file mode 100644 index 00000000..b7f5336c Binary files /dev/null and b/ProxyAgent-CSharp/images/screen002.jpg differ diff --git a/ProxyAgent-CSharp/images/screen003.jpg b/ProxyAgent-CSharp/images/screen003.jpg new file mode 100644 index 00000000..69826b55 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen003.jpg differ diff --git a/ProxyAgent-CSharp/images/screen004.jpg b/ProxyAgent-CSharp/images/screen004.jpg new file mode 100644 index 00000000..932b6146 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen004.jpg differ diff --git a/ProxyAgent-CSharp/images/screen005.jpg b/ProxyAgent-CSharp/images/screen005.jpg new file mode 100644 index 00000000..2c4c12fc Binary files /dev/null and b/ProxyAgent-CSharp/images/screen005.jpg differ diff --git a/ProxyAgent-CSharp/images/screen006.jpg b/ProxyAgent-CSharp/images/screen006.jpg new file mode 100644 index 00000000..197c6d0e Binary files /dev/null and b/ProxyAgent-CSharp/images/screen006.jpg differ diff --git a/ProxyAgent-CSharp/images/screen007.jpg b/ProxyAgent-CSharp/images/screen007.jpg new file mode 100644 index 00000000..a009488d Binary files /dev/null and b/ProxyAgent-CSharp/images/screen007.jpg differ diff --git a/ProxyAgent-CSharp/images/screen008.jpg b/ProxyAgent-CSharp/images/screen008.jpg new file mode 100644 index 00000000..d3f8f3f7 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen008.jpg differ diff --git a/ProxyAgent-CSharp/images/screen009.jpg b/ProxyAgent-CSharp/images/screen009.jpg new file mode 100644 index 00000000..c1b5ed61 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen009.jpg differ diff --git a/ProxyAgent-CSharp/images/screen010.jpg b/ProxyAgent-CSharp/images/screen010.jpg new file mode 100644 index 00000000..bedd009b Binary files /dev/null and b/ProxyAgent-CSharp/images/screen010.jpg differ diff --git a/ProxyAgent-CSharp/images/screen011.jpg b/ProxyAgent-CSharp/images/screen011.jpg new file mode 100644 index 00000000..e38a900b Binary files /dev/null and b/ProxyAgent-CSharp/images/screen011.jpg differ diff --git a/ProxyAgent-CSharp/images/screen012.jpg b/ProxyAgent-CSharp/images/screen012.jpg new file mode 100644 index 00000000..ca33eff5 Binary files /dev/null and b/ProxyAgent-CSharp/images/screen012.jpg differ